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

Compare changes

Choose any two refs to compare.

+4 -4
Dockerfile.appview
··· 1 1 # Production build for ATCR AppView 2 2 # Result: ~30MB scratch image with static binary 3 - FROM docker.io/golang:1.25.4-trixie AS builder 3 + FROM docker.io/golang:1.25.2-trixie AS builder 4 4 5 5 ENV DEBIAN_FRONTEND=noninteractive 6 6 ··· 34 34 LABEL org.opencontainers.image.title="ATCR AppView" \ 35 35 org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \ 36 36 org.opencontainers.image.authors="ATCR Contributors" \ 37 - org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \ 38 - org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \ 37 + org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 38 + org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 39 39 org.opencontainers.image.licenses="MIT" \ 40 40 org.opencontainers.image.version="0.1.0" \ 41 41 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" \ 42 - io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" 42 + io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" 43 43 44 44 ENTRYPOINT ["/atcr-appview"] 45 45 CMD ["serve"]
+1 -1
Dockerfile.dev
··· 1 1 # Development image with Air hot reload 2 2 # Build: docker build -f Dockerfile.dev -t atcr-appview-dev . 3 3 # Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev 4 - FROM docker.io/golang:1.25.4-trixie 4 + FROM docker.io/golang:1.25.2-trixie 5 5 6 6 ENV DEBIAN_FRONTEND=noninteractive 7 7
+4 -4
Dockerfile.hold
··· 1 - FROM docker.io/golang:1.25.4-trixie AS builder 1 + FROM docker.io/golang:1.25.2-trixie AS builder 2 2 3 3 ENV DEBIAN_FRONTEND=noninteractive 4 4 ··· 38 38 LABEL org.opencontainers.image.title="ATCR Hold Service" \ 39 39 org.opencontainers.image.description="ATCR Hold Service - Bring Your Own Storage component for ATCR" \ 40 40 org.opencontainers.image.authors="ATCR Contributors" \ 41 - org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \ 42 - org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \ 41 + org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 42 + org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 43 43 org.opencontainers.image.licenses="MIT" \ 44 44 org.opencontainers.image.version="0.1.0" \ 45 45 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE" \ 46 - io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/hold.md" 46 + io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/hold.md" 47 47 48 48 ENTRYPOINT ["/atcr-hold"]
+18 -15
cmd/appview/serve.go
··· 82 82 slog.Info("Initializing hold health checker", "cache_ttl", cfg.Health.CacheTTL) 83 83 healthChecker := holdhealth.NewChecker(cfg.Health.CacheTTL) 84 84 85 - // Initialize README fetcher for rendering repo page descriptions 86 - readmeFetcher := readme.NewFetcher() 85 + // Initialize README cache 86 + slog.Info("Initializing README cache", "cache_ttl", cfg.Health.ReadmeCacheTTL) 87 + readmeCache := readme.NewCache(uiDatabase, cfg.Health.ReadmeCacheTTL) 87 88 88 89 // Start background health check worker 89 90 startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose) ··· 157 158 holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode) 158 159 middleware.SetGlobalAuthorizer(holdAuthorizer) 159 160 slog.Info("Hold authorizer initialized with database caching") 161 + 162 + // Set global readme cache for middleware 163 + middleware.SetGlobalReadmeCache(readmeCache) 164 + slog.Info("README cache initialized for manifest push refresh") 160 165 161 166 // Initialize Jetstream workers (background services before HTTP routes) 162 - initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode, refresher) 167 + initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode) 163 168 164 169 // Create main chi router 165 170 mainRouter := chi.NewRouter() ··· 189 194 BaseURL: baseURL, 190 195 DeviceStore: deviceStore, 191 196 HealthChecker: healthChecker, 192 - ReadmeFetcher: readmeFetcher, 197 + ReadmeCache: readmeCache, 193 198 Templates: uiTemplates, 194 199 }) 195 200 } ··· 271 276 } 272 277 273 278 var holdDID string 274 - if profile != nil && profile.DefaultHold != "" { 279 + if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" { 280 + defaultHold := *profile.DefaultHold 275 281 // Check if defaultHold is a URL (needs migration) 276 - if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") { 277 - slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold) 282 + if strings.HasPrefix(defaultHold, "http://") || strings.HasPrefix(defaultHold, "https://") { 283 + slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", defaultHold) 278 284 279 285 // Resolve URL to DID 280 - holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 286 + holdDID = atproto.ResolveHoldDIDFromURL(defaultHold) 281 287 282 288 // Update profile with DID 283 - profile.DefaultHold = holdDID 289 + profile.DefaultHold = &holdDID 284 290 if err := storage.UpdateProfile(ctx, client, profile); err != nil { 285 291 slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err) 286 292 } else { ··· 288 294 } 289 295 } else { 290 296 // Already a DID - use it 291 - holdDID = profile.DefaultHold 297 + holdDID = defaultHold 292 298 } 293 299 // Register crew regardless of migration (outside the migration block) 294 300 // Run in background to avoid blocking OAuth callback if hold is offline ··· 392 398 393 399 w.Header().Set("Content-Type", "application/json") 394 400 w.Header().Set("Access-Control-Allow-Origin", "*") 395 - // Limit caching to allow scope changes to propagate quickly 396 - // PDS servers cache client metadata, so short max-age helps with updates 397 - w.Header().Set("Cache-Control", "public, max-age=300") 398 401 if err := json.NewEncoder(w).Encode(metadataMap); err != nil { 399 402 http.Error(w, "Failed to encode metadata", http.StatusInternalServerError) 400 403 } ··· 517 520 } 518 521 519 522 // initializeJetstream initializes the Jetstream workers for real-time events and backfill 520 - func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) { 523 + func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool) { 521 524 // Start Jetstream worker 522 525 jetstreamURL := jetstreamCfg.URL 523 526 ··· 541 544 // Get relay endpoint for sync API (defaults to Bluesky's relay) 542 545 relayEndpoint := jetstreamCfg.RelayEndpoint 543 546 544 - backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode, refresher) 547 + backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode) 545 548 if err != nil { 546 549 slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err) 547 550 } else {
+4 -3
docs/TEST_COVERAGE_GAPS.md
··· 112 112 113 113 **Remaining gaps:** 114 114 - `notifyHoldAboutManifest()` - 0% (background notification, less critical) 115 + - `refreshReadmeCache()` - 11.8% (UI feature, lower priority) 115 116 116 117 ## Critical Priority: Core Registry Functionality 117 118 ··· 422 423 423 424 --- 424 425 425 - ### ๐ŸŸก pkg/appview/readme (Partial coverage) 426 + ### ๐ŸŸก pkg/appview/readme (16.7% coverage) 426 427 427 - README rendering for repo page descriptions. The cache.go was removed as README content is now stored in `io.atcr.repo.page` records and synced via Jetstream. 428 + README fetching and caching. Less critical but still needs work. 428 429 430 + #### cache.go (0% coverage) 429 431 #### fetcher.go (๐Ÿ“Š Partial coverage) 430 - - `RenderMarkdown()` - renders repo page description markdown 431 432 432 433 --- 433 434
+1 -1
go.mod
··· 1 1 module atcr.io 2 2 3 - go 1.25.4 3 + go 1.25.5 4 4 5 5 require ( 6 6 github.com/aws/aws-sdk-go v1.55.5
+6 -6
lexicons/io/atcr/manifest.json
··· 65 65 "description": "Referenced manifests (for manifest lists/indexes)" 66 66 }, 67 67 "annotations": { 68 - "type": "unknown", 69 - "description": "Optional OCI annotation metadata. Map of string keys to string values (e.g., org.opencontainers.image.title โ†’ 'My App')." 68 + "type": "object", 69 + "description": "Optional metadata annotations" 70 70 }, 71 71 "subject": { 72 72 "type": "ref", ··· 111 111 "description": "Optional direct URLs to blob (for BYOS)" 112 112 }, 113 113 "annotations": { 114 - "type": "unknown", 115 - "description": "Optional OCI annotation metadata. Map of string keys to string values." 114 + "type": "object", 115 + "description": "Optional metadata" 116 116 } 117 117 } 118 118 }, ··· 139 139 "description": "Platform information for this manifest" 140 140 }, 141 141 "annotations": { 142 - "type": "unknown", 143 - "description": "Optional OCI annotation metadata. Map of string keys to string values." 142 + "type": "object", 143 + "description": "Optional metadata" 144 144 } 145 145 } 146 146 },
-43
lexicons/io/atcr/repo/page.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "io.atcr.repo.page", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "Repository page metadata including description and avatar. Users can edit this directly in their PDS to customize their repository page.", 8 - "key": "any", 9 - "record": { 10 - "type": "object", 11 - "required": ["repository", "createdAt", "updatedAt"], 12 - "properties": { 13 - "repository": { 14 - "type": "string", 15 - "description": "The name of the repository (e.g., 'myapp'). Must match the rkey.", 16 - "maxLength": 256 17 - }, 18 - "description": { 19 - "type": "string", 20 - "description": "Markdown README/description content for the repository page.", 21 - "maxLength": 100000 22 - }, 23 - "avatar": { 24 - "type": "blob", 25 - "description": "Repository avatar/icon image.", 26 - "accept": ["image/png", "image/jpeg", "image/webp"], 27 - "maxSize": 3000000 28 - }, 29 - "createdAt": { 30 - "type": "string", 31 - "format": "datetime", 32 - "description": "Record creation timestamp" 33 - }, 34 - "updatedAt": { 35 - "type": "string", 36 - "format": "datetime", 37 - "description": "Record last updated timestamp" 38 - } 39 - } 40 - } 41 - } 42 - } 43 - }
+4
pkg/appview/config.go
··· 79 79 80 80 // CheckInterval is the hold health check refresh interval (from env: ATCR_HEALTH_CHECK_INTERVAL, default: 15m) 81 81 CheckInterval time.Duration `yaml:"check_interval"` 82 + 83 + // ReadmeCacheTTL is the README cache TTL (from env: ATCR_README_CACHE_TTL, default: 1h) 84 + ReadmeCacheTTL time.Duration `yaml:"readme_cache_ttl"` 82 85 } 83 86 84 87 // JetstreamConfig defines ATProto Jetstream settings ··· 162 165 // Health and cache configuration 163 166 cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute) 164 167 cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute) 168 + cfg.Health.ReadmeCacheTTL = getDurationOrDefault("ATCR_README_CACHE_TTL", 1*time.Hour) 165 169 166 170 // Jetstream configuration 167 171 cfg.Jetstream.URL = getEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe")
-18
pkg/appview/db/migrations/0006_add_repo_pages.yaml
··· 1 - description: Add repo_pages table and remove readme_cache 2 - query: | 3 - -- Create repo_pages table for storing repository page metadata 4 - -- This replaces readme_cache with PDS-synced data 5 - CREATE TABLE IF NOT EXISTS repo_pages ( 6 - did TEXT NOT NULL, 7 - repository TEXT NOT NULL, 8 - description TEXT, 9 - avatar_cid TEXT, 10 - created_at TIMESTAMP NOT NULL, 11 - updated_at TIMESTAMP NOT NULL, 12 - PRIMARY KEY(did, repository), 13 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 14 - ); 15 - CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did); 16 - 17 - -- Drop readme_cache table (no longer needed) 18 - DROP TABLE IF EXISTS readme_cache;
+2 -3
pkg/appview/db/models.go
··· 148 148 // TagWithPlatforms extends Tag with platform information 149 149 type TagWithPlatforms struct { 150 150 Tag 151 - Platforms []PlatformInfo 152 - IsMultiArch bool 153 - HasAttestations bool // true if manifest list contains attestation references 151 + Platforms []PlatformInfo 152 + IsMultiArch bool 154 153 } 155 154 156 155 // ManifestWithMetadata extends Manifest with tags and platform information
+8 -119
pkg/appview/db/queries.go
··· 7 7 "time" 8 8 ) 9 9 10 - // BlobCDNURL returns the CDN URL for an ATProto blob 11 - // This is a local copy to avoid importing atproto (prevents circular dependencies) 12 - func BlobCDNURL(did, cid string) string { 13 - return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid) 14 - } 15 - 16 10 // escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching. 17 11 // It also sanitizes the input to prevent injection attacks via special characters. 18 12 func escapeLikePattern(s string) string { ··· 52 46 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 53 47 COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 54 48 t.created_at, 55 - m.hold_endpoint, 56 - COALESCE(rp.avatar_cid, '') 49 + m.hold_endpoint 57 50 FROM tags t 58 51 JOIN users u ON t.did = u.did 59 52 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 60 53 LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository 61 - LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository 62 54 ` 63 55 64 56 args := []any{currentUserDID} ··· 81 73 for rows.Next() { 82 74 var p Push 83 75 var isStarredInt int 84 - var avatarCID string 85 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil { 76 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil { 86 77 return nil, 0, err 87 78 } 88 79 p.IsStarred = isStarredInt > 0 89 - // Prefer repo page avatar over annotation icon 90 - if avatarCID != "" { 91 - p.IconURL = BlobCDNURL(p.DID, avatarCID) 92 - } 93 80 pushes = append(pushes, p) 94 81 } 95 82 ··· 132 119 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 133 120 COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 134 121 t.created_at, 135 - m.hold_endpoint, 136 - COALESCE(rp.avatar_cid, '') 122 + m.hold_endpoint 137 123 FROM tags t 138 124 JOIN users u ON t.did = u.did 139 125 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 140 126 LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository 141 - LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository 142 127 WHERE u.handle LIKE ? ESCAPE '\' 143 128 OR u.did = ? 144 129 OR t.repository LIKE ? ESCAPE '\' ··· 161 146 for rows.Next() { 162 147 var p Push 163 148 var isStarredInt int 164 - var avatarCID string 165 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil { 149 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil { 166 150 return nil, 0, err 167 151 } 168 152 p.IsStarred = isStarredInt > 0 169 - // Prefer repo page avatar over annotation icon 170 - if avatarCID != "" { 171 - p.IconURL = BlobCDNURL(p.DID, avatarCID) 172 - } 173 153 pushes = append(pushes, p) 174 154 } 175 155 ··· 312 292 r.Licenses = annotations["org.opencontainers.image.licenses"] 313 293 r.IconURL = annotations["io.atcr.icon"] 314 294 r.ReadmeURL = annotations["io.atcr.readme"] 315 - 316 - // Check for repo page avatar (overrides annotation icon) 317 - repoPage, err := GetRepoPage(db, did, r.Name) 318 - if err == nil && repoPage != nil && repoPage.AvatarCID != "" { 319 - r.IconURL = BlobCDNURL(did, repoPage.AvatarCID) 320 - } 321 295 322 296 repos = append(repos, r) 323 297 } ··· 622 596 // GetTagsWithPlatforms returns all tags for a repository with platform information 623 597 // Only multi-arch tags (manifest lists) have platform info in manifest_references 624 598 // Single-arch tags will have empty Platforms slice (platform is obvious for single-arch) 625 - // Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations 626 599 func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatforms, error) { 627 600 rows, err := db.Query(` 628 601 SELECT ··· 636 609 COALESCE(mr.platform_os, '') as platform_os, 637 610 COALESCE(mr.platform_architecture, '') as platform_architecture, 638 611 COALESCE(mr.platform_variant, '') as platform_variant, 639 - COALESCE(mr.platform_os_version, '') as platform_os_version, 640 - COALESCE(mr.is_attestation, 0) as is_attestation 612 + COALESCE(mr.platform_os_version, '') as platform_os_version 641 613 FROM tags t 642 614 JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository 643 615 LEFT JOIN manifest_references mr ON m.id = mr.manifest_id ··· 657 629 for rows.Next() { 658 630 var t Tag 659 631 var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string 660 - var isAttestation bool 661 632 662 633 if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt, 663 - &mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil { 634 + &mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion); err != nil { 664 635 return nil, err 665 636 } 666 637 ··· 672 643 Platforms: []PlatformInfo{}, 673 644 } 674 645 tagOrder = append(tagOrder, tagKey) 675 - } 676 - 677 - // Track if manifest list has attestations 678 - if isAttestation { 679 - tagMap[tagKey].HasAttestations = true 680 - // Skip attestation references in platform display 681 - continue 682 646 } 683 647 684 648 // Add platform info if present (only for multi-arch manifest lists) ··· 1686 1650 COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''), 1687 1651 rs.pull_count, 1688 1652 rs.star_count, 1689 - COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0), 1690 - COALESCE(rp.avatar_cid, '') 1653 + COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0) 1691 1654 FROM latest_manifests lm 1692 1655 JOIN manifests m ON lm.latest_id = m.id 1693 1656 JOIN users u ON m.did = u.did 1694 1657 JOIN repo_stats rs ON m.did = rs.did AND m.repository = rs.repository 1695 - LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository 1696 1658 ORDER BY rs.score DESC, rs.star_count DESC, rs.pull_count DESC, m.created_at DESC 1697 1659 LIMIT ? 1698 1660 ` ··· 1707 1669 for rows.Next() { 1708 1670 var f FeaturedRepository 1709 1671 var isStarredInt int 1710 - var avatarCID string 1711 1672 1712 1673 if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, 1713 - &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt, &avatarCID); err != nil { 1674 + &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil { 1714 1675 return nil, err 1715 1676 } 1716 1677 f.IsStarred = isStarredInt > 0 1717 - // Prefer repo page avatar over annotation icon 1718 - if avatarCID != "" { 1719 - f.IconURL = BlobCDNURL(f.OwnerDID, avatarCID) 1720 - } 1721 1678 1722 1679 featured = append(featured, f) 1723 1680 } 1724 1681 1725 1682 return featured, nil 1726 1683 } 1727 - 1728 - // RepoPage represents a repository page record cached from PDS 1729 - type RepoPage struct { 1730 - DID string 1731 - Repository string 1732 - Description string 1733 - AvatarCID string 1734 - CreatedAt time.Time 1735 - UpdatedAt time.Time 1736 - } 1737 - 1738 - // UpsertRepoPage inserts or updates a repo page record 1739 - func UpsertRepoPage(db *sql.DB, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error { 1740 - _, err := db.Exec(` 1741 - INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at) 1742 - VALUES (?, ?, ?, ?, ?, ?) 1743 - ON CONFLICT(did, repository) DO UPDATE SET 1744 - description = excluded.description, 1745 - avatar_cid = excluded.avatar_cid, 1746 - updated_at = excluded.updated_at 1747 - `, did, repository, description, avatarCID, createdAt, updatedAt) 1748 - return err 1749 - } 1750 - 1751 - // GetRepoPage retrieves a repo page record 1752 - func GetRepoPage(db *sql.DB, did, repository string) (*RepoPage, error) { 1753 - var rp RepoPage 1754 - err := db.QueryRow(` 1755 - SELECT did, repository, description, avatar_cid, created_at, updated_at 1756 - FROM repo_pages 1757 - WHERE did = ? AND repository = ? 1758 - `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt) 1759 - if err != nil { 1760 - return nil, err 1761 - } 1762 - return &rp, nil 1763 - } 1764 - 1765 - // DeleteRepoPage deletes a repo page record 1766 - func DeleteRepoPage(db *sql.DB, did, repository string) error { 1767 - _, err := db.Exec(` 1768 - DELETE FROM repo_pages WHERE did = ? AND repository = ? 1769 - `, did, repository) 1770 - return err 1771 - } 1772 - 1773 - // GetRepoPagesByDID returns all repo pages for a DID 1774 - func GetRepoPagesByDID(db *sql.DB, did string) ([]RepoPage, error) { 1775 - rows, err := db.Query(` 1776 - SELECT did, repository, description, avatar_cid, created_at, updated_at 1777 - FROM repo_pages 1778 - WHERE did = ? 1779 - `, did) 1780 - if err != nil { 1781 - return nil, err 1782 - } 1783 - defer rows.Close() 1784 - 1785 - var pages []RepoPage 1786 - for rows.Next() { 1787 - var rp RepoPage 1788 - if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil { 1789 - return nil, err 1790 - } 1791 - pages = append(pages, rp) 1792 - } 1793 - return pages, rows.Err() 1794 - }
+5 -10
pkg/appview/db/schema.sql
··· 205 205 ); 206 206 CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at); 207 207 208 - CREATE TABLE IF NOT EXISTS repo_pages ( 209 - did TEXT NOT NULL, 210 - repository TEXT NOT NULL, 211 - description TEXT, 212 - avatar_cid TEXT, 213 - created_at TIMESTAMP NOT NULL, 214 - updated_at TIMESTAMP NOT NULL, 215 - PRIMARY KEY(did, repository), 216 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 208 + CREATE TABLE IF NOT EXISTS readme_cache ( 209 + url TEXT PRIMARY KEY, 210 + html TEXT NOT NULL, 211 + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 217 212 ); 218 - CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did); 213 + CREATE INDEX IF NOT EXISTS idx_readme_cache_fetched ON readme_cache(fetched_at);
-32
pkg/appview/handlers/errors.go
··· 1 - package handlers 2 - 3 - import ( 4 - "html/template" 5 - "net/http" 6 - ) 7 - 8 - // NotFoundHandler handles 404 errors 9 - type NotFoundHandler struct { 10 - Templates *template.Template 11 - RegistryURL string 12 - } 13 - 14 - func (h *NotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 15 - RenderNotFound(w, r, h.Templates, h.RegistryURL) 16 - } 17 - 18 - // RenderNotFound renders the 404 page template. 19 - // Use this from other handlers when a resource is not found. 20 - func RenderNotFound(w http.ResponseWriter, r *http.Request, templates *template.Template, registryURL string) { 21 - w.WriteHeader(http.StatusNotFound) 22 - 23 - data := struct { 24 - PageData 25 - }{ 26 - PageData: NewPageData(r, registryURL), 27 - } 28 - 29 - if err := templates.ExecuteTemplate(w, "404", data); err != nil { 30 - http.Error(w, "Page not found", http.StatusNotFound) 31 - } 32 - }
-114
pkg/appview/handlers/images.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "encoding/json" 6 - "errors" 7 6 "fmt" 8 - "io" 9 7 "net/http" 10 8 "strings" 11 - "time" 12 9 13 10 "atcr.io/pkg/appview/db" 14 11 "atcr.io/pkg/appview/middleware" ··· 158 155 159 156 w.WriteHeader(http.StatusOK) 160 157 } 161 - 162 - // UploadAvatarHandler handles uploading/updating a repository avatar 163 - type UploadAvatarHandler struct { 164 - DB *sql.DB 165 - Refresher *oauth.Refresher 166 - } 167 - 168 - // validImageTypes are the allowed MIME types for avatars (matches lexicon) 169 - var validImageTypes = map[string]bool{ 170 - "image/png": true, 171 - "image/jpeg": true, 172 - "image/webp": true, 173 - } 174 - 175 - func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 176 - user := middleware.GetUser(r) 177 - if user == nil { 178 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 179 - return 180 - } 181 - 182 - repo := chi.URLParam(r, "repository") 183 - 184 - // Parse multipart form (max 3MB to match lexicon maxSize) 185 - if err := r.ParseMultipartForm(3 << 20); err != nil { 186 - http.Error(w, "File too large (max 3MB)", http.StatusBadRequest) 187 - return 188 - } 189 - 190 - file, header, err := r.FormFile("avatar") 191 - if err != nil { 192 - http.Error(w, "No file provided", http.StatusBadRequest) 193 - return 194 - } 195 - defer file.Close() 196 - 197 - // Validate MIME type 198 - contentType := header.Header.Get("Content-Type") 199 - if !validImageTypes[contentType] { 200 - http.Error(w, "Invalid file type. Must be PNG, JPEG, or WebP", http.StatusBadRequest) 201 - return 202 - } 203 - 204 - // Read file data 205 - data, err := io.ReadAll(io.LimitReader(file, 3<<20+1)) // Read up to 3MB + 1 byte 206 - if err != nil { 207 - http.Error(w, "Failed to read file", http.StatusInternalServerError) 208 - return 209 - } 210 - if len(data) > 3<<20 { 211 - http.Error(w, "File too large (max 3MB)", http.StatusBadRequest) 212 - return 213 - } 214 - 215 - // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety) 216 - pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 217 - 218 - // Upload blob to PDS 219 - blobRef, err := pdsClient.UploadBlob(r.Context(), data, contentType) 220 - if err != nil { 221 - if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 222 - http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 223 - return 224 - } 225 - http.Error(w, fmt.Sprintf("Failed to upload image: %v", err), http.StatusInternalServerError) 226 - return 227 - } 228 - 229 - // Fetch existing repo page record to preserve description 230 - var existingDescription string 231 - var existingCreatedAt time.Time 232 - record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repo) 233 - if err == nil { 234 - // Parse existing record to preserve description 235 - var existingRecord atproto.RepoPageRecord 236 - if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil { 237 - existingDescription = existingRecord.Description 238 - existingCreatedAt = existingRecord.CreatedAt 239 - } 240 - } else if !errors.Is(err, atproto.ErrRecordNotFound) { 241 - // Some other error - check if OAuth error 242 - if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 243 - http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 244 - return 245 - } 246 - // Log but continue - we'll create a new record 247 - } 248 - 249 - // Create updated repo page record 250 - repoPage := atproto.NewRepoPageRecord(repo, existingDescription, blobRef) 251 - // Preserve original createdAt if record existed 252 - if !existingCreatedAt.IsZero() { 253 - repoPage.CreatedAt = existingCreatedAt 254 - } 255 - 256 - // Save record to PDS 257 - _, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repo, repoPage) 258 - if err != nil { 259 - if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 260 - http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 261 - return 262 - } 263 - http.Error(w, fmt.Sprintf("Failed to update repository page: %v", err), http.StatusInternalServerError) 264 - return 265 - } 266 - 267 - // Return new avatar URL 268 - avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link) 269 - w.Header().Set("Content-Type", "application/json") 270 - json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL}) 271 - }
+15 -40
pkg/appview/handlers/repository.go
··· 27 27 Directory identity.Directory 28 28 Refresher *oauth.Refresher 29 29 HealthChecker *holdhealth.Checker 30 - ReadmeFetcher *readme.Fetcher // For rendering repo page descriptions 30 + ReadmeCache *readme.Cache 31 31 } 32 32 33 33 func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 37 37 // Resolve identifier (handle or DID) to canonical DID and current handle 38 38 did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier) 39 39 if err != nil { 40 - RenderNotFound(w, r, h.Templates, h.RegistryURL) 40 + http.Error(w, "User not found", http.StatusNotFound) 41 41 return 42 42 } 43 43 ··· 48 48 return 49 49 } 50 50 if owner == nil { 51 - RenderNotFound(w, r, h.Templates, h.RegistryURL) 51 + http.Error(w, "User not found", http.StatusNotFound) 52 52 return 53 53 } 54 54 ··· 136 136 } 137 137 138 138 if len(tagsWithPlatforms) == 0 && len(manifests) == 0 { 139 - RenderNotFound(w, r, h.Templates, h.RegistryURL) 139 + http.Error(w, "Repository not found", http.StatusNotFound) 140 140 return 141 141 } 142 142 ··· 190 190 isOwner = (user.DID == owner.DID) 191 191 } 192 192 193 - // Fetch README content from repo page record or annotations 193 + // Fetch README content if available 194 194 var readmeHTML template.HTML 195 + if repo.ReadmeURL != "" && h.ReadmeCache != nil { 196 + // Fetch with timeout 197 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 198 + defer cancel() 195 199 196 - // Try repo page record from database (synced from PDS via Jetstream) 197 - repoPage, err := db.GetRepoPage(h.DB, owner.DID, repository) 198 - if err == nil && repoPage != nil { 199 - // Use repo page avatar if present 200 - if repoPage.AvatarCID != "" { 201 - repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) 202 - } 203 - // Render description as markdown if present 204 - if repoPage.Description != "" && h.ReadmeFetcher != nil { 205 - html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description)) 206 - if err != nil { 207 - slog.Warn("Failed to render repo page description", "error", err) 208 - } else { 209 - readmeHTML = template.HTML(html) 210 - } 211 - } 212 - } 213 - // Fall back to fetching README from URL annotations if no description in repo page 214 - if readmeHTML == "" && h.ReadmeFetcher != nil { 215 - // Fall back to fetching from URL annotations 216 - readmeURL := repo.ReadmeURL 217 - if readmeURL == "" && repo.SourceURL != "" { 218 - // Try to derive README URL from source URL 219 - readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "main") 220 - if readmeURL == "" { 221 - readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "master") 222 - } 223 - } 224 - if readmeURL != "" { 225 - html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL) 226 - if err != nil { 227 - slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err) 228 - } else { 229 - readmeHTML = template.HTML(html) 230 - } 200 + html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL) 201 + if err != nil { 202 + slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err) 203 + // Continue without README on error 204 + } else { 205 + readmeHTML = template.HTML(html) 231 206 } 232 207 } 233 208
+6 -3
pkg/appview/handlers/settings.go
··· 62 62 data.Profile.Handle = user.Handle 63 63 data.Profile.DID = user.DID 64 64 data.Profile.PDSEndpoint = user.PDSEndpoint 65 - data.Profile.DefaultHold = profile.DefaultHold 65 + if profile.DefaultHold != nil { 66 + data.Profile.DefaultHold = *profile.DefaultHold 67 + } 66 68 67 69 if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil { 68 70 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 94 96 profile = atproto.NewSailorProfileRecord(holdEndpoint) 95 97 } else { 96 98 // Update existing profile 97 - profile.DefaultHold = holdEndpoint 98 - profile.UpdatedAt = time.Now() 99 + profile.DefaultHold = &holdEndpoint 100 + now := time.Now().Format(time.RFC3339) 101 + profile.UpdatedAt = &now 99 102 } 100 103 101 104 // Save profile
+1 -1
pkg/appview/handlers/user.go
··· 23 23 // Resolve identifier (handle or DID) to canonical DID and current handle 24 24 did, resolvedHandle, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), identifier) 25 25 if err != nil { 26 - RenderNotFound(w, r, h.Templates, h.RegistryURL) 26 + http.Error(w, "User not found", http.StatusNotFound) 27 27 return 28 28 } 29 29
+20 -261
pkg/appview/jetstream/backfill.go
··· 5 5 "database/sql" 6 6 "encoding/json" 7 7 "fmt" 8 - "io" 9 8 "log/slog" 10 - "net/http" 11 9 "strings" 12 10 "time" 13 11 14 12 "atcr.io/pkg/appview/db" 15 - "atcr.io/pkg/appview/readme" 16 13 "atcr.io/pkg/atproto" 17 - "atcr.io/pkg/auth/oauth" 18 14 ) 19 15 20 16 // BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data 21 17 type BackfillWorker struct { 22 18 db *sql.DB 23 19 client *atproto.Client 24 - processor *Processor // Shared processor for DB operations 25 - defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io") 26 - testMode bool // If true, suppress warnings for external holds 27 - refresher *oauth.Refresher // OAuth refresher for PDS writes (optional, can be nil) 20 + processor *Processor // Shared processor for DB operations 21 + defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io") 22 + testMode bool // If true, suppress warnings for external holds 28 23 } 29 24 30 25 // BackfillState tracks backfill progress ··· 41 36 // NewBackfillWorker creates a backfill worker using sync API 42 37 // defaultHoldDID should be in format "did:web:hold01.atcr.io" 43 38 // To find a hold's DID, visit: https://hold-url/.well-known/did.json 44 - // refresher is optional - if provided, backfill will try to update PDS records when fetching README content 45 - func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) (*BackfillWorker, error) { 39 + func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool) (*BackfillWorker, error) { 46 40 // Create client for relay - used only for listReposByCollection 47 41 client := atproto.NewClient(relayEndpoint, "", "") 48 42 ··· 52 46 processor: NewProcessor(database, false), // No cache for batch processing 53 47 defaultHoldDID: defaultHoldDID, 54 48 testMode: testMode, 55 - refresher: refresher, 56 49 }, nil 57 50 } 58 51 ··· 74 67 atproto.TagCollection, // io.atcr.tag 75 68 atproto.StarCollection, // io.atcr.sailor.star 76 69 atproto.SailorProfileCollection, // io.atcr.sailor.profile 77 - atproto.RepoPageCollection, // io.atcr.repo.page 78 70 } 79 71 80 72 for _, collection := range collections { ··· 172 164 // Track what we found for deletion reconciliation 173 165 switch collection { 174 166 case atproto.ManifestCollection: 175 - var manifestRecord atproto.ManifestRecord 167 + var manifestRecord atproto.Manifest 176 168 if err := json.Unmarshal(record.Value, &manifestRecord); err == nil { 177 169 foundManifestDigests = append(foundManifestDigests, manifestRecord.Digest) 178 170 } 179 171 case atproto.TagCollection: 180 - var tagRecord atproto.TagRecord 172 + var tagRecord atproto.Tag 181 173 if err := json.Unmarshal(record.Value, &tagRecord); err == nil { 182 174 foundTags = append(foundTags, struct{ Repository, Tag string }{ 183 175 Repository: tagRecord.Repository, ··· 185 177 }) 186 178 } 187 179 case atproto.StarCollection: 188 - var starRecord atproto.StarRecord 180 + var starRecord atproto.SailorStar 189 181 if err := json.Unmarshal(record.Value, &starRecord); err == nil { 190 - key := fmt.Sprintf("%s/%s", starRecord.Subject.DID, starRecord.Subject.Repository) 191 - foundStars[key] = starRecord.CreatedAt 182 + key := fmt.Sprintf("%s/%s", starRecord.Subject.Did, starRecord.Subject.Repository) 183 + // Parse CreatedAt string to time.Time 184 + createdAt, parseErr := time.Parse(time.RFC3339, starRecord.CreatedAt) 185 + if parseErr != nil { 186 + createdAt = time.Now() 187 + } 188 + foundStars[key] = createdAt 192 189 } 193 190 } 194 191 ··· 225 222 } 226 223 } 227 224 228 - // After processing repo pages, fetch descriptions from external sources if empty 229 - if collection == atproto.RepoPageCollection { 230 - if err := b.reconcileRepoPageDescriptions(ctx, did, pdsEndpoint); err != nil { 231 - slog.Warn("Backfill failed to reconcile repo page descriptions", "did", did, "error", err) 232 - } 233 - } 234 - 235 225 return recordCount, nil 236 226 } 237 227 ··· 297 287 return b.processor.ProcessStar(context.Background(), did, record.Value) 298 288 case atproto.SailorProfileCollection: 299 289 return b.processor.ProcessSailorProfile(ctx, did, record.Value, b.queryCaptainRecordWrapper) 300 - case atproto.RepoPageCollection: 301 - // rkey is extracted from the record URI, but for repo pages we use Repository field 302 - return b.processor.ProcessRepoPage(ctx, did, record.URI, record.Value, false) 303 290 default: 304 291 return fmt.Errorf("unsupported collection: %s", collection) 305 292 } ··· 377 364 378 365 // reconcileAnnotations ensures annotations come from the newest manifest in each repository 379 366 // This fixes the out-of-order backfill issue where older manifests can overwrite newer annotations 367 + // NOTE: Currently disabled because the generated Manifest_Annotations type doesn't support 368 + // arbitrary key-value pairs. Would need to update lexicon schema with "unknown" type. 380 369 func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, pdsClient *atproto.Client) error { 381 - // Get all repositories for this DID 382 - repositories, err := db.GetRepositoriesForDID(b.db, did) 383 - if err != nil { 384 - return fmt.Errorf("failed to get repositories: %w", err) 385 - } 386 - 387 - for _, repo := range repositories { 388 - // Find newest manifest for this repository 389 - newestManifest, err := db.GetNewestManifestForRepo(b.db, did, repo) 390 - if err != nil { 391 - slog.Warn("Backfill failed to get newest manifest for repo", "did", did, "repository", repo, "error", err) 392 - continue // Skip on error 393 - } 394 - 395 - // Fetch the full manifest record from PDS using the digest as rkey 396 - rkey := strings.TrimPrefix(newestManifest.Digest, "sha256:") 397 - record, err := pdsClient.GetRecord(ctx, atproto.ManifestCollection, rkey) 398 - if err != nil { 399 - slog.Warn("Backfill failed to fetch manifest record for repo", "did", did, "repository", repo, "error", err) 400 - continue // Skip on error 401 - } 402 - 403 - // Parse manifest record 404 - var manifestRecord atproto.ManifestRecord 405 - if err := json.Unmarshal(record.Value, &manifestRecord); err != nil { 406 - slog.Warn("Backfill failed to parse manifest record for repo", "did", did, "repository", repo, "error", err) 407 - continue 408 - } 409 - 410 - // Update annotations from newest manifest only 411 - if len(manifestRecord.Annotations) > 0 { 412 - // Filter out empty annotations 413 - hasData := false 414 - for _, value := range manifestRecord.Annotations { 415 - if value != "" { 416 - hasData = true 417 - break 418 - } 419 - } 420 - 421 - if hasData { 422 - err = db.UpsertRepositoryAnnotations(b.db, did, repo, manifestRecord.Annotations) 423 - if err != nil { 424 - slog.Warn("Backfill failed to reconcile annotations for repo", "did", did, "repository", repo, "error", err) 425 - } else { 426 - slog.Info("Backfill reconciled annotations for repo from newest manifest", "did", did, "repository", repo, "digest", newestManifest.Digest) 427 - } 428 - } 429 - } 430 - } 431 - 432 - return nil 433 - } 434 - 435 - // reconcileRepoPageDescriptions fetches README content from external sources for repo pages with empty descriptions 436 - // If the user has an OAuth session, it updates the PDS record (source of truth) 437 - // Otherwise, it just stores the fetched content in the database 438 - func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did, pdsEndpoint string) error { 439 - // Get all repo pages for this DID 440 - repoPages, err := db.GetRepoPagesByDID(b.db, did) 441 - if err != nil { 442 - return fmt.Errorf("failed to get repo pages: %w", err) 443 - } 444 - 445 - for _, page := range repoPages { 446 - // Skip pages that already have a description 447 - if page.Description != "" { 448 - continue 449 - } 450 - 451 - // Get annotations from the repository's manifest 452 - annotations, err := db.GetRepositoryAnnotations(b.db, did, page.Repository) 453 - if err != nil { 454 - slog.Debug("Failed to get annotations for repo page", "did", did, "repository", page.Repository, "error", err) 455 - continue 456 - } 457 - 458 - // Try to fetch README content from external sources 459 - description := b.fetchReadmeContent(ctx, annotations) 460 - if description == "" { 461 - // No README content available, skip 462 - continue 463 - } 464 - 465 - slog.Info("Fetched README for repo page", "did", did, "repository", page.Repository, "descriptionLength", len(description)) 466 - 467 - // Try to update PDS if we have OAuth session 468 - pdsUpdated := false 469 - if b.refresher != nil { 470 - if err := b.updateRepoPageInPDS(ctx, did, pdsEndpoint, page.Repository, description, page.AvatarCID); err != nil { 471 - slog.Debug("Could not update repo page in PDS, falling back to DB-only", "did", did, "repository", page.Repository, "error", err) 472 - } else { 473 - pdsUpdated = true 474 - slog.Info("Updated repo page in PDS with fetched description", "did", did, "repository", page.Repository) 475 - } 476 - } 477 - 478 - // Always update database with the fetched content 479 - if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil { 480 - slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err) 481 - } else if !pdsUpdated { 482 - slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository) 483 - } 484 - } 485 - 486 - return nil 487 - } 488 - 489 - // fetchReadmeContent attempts to fetch README content from external sources based on annotations 490 - // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source 491 - func (b *BackfillWorker) fetchReadmeContent(ctx context.Context, annotations map[string]string) string { 492 - // Create a context with timeout for README fetching 493 - fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 494 - defer cancel() 495 - 496 - // Priority 1: Direct README URL from io.atcr.readme annotation 497 - if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" { 498 - content, err := b.fetchRawReadme(fetchCtx, readmeURL) 499 - if err != nil { 500 - slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err) 501 - } else if content != "" { 502 - return content 503 - } 504 - } 505 - 506 - // Priority 2: Derive README URL from org.opencontainers.image.source 507 - if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" { 508 - // Try main branch first, then master 509 - for _, branch := range []string{"main", "master"} { 510 - readmeURL := readme.DeriveReadmeURL(sourceURL, branch) 511 - if readmeURL == "" { 512 - continue 513 - } 514 - 515 - content, err := b.fetchRawReadme(fetchCtx, readmeURL) 516 - if err != nil { 517 - // Only log non-404 errors (404 is expected when trying main vs master) 518 - if !readme.Is404(err) { 519 - slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err) 520 - } 521 - continue 522 - } 523 - 524 - if content != "" { 525 - return content 526 - } 527 - } 528 - } 529 - 530 - return "" 531 - } 532 - 533 - // fetchRawReadme fetches raw markdown content from a URL 534 - func (b *BackfillWorker) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) { 535 - req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil) 536 - if err != nil { 537 - return "", fmt.Errorf("failed to create request: %w", err) 538 - } 539 - 540 - req.Header.Set("User-Agent", "ATCR-Backfill-README-Fetcher/1.0") 541 - 542 - client := &http.Client{ 543 - Timeout: 10 * time.Second, 544 - CheckRedirect: func(req *http.Request, via []*http.Request) error { 545 - if len(via) >= 5 { 546 - return fmt.Errorf("too many redirects") 547 - } 548 - return nil 549 - }, 550 - } 551 - 552 - resp, err := client.Do(req) 553 - if err != nil { 554 - return "", fmt.Errorf("failed to fetch URL: %w", err) 555 - } 556 - defer resp.Body.Close() 557 - 558 - if resp.StatusCode != http.StatusOK { 559 - return "", fmt.Errorf("status %d", resp.StatusCode) 560 - } 561 - 562 - // Limit content size to 100KB 563 - limitedReader := io.LimitReader(resp.Body, 100*1024) 564 - content, err := io.ReadAll(limitedReader) 565 - if err != nil { 566 - return "", fmt.Errorf("failed to read response body: %w", err) 567 - } 568 - 569 - return string(content), nil 570 - } 571 - 572 - // updateRepoPageInPDS updates the repo page record in the user's PDS using OAuth 573 - func (b *BackfillWorker) updateRepoPageInPDS(ctx context.Context, did, pdsEndpoint, repository, description, avatarCID string) error { 574 - if b.refresher == nil { 575 - return fmt.Errorf("no OAuth refresher available") 576 - } 577 - 578 - // Create ATProto client with session provider 579 - pdsClient := atproto.NewClientWithSessionProvider(pdsEndpoint, did, b.refresher) 580 - 581 - // Get existing repo page record to preserve other fields 582 - existingRecord, err := pdsClient.GetRecord(ctx, atproto.RepoPageCollection, repository) 583 - var createdAt time.Time 584 - var avatarRef *atproto.ATProtoBlobRef 585 - 586 - if err == nil && existingRecord != nil { 587 - // Parse existing record 588 - var existingPage atproto.RepoPageRecord 589 - if err := json.Unmarshal(existingRecord.Value, &existingPage); err == nil { 590 - createdAt = existingPage.CreatedAt 591 - avatarRef = existingPage.Avatar 592 - } 593 - } 594 - 595 - if createdAt.IsZero() { 596 - createdAt = time.Now() 597 - } 598 - 599 - // Create updated repo page record 600 - repoPage := &atproto.RepoPageRecord{ 601 - Type: atproto.RepoPageCollection, 602 - Repository: repository, 603 - Description: description, 604 - Avatar: avatarRef, 605 - CreatedAt: createdAt, 606 - UpdatedAt: time.Now(), 607 - } 608 - 609 - // Write to PDS - this will use DoWithSession internally 610 - _, err = pdsClient.PutRecord(ctx, atproto.RepoPageCollection, repository, repoPage) 611 - if err != nil { 612 - return fmt.Errorf("failed to write to PDS: %w", err) 613 - } 614 - 370 + // TODO: Re-enable once lexicon supports annotations as map[string]string 371 + // For now, skip annotation reconciliation as the generated type is an empty struct 372 + _ = did 373 + _ = pdsClient 615 374 return nil 616 375 }
+51 -65
pkg/appview/jetstream/processor.go
··· 100 100 // Returns the manifest ID for further processing (layers/references) 101 101 func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData []byte) (int64, error) { 102 102 // Unmarshal manifest record 103 - var manifestRecord atproto.ManifestRecord 103 + var manifestRecord atproto.Manifest 104 104 if err := json.Unmarshal(recordData, &manifestRecord); err != nil { 105 105 return 0, fmt.Errorf("failed to unmarshal manifest: %w", err) 106 106 } ··· 110 110 // Extract hold DID from manifest (with fallback for legacy manifests) 111 111 // New manifests use holdDid field (DID format) 112 112 // Old manifests use holdEndpoint field (URL format) - convert to DID 113 - holdDID := manifestRecord.HoldDID 114 - if holdDID == "" && manifestRecord.HoldEndpoint != "" { 113 + var holdDID string 114 + if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" { 115 + holdDID = *manifestRecord.HoldDid 116 + } else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" { 115 117 // Legacy manifest - convert URL to DID 116 - holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 118 + holdDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint) 119 + } 120 + 121 + // Parse CreatedAt string to time.Time 122 + createdAt, err := time.Parse(time.RFC3339, manifestRecord.CreatedAt) 123 + if err != nil { 124 + // Fall back to current time if parsing fails 125 + createdAt = time.Now() 117 126 } 118 127 119 128 // Prepare manifest for insertion (WITHOUT annotation fields) ··· 122 131 Repository: manifestRecord.Repository, 123 132 Digest: manifestRecord.Digest, 124 133 MediaType: manifestRecord.MediaType, 125 - SchemaVersion: manifestRecord.SchemaVersion, 134 + SchemaVersion: int(manifestRecord.SchemaVersion), 126 135 HoldEndpoint: holdDID, 127 - CreatedAt: manifestRecord.CreatedAt, 136 + CreatedAt: createdAt, 128 137 // Annotations removed - stored separately in repository_annotations table 129 138 } 130 139 ··· 154 163 } 155 164 } 156 165 157 - // Update repository annotations ONLY if manifest has at least one non-empty annotation 158 - if manifestRecord.Annotations != nil { 159 - hasData := false 160 - for _, value := range manifestRecord.Annotations { 161 - if value != "" { 162 - hasData = true 163 - break 164 - } 165 - } 166 - 167 - if hasData { 168 - // Replace all annotations for this repository 169 - err = db.UpsertRepositoryAnnotations(p.db, did, manifestRecord.Repository, manifestRecord.Annotations) 170 - if err != nil { 171 - return 0, fmt.Errorf("failed to upsert annotations: %w", err) 172 - } 173 - } 174 - } 166 + // Note: Repository annotations are currently disabled because the generated 167 + // Manifest_Annotations type doesn't support arbitrary key-value pairs. 168 + // The lexicon would need to use "unknown" type for annotations to support this. 169 + // TODO: Re-enable once lexicon supports annotations as map[string]string 170 + _ = manifestRecord.Annotations 175 171 176 172 // Insert manifest references or layers 177 173 if isManifestList { ··· 184 180 185 181 if ref.Platform != nil { 186 182 platformArch = ref.Platform.Architecture 187 - platformOS = ref.Platform.OS 188 - platformVariant = ref.Platform.Variant 189 - platformOSVersion = ref.Platform.OSVersion 183 + platformOS = ref.Platform.Os 184 + if ref.Platform.Variant != nil { 185 + platformVariant = *ref.Platform.Variant 186 + } 187 + if ref.Platform.OsVersion != nil { 188 + platformOSVersion = *ref.Platform.OsVersion 189 + } 190 190 } 191 191 192 - // Detect attestation manifests from annotations 192 + // Note: Attestation detection via annotations is currently disabled 193 + // because the generated Manifest_ManifestReference_Annotations type 194 + // doesn't support arbitrary key-value pairs. 193 195 isAttestation := false 194 - if ref.Annotations != nil { 195 - if refType, ok := ref.Annotations["vnd.docker.reference.type"]; ok { 196 - isAttestation = refType == "attestation-manifest" 197 - } 198 - } 199 196 200 197 if err := db.InsertManifestReference(p.db, &db.ManifestReference{ 201 198 ManifestID: manifestID, ··· 235 232 // ProcessTag processes a tag record and stores it in the database 236 233 func (p *Processor) ProcessTag(ctx context.Context, did string, recordData []byte) error { 237 234 // Unmarshal tag record 238 - var tagRecord atproto.TagRecord 235 + var tagRecord atproto.Tag 239 236 if err := json.Unmarshal(recordData, &tagRecord); err != nil { 240 237 return fmt.Errorf("failed to unmarshal tag: %w", err) 241 238 } ··· 245 242 return fmt.Errorf("failed to get manifest digest from tag record: %w", err) 246 243 } 247 244 245 + // Parse CreatedAt string to time.Time 246 + tagCreatedAt, err := time.Parse(time.RFC3339, tagRecord.CreatedAt) 247 + if err != nil { 248 + // Fall back to current time if parsing fails 249 + tagCreatedAt = time.Now() 250 + } 251 + 248 252 // Insert or update tag 249 253 return db.UpsertTag(p.db, &db.Tag{ 250 254 DID: did, 251 255 Repository: tagRecord.Repository, 252 256 Tag: tagRecord.Tag, 253 257 Digest: manifestDigest, 254 - CreatedAt: tagRecord.UpdatedAt, 258 + CreatedAt: tagCreatedAt, 255 259 }) 256 260 } 257 261 258 262 // ProcessStar processes a star record and stores it in the database 259 263 func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []byte) error { 260 264 // Unmarshal star record 261 - var starRecord atproto.StarRecord 265 + var starRecord atproto.SailorStar 262 266 if err := json.Unmarshal(recordData, &starRecord); err != nil { 263 267 return fmt.Errorf("failed to unmarshal star: %w", err) 264 268 } ··· 266 270 // The DID here is the starrer (user who starred) 267 271 // The subject contains the owner DID and repository 268 272 // Star count will be calculated on demand from the stars table 269 - return db.UpsertStar(p.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt) 273 + // Parse the CreatedAt string to time.Time 274 + createdAt, err := time.Parse(time.RFC3339, starRecord.CreatedAt) 275 + if err != nil { 276 + // Fall back to current time if parsing fails 277 + createdAt = time.Now() 278 + } 279 + return db.UpsertStar(p.db, did, starRecord.Subject.Did, starRecord.Subject.Repository, createdAt) 270 280 } 271 281 272 282 // ProcessSailorProfile processes a sailor profile record 273 283 // This is primarily used by backfill to cache captain records for holds 274 284 func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, recordData []byte, queryCaptainFn func(context.Context, string) error) error { 275 285 // Unmarshal sailor profile record 276 - var profileRecord atproto.SailorProfileRecord 286 + var profileRecord atproto.SailorProfile 277 287 if err := json.Unmarshal(recordData, &profileRecord); err != nil { 278 288 return fmt.Errorf("failed to unmarshal sailor profile: %w", err) 279 289 } 280 290 281 291 // Skip if no default hold set 282 - if profileRecord.DefaultHold == "" { 292 + if profileRecord.DefaultHold == nil || *profileRecord.DefaultHold == "" { 283 293 return nil 284 294 } 285 295 286 296 // Convert hold URL/DID to canonical DID 287 - holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold) 297 + holdDID := atproto.ResolveHoldDIDFromURL(*profileRecord.DefaultHold) 288 298 if holdDID == "" { 289 - slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold) 299 + slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", *profileRecord.DefaultHold) 290 300 return nil 291 301 } 292 302 ··· 297 307 } 298 308 299 309 return nil 300 - } 301 - 302 - // ProcessRepoPage processes a repository page record 303 - // This is called when Jetstream receives a repo page create/update event 304 - func (p *Processor) ProcessRepoPage(ctx context.Context, did string, rkey string, recordData []byte, isDelete bool) error { 305 - if isDelete { 306 - // Delete the repo page from our cache 307 - return db.DeleteRepoPage(p.db, did, rkey) 308 - } 309 - 310 - // Unmarshal repo page record 311 - var pageRecord atproto.RepoPageRecord 312 - if err := json.Unmarshal(recordData, &pageRecord); err != nil { 313 - return fmt.Errorf("failed to unmarshal repo page: %w", err) 314 - } 315 - 316 - // Extract avatar CID if present 317 - avatarCID := "" 318 - if pageRecord.Avatar != nil && pageRecord.Avatar.Ref.Link != "" { 319 - avatarCID = pageRecord.Avatar.Ref.Link 320 - } 321 - 322 - // Upsert to database 323 - return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.CreatedAt, pageRecord.UpdatedAt) 324 310 } 325 311 326 312 // ProcessIdentity handles identity change events (handle updates)
+36 -54
pkg/appview/jetstream/processor_test.go
··· 11 11 _ "github.com/mattn/go-sqlite3" 12 12 ) 13 13 14 + // ptrString returns a pointer to the given string 15 + func ptrString(s string) *string { 16 + return &s 17 + } 18 + 14 19 // setupTestDB creates an in-memory SQLite database for testing 15 20 func setupTestDB(t *testing.T) *sql.DB { 16 21 database, err := sql.Open("sqlite3", ":memory:") ··· 143 148 ctx := context.Background() 144 149 145 150 // Create test manifest record 146 - manifestRecord := &atproto.ManifestRecord{ 151 + manifestRecord := &atproto.Manifest{ 147 152 Repository: "test-app", 148 153 Digest: "sha256:abc123", 149 154 MediaType: "application/vnd.oci.image.manifest.v1+json", 150 155 SchemaVersion: 2, 151 - HoldEndpoint: "did:web:hold01.atcr.io", 152 - CreatedAt: time.Now(), 153 - Config: &atproto.BlobReference{ 156 + HoldEndpoint: ptrString("did:web:hold01.atcr.io"), 157 + CreatedAt: time.Now().Format(time.RFC3339), 158 + Config: &atproto.Manifest_BlobReference{ 154 159 Digest: "sha256:config123", 155 160 Size: 1234, 156 161 }, 157 - Layers: []atproto.BlobReference{ 162 + Layers: []atproto.Manifest_BlobReference{ 158 163 {Digest: "sha256:layer1", Size: 5000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"}, 159 164 {Digest: "sha256:layer2", Size: 3000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"}, 160 165 }, 161 - Annotations: map[string]string{ 162 - "org.opencontainers.image.title": "Test App", 163 - "org.opencontainers.image.description": "A test application", 164 - "org.opencontainers.image.source": "https://github.com/test/app", 165 - "org.opencontainers.image.licenses": "MIT", 166 - "io.atcr.icon": "https://example.com/icon.png", 167 - }, 166 + // Annotations disabled - generated Manifest_Annotations is empty struct 168 167 } 169 168 170 169 // Marshal to bytes for ProcessManifest ··· 193 192 t.Errorf("Expected 1 manifest, got %d", count) 194 193 } 195 194 196 - // Verify annotations were stored in repository_annotations table 197 - var title, source string 198 - err = database.QueryRow("SELECT value FROM repository_annotations WHERE did = ? AND repository = ? AND key = ?", 199 - "did:plc:test123", "test-app", "org.opencontainers.image.title").Scan(&title) 200 - if err != nil { 201 - t.Fatalf("Failed to query title annotation: %v", err) 202 - } 203 - if title != "Test App" { 204 - t.Errorf("title = %q, want %q", title, "Test App") 205 - } 206 - 207 - err = database.QueryRow("SELECT value FROM repository_annotations WHERE did = ? AND repository = ? AND key = ?", 208 - "did:plc:test123", "test-app", "org.opencontainers.image.source").Scan(&source) 209 - if err != nil { 210 - t.Fatalf("Failed to query source annotation: %v", err) 211 - } 212 - if source != "https://github.com/test/app" { 213 - t.Errorf("source = %q, want %q", source, "https://github.com/test/app") 214 - } 195 + // Note: Annotations verification disabled - generated Manifest_Annotations is empty struct 196 + // TODO: Re-enable when lexicon uses "unknown" type for annotations 215 197 216 198 // Verify layers were inserted 217 199 var layerCount int ··· 242 224 ctx := context.Background() 243 225 244 226 // Create test manifest list record 245 - manifestRecord := &atproto.ManifestRecord{ 227 + manifestRecord := &atproto.Manifest{ 246 228 Repository: "test-app", 247 229 Digest: "sha256:list123", 248 230 MediaType: "application/vnd.oci.image.index.v1+json", 249 231 SchemaVersion: 2, 250 - HoldEndpoint: "did:web:hold01.atcr.io", 251 - CreatedAt: time.Now(), 252 - Manifests: []atproto.ManifestReference{ 232 + HoldEndpoint: ptrString("did:web:hold01.atcr.io"), 233 + CreatedAt: time.Now().Format(time.RFC3339), 234 + Manifests: []atproto.Manifest_ManifestReference{ 253 235 { 254 236 Digest: "sha256:amd64manifest", 255 237 MediaType: "application/vnd.oci.image.manifest.v1+json", 256 238 Size: 1000, 257 - Platform: &atproto.Platform{ 239 + Platform: &atproto.Manifest_Platform{ 258 240 Architecture: "amd64", 259 - OS: "linux", 241 + Os: "linux", 260 242 }, 261 243 }, 262 244 { 263 245 Digest: "sha256:arm64manifest", 264 246 MediaType: "application/vnd.oci.image.manifest.v1+json", 265 247 Size: 1100, 266 - Platform: &atproto.Platform{ 248 + Platform: &atproto.Manifest_Platform{ 267 249 Architecture: "arm64", 268 - OS: "linux", 269 - Variant: "v8", 250 + Os: "linux", 251 + Variant: ptrString("v8"), 270 252 }, 271 253 }, 272 254 }, ··· 326 308 ctx := context.Background() 327 309 328 310 // Create test tag record (using ManifestDigest field for simplicity) 329 - tagRecord := &atproto.TagRecord{ 311 + tagRecord := &atproto.Tag{ 330 312 Repository: "test-app", 331 313 Tag: "latest", 332 - ManifestDigest: "sha256:abc123", 333 - UpdatedAt: time.Now(), 314 + ManifestDigest: ptrString("sha256:abc123"), 315 + CreatedAt: time.Now().Format(time.RFC3339), 334 316 } 335 317 336 318 // Marshal to bytes for ProcessTag ··· 368 350 } 369 351 370 352 // Test upserting same tag with new digest 371 - tagRecord.ManifestDigest = "sha256:newdigest" 353 + tagRecord.ManifestDigest = ptrString("sha256:newdigest") 372 354 recordBytes, err = json.Marshal(tagRecord) 373 355 if err != nil { 374 356 t.Fatalf("Failed to marshal tag: %v", err) ··· 407 389 ctx := context.Background() 408 390 409 391 // Create test star record 410 - starRecord := &atproto.StarRecord{ 411 - Subject: atproto.StarSubject{ 412 - DID: "did:plc:owner123", 392 + starRecord := &atproto.SailorStar{ 393 + Subject: atproto.SailorStar_Subject{ 394 + Did: "did:plc:owner123", 413 395 Repository: "test-app", 414 396 }, 415 - CreatedAt: time.Now(), 397 + CreatedAt: time.Now().Format(time.RFC3339), 416 398 } 417 399 418 400 // Marshal to bytes for ProcessStar ··· 466 448 p := NewProcessor(database, false) 467 449 ctx := context.Background() 468 450 469 - manifestRecord := &atproto.ManifestRecord{ 451 + manifestRecord := &atproto.Manifest{ 470 452 Repository: "test-app", 471 453 Digest: "sha256:abc123", 472 454 MediaType: "application/vnd.oci.image.manifest.v1+json", 473 455 SchemaVersion: 2, 474 - HoldEndpoint: "did:web:hold01.atcr.io", 475 - CreatedAt: time.Now(), 456 + HoldEndpoint: ptrString("did:web:hold01.atcr.io"), 457 + CreatedAt: time.Now().Format(time.RFC3339), 476 458 } 477 459 478 460 // Marshal to bytes for ProcessManifest ··· 518 500 ctx := context.Background() 519 501 520 502 // Manifest with nil annotations 521 - manifestRecord := &atproto.ManifestRecord{ 503 + manifestRecord := &atproto.Manifest{ 522 504 Repository: "test-app", 523 505 Digest: "sha256:abc123", 524 506 MediaType: "application/vnd.oci.image.manifest.v1+json", 525 507 SchemaVersion: 2, 526 - HoldEndpoint: "did:web:hold01.atcr.io", 527 - CreatedAt: time.Now(), 508 + HoldEndpoint: ptrString("did:web:hold01.atcr.io"), 509 + CreatedAt: time.Now().Format(time.RFC3339), 528 510 Annotations: nil, 529 511 } 530 512
+3 -39
pkg/appview/jetstream/worker.go
··· 61 61 jetstreamURL: jetstreamURL, 62 62 startCursor: startCursor, 63 63 wantedCollections: []string{ 64 - "io.atcr.*", // Subscribe to all ATCR collections 64 + atproto.ManifestCollection, // io.atcr.manifest 65 + atproto.TagCollection, // io.atcr.tag 66 + atproto.StarCollection, // io.atcr.sailor.star 65 67 }, 66 68 processor: NewProcessor(database, true), // Use cache for live streaming 67 69 } ··· 310 312 case atproto.StarCollection: 311 313 slog.Info("Jetstream processing star event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey) 312 314 return w.processStar(commit) 313 - case atproto.RepoPageCollection: 314 - slog.Info("Jetstream processing repo page event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey) 315 - return w.processRepoPage(commit) 316 315 default: 317 316 // Ignore other collections 318 317 return nil ··· 435 434 436 435 // Use shared processor for DB operations 437 436 return w.processor.ProcessStar(context.Background(), commit.DID, recordBytes) 438 - } 439 - 440 - // processRepoPage processes a repo page commit event 441 - func (w *Worker) processRepoPage(commit *CommitEvent) error { 442 - // Resolve and upsert user with handle/PDS endpoint 443 - if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil { 444 - return fmt.Errorf("failed to ensure user: %w", err) 445 - } 446 - 447 - isDelete := commit.Operation == "delete" 448 - 449 - if isDelete { 450 - // Delete - rkey is the repository name 451 - slog.Info("Jetstream deleting repo page", "did", commit.DID, "repository", commit.RKey) 452 - if err := w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, nil, true); err != nil { 453 - slog.Error("Jetstream ERROR deleting repo page", "error", err) 454 - return err 455 - } 456 - slog.Info("Jetstream successfully deleted repo page", "did", commit.DID, "repository", commit.RKey) 457 - return nil 458 - } 459 - 460 - // Parse repo page record 461 - if commit.Record == nil { 462 - return nil 463 - } 464 - 465 - // Marshal map to bytes for processing 466 - recordBytes, err := json.Marshal(commit.Record) 467 - if err != nil { 468 - return fmt.Errorf("failed to marshal record: %w", err) 469 - } 470 - 471 - // Use shared processor for DB operations 472 - return w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, recordBytes, false) 473 437 } 474 438 475 439 // processIdentity processes an identity event (handle change)
+23 -14
pkg/appview/middleware/registry.go
··· 15 15 "github.com/distribution/distribution/v3/registry/storage/driver" 16 16 "github.com/distribution/reference" 17 17 18 - "atcr.io/pkg/appview/readme" 19 18 "atcr.io/pkg/appview/storage" 20 19 "atcr.io/pkg/atproto" 21 20 "atcr.io/pkg/auth" ··· 170 169 // These are set by main.go during startup and copied into NamespaceResolver instances. 171 170 // After initialization, request handling uses the NamespaceResolver's instance fields. 172 171 var ( 173 - globalRefresher *oauth.Refresher 174 - globalDatabase storage.DatabaseMetrics 175 - globalAuthorizer auth.HoldAuthorizer 172 + globalRefresher *oauth.Refresher 173 + globalDatabase storage.DatabaseMetrics 174 + globalAuthorizer auth.HoldAuthorizer 175 + globalReadmeCache storage.ReadmeCache 176 176 ) 177 177 178 178 // SetGlobalRefresher sets the OAuth refresher instance during initialization ··· 193 193 globalAuthorizer = authorizer 194 194 } 195 195 196 + // SetGlobalReadmeCache sets the readme cache instance during initialization 197 + // Must be called before the registry starts serving requests 198 + func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) { 199 + globalReadmeCache = readmeCache 200 + } 201 + 196 202 func init() { 197 203 // Register the name resolution middleware 198 204 registrymw.Register("atproto-resolver", initATProtoResolver) ··· 207 213 refresher *oauth.Refresher // OAuth session manager (copied from global on init) 208 214 database storage.DatabaseMetrics // Metrics database (copied from global on init) 209 215 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 216 + readmeCache storage.ReadmeCache // README cache (copied from global on init) 210 217 validationCache *validationCache // Request-level service token cache 211 - readmeFetcher *readme.Fetcher // README fetcher for repo pages 212 218 } 213 219 214 220 // initATProtoResolver initializes the name resolution middleware ··· 242 248 refresher: globalRefresher, 243 249 database: globalDatabase, 244 250 authorizer: globalAuthorizer, 251 + readmeCache: globalReadmeCache, 245 252 validationCache: newValidationCache(), 246 - readmeFetcher: readme.NewFetcher(), 247 253 }, nil 248 254 } 249 255 ··· 460 466 Database: nr.database, 461 467 Authorizer: nr.authorizer, 462 468 Refresher: nr.refresher, 463 - ReadmeFetcher: nr.readmeFetcher, 469 + ReadmeCache: nr.readmeCache, 464 470 } 465 471 466 472 return storage.NewRoutingRepository(repo, registryCtx), nil ··· 484 490 // findHoldDID determines which hold DID to use for blob storage 485 491 // Priority order: 486 492 // 1. User's sailor profile defaultHold (if set) 487 - // 2. AppView's default hold DID 493 + // 2. User's own hold record (io.atcr.hold) 494 + // 3. AppView's default hold DID 488 495 // Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured 489 496 func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string { 490 497 // Create ATProto client (without auth - reading public records) ··· 497 504 slog.Warn("Failed to read profile", "did", did, "error", err) 498 505 } 499 506 500 - if profile != nil && profile.DefaultHold != "" { 507 + if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" { 508 + defaultHold := *profile.DefaultHold 501 509 // Profile exists with defaultHold set 502 510 // In test mode, verify it's reachable before using it 503 511 if nr.testMode { 504 - if nr.isHoldReachable(ctx, profile.DefaultHold) { 505 - return profile.DefaultHold 512 + if nr.isHoldReachable(ctx, defaultHold) { 513 + return defaultHold 506 514 } 507 - slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold) 515 + slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", defaultHold) 508 516 return nr.defaultHoldDID 509 517 } 510 - return profile.DefaultHold 518 + return defaultHold 511 519 } 512 520 513 - // No profile defaultHold - use AppView default 521 + // Profile doesn't exist or defaultHold is null/empty 522 + // Legacy io.atcr.hold records are no longer supported - use AppView default 514 523 return nr.defaultHoldDID 515 524 } 516 525
+32 -2
pkg/appview/middleware/registry_test.go
··· 67 67 // If we get here without panic, test passes 68 68 } 69 69 70 + func TestSetGlobalReadmeCache(t *testing.T) { 71 + SetGlobalReadmeCache(nil) 72 + // If we get here without panic, test passes 73 + } 74 + 70 75 // TestInitATProtoResolver tests the initialization function 71 76 func TestInitATProtoResolver(t *testing.T) { 72 77 ctx := context.Background() ··· 199 204 assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold") 200 205 } 201 206 202 - // TestFindHoldDID_Priority tests the priority order 207 + // TestFindHoldDID_NoProfile tests fallback to default hold when no profile exists 208 + func TestFindHoldDID_NoProfile(t *testing.T) { 209 + // Start a mock PDS server that returns 404 for profile 210 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 211 + if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" { 212 + // Profile not found 213 + w.WriteHeader(http.StatusNotFound) 214 + return 215 + } 216 + w.WriteHeader(http.StatusNotFound) 217 + })) 218 + defer mockPDS.Close() 219 + 220 + resolver := &NamespaceResolver{ 221 + defaultHoldDID: "did:web:default.atcr.io", 222 + } 223 + 224 + ctx := context.Background() 225 + holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL) 226 + 227 + // Should fall back to default hold DID when no profile exists 228 + // Note: Legacy io.atcr.hold records are no longer supported 229 + assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default hold DID") 230 + } 231 + 232 + // TestFindHoldDID_Priority tests that profile takes priority over default 203 233 func TestFindHoldDID_Priority(t *testing.T) { 204 - // Start a mock PDS server that returns both profile and hold records 234 + // Start a mock PDS server that returns profile 205 235 mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 206 236 if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" { 207 237 // Return sailor profile with defaultHold (highest priority)
+111
pkg/appview/readme/cache.go
··· 1 + // Package readme provides README fetching, rendering, and caching functionality 2 + // for container repositories. It fetches markdown content from URLs, renders it 3 + // to sanitized HTML using GitHub-flavored markdown, and caches the results in 4 + // a database with configurable TTL. 5 + package readme 6 + 7 + import ( 8 + "context" 9 + "database/sql" 10 + "log/slog" 11 + "time" 12 + ) 13 + 14 + // Cache stores rendered README HTML in the database 15 + type Cache struct { 16 + db *sql.DB 17 + fetcher *Fetcher 18 + ttl time.Duration 19 + } 20 + 21 + // NewCache creates a new README cache 22 + func NewCache(db *sql.DB, ttl time.Duration) *Cache { 23 + if ttl == 0 { 24 + ttl = 1 * time.Hour // Default TTL 25 + } 26 + return &Cache{ 27 + db: db, 28 + fetcher: NewFetcher(), 29 + ttl: ttl, 30 + } 31 + } 32 + 33 + // Get retrieves a README from cache or fetches it 34 + func (c *Cache) Get(ctx context.Context, readmeURL string) (string, error) { 35 + // Try to get from cache 36 + html, fetchedAt, err := c.getFromDB(readmeURL) 37 + if err == nil { 38 + // Check if cache is still valid 39 + if time.Since(fetchedAt) < c.ttl { 40 + return html, nil 41 + } 42 + } 43 + 44 + // Cache miss or expired, fetch fresh content 45 + html, err = c.fetcher.FetchAndRender(ctx, readmeURL) 46 + if err != nil { 47 + // If fetch fails but we have stale cache, return it 48 + if html != "" { 49 + return html, nil 50 + } 51 + return "", err 52 + } 53 + 54 + // Store in cache 55 + if err := c.storeInDB(readmeURL, html); err != nil { 56 + // Log error but don't fail - we have the content 57 + slog.Warn("Failed to cache README", "error", err) 58 + } 59 + 60 + return html, nil 61 + } 62 + 63 + // getFromDB retrieves cached README from database 64 + func (c *Cache) getFromDB(readmeURL string) (string, time.Time, error) { 65 + var html string 66 + var fetchedAt time.Time 67 + 68 + err := c.db.QueryRow(` 69 + SELECT html, fetched_at 70 + FROM readme_cache 71 + WHERE url = ? 72 + `, readmeURL).Scan(&html, &fetchedAt) 73 + 74 + if err != nil { 75 + return "", time.Time{}, err 76 + } 77 + 78 + return html, fetchedAt, nil 79 + } 80 + 81 + // storeInDB stores rendered README in database 82 + func (c *Cache) storeInDB(readmeURL, html string) error { 83 + _, err := c.db.Exec(` 84 + INSERT INTO readme_cache (url, html, fetched_at) 85 + VALUES (?, ?, ?) 86 + ON CONFLICT(url) DO UPDATE SET 87 + html = excluded.html, 88 + fetched_at = excluded.fetched_at 89 + `, readmeURL, html, time.Now()) 90 + 91 + return err 92 + } 93 + 94 + // Invalidate removes a README from the cache 95 + func (c *Cache) Invalidate(readmeURL string) error { 96 + _, err := c.db.Exec(` 97 + DELETE FROM readme_cache 98 + WHERE url = ? 99 + `, readmeURL) 100 + return err 101 + } 102 + 103 + // Cleanup removes expired entries from the cache 104 + func (c *Cache) Cleanup() error { 105 + cutoff := time.Now().Add(-c.ttl * 2) // Keep for 2x TTL 106 + _, err := c.db.Exec(` 107 + DELETE FROM readme_cache 108 + WHERE fetched_at < ? 109 + `, cutoff) 110 + return err 111 + }
+13
pkg/appview/readme/cache_test.go
··· 1 + package readme 2 + 3 + import "testing" 4 + 5 + func TestCache_Struct(t *testing.T) { 6 + // Simple struct test 7 + cache := &Cache{} 8 + if cache == nil { 9 + t.Error("Expected non-nil cache") 10 + } 11 + } 12 + 13 + // TODO: Add cache operation tests
+9 -62
pkg/appview/readme/fetcher.go
··· 7 7 "io" 8 8 "net/http" 9 9 "net/url" 10 - "regexp" 11 10 "strings" 12 11 "time" 13 12 ··· 181 180 return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, path) 182 181 } 183 182 184 - // Is404 returns true if the error indicates a 404 Not Found response 185 - func Is404(err error) bool { 186 - return err != nil && strings.Contains(err.Error(), "unexpected status code: 404") 187 - } 188 - 189 - // RenderMarkdown renders a markdown string to sanitized HTML 190 - // This is used for rendering repo page descriptions stored in the database 191 - func (f *Fetcher) RenderMarkdown(content []byte) (string, error) { 192 - // Render markdown to HTML (no base URL for repo page descriptions) 193 - return f.renderMarkdown(content, "") 194 - } 195 - 196 - // Regex patterns for matching relative URLs that need rewriting 197 - // These match src="..." or href="..." where the URL is relative (not absolute, not data:, not #anchor) 198 - var ( 199 - // Match src="filename" where filename doesn't start with http://, https://, //, /, #, data:, or mailto: 200 - relativeSrcPattern = regexp.MustCompile(`src="([^"/:][^"]*)"`) 201 - // Match href="filename" where filename doesn't start with http://, https://, //, /, #, data:, or mailto: 202 - relativeHrefPattern = regexp.MustCompile(`href="([^"/:][^"]*)"`) 203 - ) 204 - 205 183 // rewriteRelativeURLs converts relative URLs to absolute URLs 206 184 func rewriteRelativeURLs(html, baseURL string) string { 207 185 if baseURL == "" { ··· 213 191 return html 214 192 } 215 193 216 - // Handle root-relative URLs (starting with /) first 217 - // Must be done before bare relative URLs to avoid double-processing 218 - if base.Scheme != "" && base.Host != "" { 219 - root := fmt.Sprintf("%s://%s/", base.Scheme, base.Host) 220 - // Replace src="/" and href="/" but not src="//" (protocol-relative URLs) 221 - html = strings.ReplaceAll(html, `src="/`, fmt.Sprintf(`src="%s`, root)) 222 - html = strings.ReplaceAll(html, `href="/`, fmt.Sprintf(`href="%s`, root)) 223 - } 224 - 225 - // Handle explicit relative paths (./something and ../something) 194 + // Simple string replacement for common patterns 195 + // This is a basic implementation - for production, consider using an HTML parser 226 196 html = strings.ReplaceAll(html, `src="./`, fmt.Sprintf(`src="%s`, baseURL)) 227 197 html = strings.ReplaceAll(html, `href="./`, fmt.Sprintf(`href="%s`, baseURL)) 228 198 html = strings.ReplaceAll(html, `src="../`, fmt.Sprintf(`src="%s../`, baseURL)) 229 199 html = strings.ReplaceAll(html, `href="../`, fmt.Sprintf(`href="%s../`, baseURL)) 230 200 231 - // Handle bare relative URLs (e.g., src="image.png" without ./ prefix) 232 - // Skip URLs that are already absolute (start with http://, https://, or //) 233 - // Skip anchors (#), data URLs (data:), and mailto links 234 - html = relativeSrcPattern.ReplaceAllStringFunc(html, func(match string) string { 235 - // Extract the URL from src="..." 236 - url := match[5 : len(match)-1] // Remove 'src="' and '"' 237 - 238 - // Skip if already processed or is a special URL type 239 - if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") || 240 - strings.HasPrefix(url, "//") || strings.HasPrefix(url, "#") || 241 - strings.HasPrefix(url, "data:") || strings.HasPrefix(url, "mailto:") { 242 - return match 243 - } 244 - 245 - return fmt.Sprintf(`src="%s%s"`, baseURL, url) 246 - }) 247 - 248 - html = relativeHrefPattern.ReplaceAllStringFunc(html, func(match string) string { 249 - // Extract the URL from href="..." 250 - url := match[6 : len(match)-1] // Remove 'href="' and '"' 251 - 252 - // Skip if already processed or is a special URL type 253 - if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") || 254 - strings.HasPrefix(url, "//") || strings.HasPrefix(url, "#") || 255 - strings.HasPrefix(url, "data:") || strings.HasPrefix(url, "mailto:") { 256 - return match 257 - } 258 - 259 - return fmt.Sprintf(`href="%s%s"`, baseURL, url) 260 - }) 201 + // Handle root-relative URLs (starting with /) 202 + if base.Scheme != "" && base.Host != "" { 203 + root := fmt.Sprintf("%s://%s/", base.Scheme, base.Host) 204 + // Replace src="/" and href="/" but not src="//" (absolute URLs) 205 + html = strings.ReplaceAll(html, `src="/`, fmt.Sprintf(`src="%s`, root)) 206 + html = strings.ReplaceAll(html, `href="/`, fmt.Sprintf(`href="%s`, root)) 207 + } 261 208 262 209 return html 263 210 }
-148
pkg/appview/readme/fetcher_test.go
··· 145 145 baseURL: "https://example.com/docs/", 146 146 expected: `<img src="https://example.com//cdn.example.com/image.png">`, 147 147 }, 148 - { 149 - name: "bare relative src (no ./ prefix)", 150 - html: `<img src="image.png">`, 151 - baseURL: "https://example.com/docs/", 152 - expected: `<img src="https://example.com/docs/image.png">`, 153 - }, 154 - { 155 - name: "bare relative href (no ./ prefix)", 156 - html: `<a href="page.html">link</a>`, 157 - baseURL: "https://example.com/docs/", 158 - expected: `<a href="https://example.com/docs/page.html">link</a>`, 159 - }, 160 - { 161 - name: "bare relative with path", 162 - html: `<img src="images/logo.png">`, 163 - baseURL: "https://example.com/docs/", 164 - expected: `<img src="https://example.com/docs/images/logo.png">`, 165 - }, 166 - { 167 - name: "anchor links unchanged", 168 - html: `<a href="#section">link</a>`, 169 - baseURL: "https://example.com/docs/", 170 - expected: `<a href="#section">link</a>`, 171 - }, 172 - { 173 - name: "data URLs unchanged", 174 - html: `<img src="data:image/png;base64,abc123">`, 175 - baseURL: "https://example.com/docs/", 176 - expected: `<img src="data:image/png;base64,abc123">`, 177 - }, 178 - { 179 - name: "mailto links unchanged", 180 - html: `<a href="mailto:test@example.com">email</a>`, 181 - baseURL: "https://example.com/docs/", 182 - expected: `<a href="mailto:test@example.com">email</a>`, 183 - }, 184 - { 185 - name: "mixed bare and prefixed relative URLs", 186 - html: `<img src="slices_and_lucy.png"><a href="./other.md">link</a>`, 187 - baseURL: "https://github.com/user/repo/blob/main/", 188 - expected: `<img src="https://github.com/user/repo/blob/main/slices_and_lucy.png"><a href="https://github.com/user/repo/blob/main/other.md">link</a>`, 189 - }, 190 148 } 191 149 192 150 for _, tt := range tests { ··· 197 155 } 198 156 }) 199 157 } 200 - } 201 - 202 - func TestFetcher_RenderMarkdown(t *testing.T) { 203 - fetcher := NewFetcher() 204 - 205 - tests := []struct { 206 - name string 207 - content string 208 - wantContain string 209 - wantErr bool 210 - }{ 211 - { 212 - name: "simple paragraph", 213 - content: "Hello, world!", 214 - wantContain: "<p>Hello, world!</p>", 215 - wantErr: false, 216 - }, 217 - { 218 - name: "heading", 219 - content: "# My App", 220 - wantContain: "<h1", 221 - wantErr: false, 222 - }, 223 - { 224 - name: "bold text", 225 - content: "This is **bold** text.", 226 - wantContain: "<strong>bold</strong>", 227 - wantErr: false, 228 - }, 229 - { 230 - name: "italic text", 231 - content: "This is *italic* text.", 232 - wantContain: "<em>italic</em>", 233 - wantErr: false, 234 - }, 235 - { 236 - name: "code block", 237 - content: "```\ncode here\n```", 238 - wantContain: "<pre>", 239 - wantErr: false, 240 - }, 241 - { 242 - name: "link", 243 - content: "[Link text](https://example.com)", 244 - wantContain: `href="https://example.com"`, 245 - wantErr: false, 246 - }, 247 - { 248 - name: "image", 249 - content: "![Alt text](https://example.com/image.png)", 250 - wantContain: `src="https://example.com/image.png"`, 251 - wantErr: false, 252 - }, 253 - { 254 - name: "unordered list", 255 - content: "- Item 1\n- Item 2", 256 - wantContain: "<ul>", 257 - wantErr: false, 258 - }, 259 - { 260 - name: "ordered list", 261 - content: "1. Item 1\n2. Item 2", 262 - wantContain: "<ol>", 263 - wantErr: false, 264 - }, 265 - { 266 - name: "empty content", 267 - content: "", 268 - wantContain: "", 269 - wantErr: false, 270 - }, 271 - { 272 - name: "complex markdown", 273 - content: "# Title\n\nA paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n```go\nfunc main() {}\n```", 274 - wantContain: "<h1", 275 - wantErr: false, 276 - }, 277 - } 278 - 279 - for _, tt := range tests { 280 - t.Run(tt.name, func(t *testing.T) { 281 - html, err := fetcher.RenderMarkdown([]byte(tt.content)) 282 - if (err != nil) != tt.wantErr { 283 - t.Errorf("RenderMarkdown() error = %v, wantErr %v", err, tt.wantErr) 284 - return 285 - } 286 - if !tt.wantErr && tt.wantContain != "" { 287 - if !containsSubstring(html, tt.wantContain) { 288 - t.Errorf("RenderMarkdown() = %q, want to contain %q", html, tt.wantContain) 289 - } 290 - } 291 - }) 292 - } 293 - } 294 - 295 - func containsSubstring(s, substr string) bool { 296 - return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr))) 297 - } 298 - 299 - func containsSubstringHelper(s, substr string) bool { 300 - for i := 0; i <= len(s)-len(substr); i++ { 301 - if s[i:i+len(substr)] == substr { 302 - return true 303 - } 304 - } 305 - return false 306 158 } 307 159 308 160 // TODO: Add README fetching and caching tests
-103
pkg/appview/readme/source.go
··· 1 - package readme 2 - 3 - import ( 4 - "fmt" 5 - "net/url" 6 - "strings" 7 - ) 8 - 9 - // Platform represents a supported Git hosting platform 10 - type Platform string 11 - 12 - const ( 13 - PlatformGitHub Platform = "github" 14 - PlatformGitLab Platform = "gitlab" 15 - PlatformTangled Platform = "tangled" 16 - ) 17 - 18 - // ParseSourceURL extracts platform, user, and repo from a source repository URL. 19 - // Returns ok=false if the URL is not a recognized pattern. 20 - func ParseSourceURL(sourceURL string) (platform Platform, user, repo string, ok bool) { 21 - if sourceURL == "" { 22 - return "", "", "", false 23 - } 24 - 25 - parsed, err := url.Parse(sourceURL) 26 - if err != nil { 27 - return "", "", "", false 28 - } 29 - 30 - // Normalize: remove trailing slash and .git suffix 31 - path := strings.TrimSuffix(parsed.Path, "/") 32 - path = strings.TrimSuffix(path, ".git") 33 - path = strings.TrimPrefix(path, "/") 34 - 35 - if path == "" { 36 - return "", "", "", false 37 - } 38 - 39 - host := strings.ToLower(parsed.Host) 40 - 41 - switch { 42 - case host == "github.com": 43 - // GitHub: github.com/{user}/{repo} 44 - parts := strings.SplitN(path, "/", 3) 45 - if len(parts) < 2 || parts[0] == "" || parts[1] == "" { 46 - return "", "", "", false 47 - } 48 - return PlatformGitHub, parts[0], parts[1], true 49 - 50 - case host == "gitlab.com": 51 - // GitLab: gitlab.com/{user}/{repo} or gitlab.com/{group}/{subgroup}/{repo} 52 - // For nested groups, user = everything except last part, repo = last part 53 - lastSlash := strings.LastIndex(path, "/") 54 - if lastSlash == -1 || lastSlash == 0 { 55 - return "", "", "", false 56 - } 57 - user = path[:lastSlash] 58 - repo = path[lastSlash+1:] 59 - if user == "" || repo == "" { 60 - return "", "", "", false 61 - } 62 - return PlatformGitLab, user, repo, true 63 - 64 - case host == "tangled.org" || host == "tangled.sh": 65 - // Tangled: tangled.org/{user}/{repo} or tangled.sh/@{user}/{repo} (legacy) 66 - // Strip leading @ from user if present 67 - path = strings.TrimPrefix(path, "@") 68 - parts := strings.SplitN(path, "/", 3) 69 - if len(parts) < 2 || parts[0] == "" || parts[1] == "" { 70 - return "", "", "", false 71 - } 72 - return PlatformTangled, parts[0], parts[1], true 73 - 74 - default: 75 - return "", "", "", false 76 - } 77 - } 78 - 79 - // DeriveReadmeURL converts a source repository URL to a raw README URL. 80 - // Returns empty string if platform is not supported. 81 - func DeriveReadmeURL(sourceURL, branch string) string { 82 - platform, user, repo, ok := ParseSourceURL(sourceURL) 83 - if !ok { 84 - return "" 85 - } 86 - 87 - switch platform { 88 - case PlatformGitHub: 89 - // https://raw.githubusercontent.com/{user}/{repo}/refs/heads/{branch}/README.md 90 - return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/refs/heads/%s/README.md", user, repo, branch) 91 - 92 - case PlatformGitLab: 93 - // https://gitlab.com/{user}/{repo}/-/raw/{branch}/README.md 94 - return fmt.Sprintf("https://gitlab.com/%s/%s/-/raw/%s/README.md", user, repo, branch) 95 - 96 - case PlatformTangled: 97 - // https://tangled.org/{user}/{repo}/raw/{branch}/README.md 98 - return fmt.Sprintf("https://tangled.org/%s/%s/raw/%s/README.md", user, repo, branch) 99 - 100 - default: 101 - return "" 102 - } 103 - }
-241
pkg/appview/readme/source_test.go
··· 1 - package readme 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - func TestParseSourceURL(t *testing.T) { 8 - tests := []struct { 9 - name string 10 - sourceURL string 11 - wantPlatform Platform 12 - wantUser string 13 - wantRepo string 14 - wantOK bool 15 - }{ 16 - // GitHub 17 - { 18 - name: "github standard", 19 - sourceURL: "https://github.com/bigmoves/quickslice", 20 - wantPlatform: PlatformGitHub, 21 - wantUser: "bigmoves", 22 - wantRepo: "quickslice", 23 - wantOK: true, 24 - }, 25 - { 26 - name: "github with .git suffix", 27 - sourceURL: "https://github.com/user/repo.git", 28 - wantPlatform: PlatformGitHub, 29 - wantUser: "user", 30 - wantRepo: "repo", 31 - wantOK: true, 32 - }, 33 - { 34 - name: "github with trailing slash", 35 - sourceURL: "https://github.com/user/repo/", 36 - wantPlatform: PlatformGitHub, 37 - wantUser: "user", 38 - wantRepo: "repo", 39 - wantOK: true, 40 - }, 41 - { 42 - name: "github with subpath (ignored)", 43 - sourceURL: "https://github.com/user/repo/tree/main", 44 - wantPlatform: PlatformGitHub, 45 - wantUser: "user", 46 - wantRepo: "repo", 47 - wantOK: true, 48 - }, 49 - { 50 - name: "github user only", 51 - sourceURL: "https://github.com/user", 52 - wantOK: false, 53 - }, 54 - 55 - // GitLab 56 - { 57 - name: "gitlab standard", 58 - sourceURL: "https://gitlab.com/user/repo", 59 - wantPlatform: PlatformGitLab, 60 - wantUser: "user", 61 - wantRepo: "repo", 62 - wantOK: true, 63 - }, 64 - { 65 - name: "gitlab nested groups", 66 - sourceURL: "https://gitlab.com/group/subgroup/repo", 67 - wantPlatform: PlatformGitLab, 68 - wantUser: "group/subgroup", 69 - wantRepo: "repo", 70 - wantOK: true, 71 - }, 72 - { 73 - name: "gitlab deep nested groups", 74 - sourceURL: "https://gitlab.com/a/b/c/d/repo", 75 - wantPlatform: PlatformGitLab, 76 - wantUser: "a/b/c/d", 77 - wantRepo: "repo", 78 - wantOK: true, 79 - }, 80 - { 81 - name: "gitlab with .git suffix", 82 - sourceURL: "https://gitlab.com/user/repo.git", 83 - wantPlatform: PlatformGitLab, 84 - wantUser: "user", 85 - wantRepo: "repo", 86 - wantOK: true, 87 - }, 88 - 89 - // Tangled 90 - { 91 - name: "tangled standard", 92 - sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry", 93 - wantPlatform: PlatformTangled, 94 - wantUser: "evan.jarrett.net", 95 - wantRepo: "at-container-registry", 96 - wantOK: true, 97 - }, 98 - { 99 - name: "tangled with legacy @ prefix", 100 - sourceURL: "https://tangled.org/@evan.jarrett.net/at-container-registry", 101 - wantPlatform: PlatformTangled, 102 - wantUser: "evan.jarrett.net", 103 - wantRepo: "at-container-registry", 104 - wantOK: true, 105 - }, 106 - { 107 - name: "tangled.sh domain", 108 - sourceURL: "https://tangled.sh/user/repo", 109 - wantPlatform: PlatformTangled, 110 - wantUser: "user", 111 - wantRepo: "repo", 112 - wantOK: true, 113 - }, 114 - { 115 - name: "tangled with trailing slash", 116 - sourceURL: "https://tangled.org/user/repo/", 117 - wantPlatform: PlatformTangled, 118 - wantUser: "user", 119 - wantRepo: "repo", 120 - wantOK: true, 121 - }, 122 - 123 - // Unsupported / Invalid 124 - { 125 - name: "unsupported platform", 126 - sourceURL: "https://bitbucket.org/user/repo", 127 - wantOK: false, 128 - }, 129 - { 130 - name: "empty url", 131 - sourceURL: "", 132 - wantOK: false, 133 - }, 134 - { 135 - name: "invalid url", 136 - sourceURL: "not-a-url", 137 - wantOK: false, 138 - }, 139 - { 140 - name: "just host", 141 - sourceURL: "https://github.com", 142 - wantOK: false, 143 - }, 144 - } 145 - 146 - for _, tt := range tests { 147 - t.Run(tt.name, func(t *testing.T) { 148 - platform, user, repo, ok := ParseSourceURL(tt.sourceURL) 149 - if ok != tt.wantOK { 150 - t.Errorf("ParseSourceURL(%q) ok = %v, want %v", tt.sourceURL, ok, tt.wantOK) 151 - return 152 - } 153 - if !tt.wantOK { 154 - return 155 - } 156 - if platform != tt.wantPlatform { 157 - t.Errorf("ParseSourceURL(%q) platform = %v, want %v", tt.sourceURL, platform, tt.wantPlatform) 158 - } 159 - if user != tt.wantUser { 160 - t.Errorf("ParseSourceURL(%q) user = %q, want %q", tt.sourceURL, user, tt.wantUser) 161 - } 162 - if repo != tt.wantRepo { 163 - t.Errorf("ParseSourceURL(%q) repo = %q, want %q", tt.sourceURL, repo, tt.wantRepo) 164 - } 165 - }) 166 - } 167 - } 168 - 169 - func TestDeriveReadmeURL(t *testing.T) { 170 - tests := []struct { 171 - name string 172 - sourceURL string 173 - branch string 174 - want string 175 - }{ 176 - // GitHub 177 - { 178 - name: "github main", 179 - sourceURL: "https://github.com/bigmoves/quickslice", 180 - branch: "main", 181 - want: "https://raw.githubusercontent.com/bigmoves/quickslice/refs/heads/main/README.md", 182 - }, 183 - { 184 - name: "github master", 185 - sourceURL: "https://github.com/user/repo", 186 - branch: "master", 187 - want: "https://raw.githubusercontent.com/user/repo/refs/heads/master/README.md", 188 - }, 189 - 190 - // GitLab 191 - { 192 - name: "gitlab main", 193 - sourceURL: "https://gitlab.com/user/repo", 194 - branch: "main", 195 - want: "https://gitlab.com/user/repo/-/raw/main/README.md", 196 - }, 197 - { 198 - name: "gitlab nested groups", 199 - sourceURL: "https://gitlab.com/group/subgroup/repo", 200 - branch: "main", 201 - want: "https://gitlab.com/group/subgroup/repo/-/raw/main/README.md", 202 - }, 203 - 204 - // Tangled 205 - { 206 - name: "tangled main", 207 - sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry", 208 - branch: "main", 209 - want: "https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/README.md", 210 - }, 211 - { 212 - name: "tangled legacy @ prefix", 213 - sourceURL: "https://tangled.org/@user/repo", 214 - branch: "main", 215 - want: "https://tangled.org/user/repo/raw/main/README.md", 216 - }, 217 - 218 - // Unsupported 219 - { 220 - name: "unsupported platform", 221 - sourceURL: "https://bitbucket.org/user/repo", 222 - branch: "main", 223 - want: "", 224 - }, 225 - { 226 - name: "empty url", 227 - sourceURL: "", 228 - branch: "main", 229 - want: "", 230 - }, 231 - } 232 - 233 - for _, tt := range tests { 234 - t.Run(tt.name, func(t *testing.T) { 235 - got := DeriveReadmeURL(tt.sourceURL, tt.branch) 236 - if got != tt.want { 237 - t.Errorf("DeriveReadmeURL(%q, %q) = %q, want %q", tt.sourceURL, tt.branch, got, tt.want) 238 - } 239 - }) 240 - } 241 - }
+2 -15
pkg/appview/routes/routes.go
··· 27 27 BaseURL string 28 28 DeviceStore *db.DeviceStore 29 29 HealthChecker *holdhealth.Checker 30 - ReadmeFetcher *readme.Fetcher 30 + ReadmeCache *readme.Cache 31 31 Templates *template.Template 32 32 } 33 33 ··· 160 160 Directory: deps.OAuthClientApp.Dir, 161 161 Refresher: deps.Refresher, 162 162 HealthChecker: deps.HealthChecker, 163 - ReadmeFetcher: deps.ReadmeFetcher, 163 + ReadmeCache: deps.ReadmeCache, 164 164 }, 165 165 ).ServeHTTP) 166 166 ··· 184 184 }).ServeHTTP) 185 185 186 186 r.Delete("/api/images/{repository}/manifests/{digest}", (&uihandlers.DeleteManifestHandler{ 187 - DB: deps.Database, 188 - Refresher: deps.Refresher, 189 - }).ServeHTTP) 190 - 191 - r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{ 192 187 DB: deps.Database, 193 188 Refresher: deps.Refresher, 194 189 }).ServeHTTP) ··· 224 219 } 225 220 router.Get("/auth/logout", logoutHandler.ServeHTTP) 226 221 router.Post("/auth/logout", logoutHandler.ServeHTTP) 227 - 228 - // Custom 404 handler 229 - router.NotFound(middleware.OptionalAuth(deps.SessionStore, deps.Database)( 230 - &uihandlers.NotFoundHandler{ 231 - Templates: deps.Templates, 232 - RegistryURL: registryURL, 233 - }, 234 - ).ServeHTTP) 235 222 } 236 223 237 224 // CORSMiddleware returns a middleware that sets CORS headers for API endpoints
+49 -160
pkg/appview/static/css/style.css
··· 38 38 --version-badge-text: #7b1fa2; 39 39 --version-badge-border: #ba68c8; 40 40 41 - /* Attestation badge */ 42 - --attestation-badge-bg: #d1fae5; 43 - --attestation-badge-text: #065f46; 44 - 45 41 /* Hero section colors */ 46 42 --hero-bg-start: #f8f9fa; 47 43 --hero-bg-end: #e9ecef; ··· 94 90 --version-badge-text: #ffffff; 95 91 --version-badge-border: #ba68c8; 96 92 97 - /* Attestation badge */ 98 - --attestation-badge-bg: #065f46; 99 - --attestation-badge-text: #6ee7b7; 100 - 101 93 /* Hero section colors */ 102 94 --hero-bg-start: #2d2d2d; 103 95 --hero-bg-end: #1a1a1a; ··· 117 109 } 118 110 119 111 body { 120 - font-family: 121 - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", 122 - Arial, sans-serif; 112 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 123 113 background: var(--bg); 124 114 color: var(--fg); 125 115 line-height: 1.6; ··· 180 170 } 181 171 182 172 .nav-links a:hover { 183 - background: var(--secondary); 173 + background:var(--secondary); 184 174 border-radius: 4px; 185 175 } 186 176 ··· 203 193 } 204 194 205 195 .user-menu-btn:hover { 206 - background: var(--secondary); 196 + background:var(--secondary); 207 197 } 208 198 209 199 .user-avatar { ··· 276 266 position: absolute; 277 267 top: calc(100% + 0.5rem); 278 268 right: 0; 279 - background: var(--bg); 269 + background:var(--bg); 280 270 border: 1px solid var(--border); 281 271 border-radius: 8px; 282 272 box-shadow: var(--shadow-lg); ··· 297 287 color: var(--fg); 298 288 text-decoration: none; 299 289 border: none; 300 - background: var(--bg); 290 + background:var(--bg); 301 291 cursor: pointer; 302 292 transition: background 0.2s; 303 293 font-size: 0.95rem; ··· 319 309 } 320 310 321 311 /* Buttons */ 322 - button, 323 - .btn, 324 - .btn-primary, 325 - .btn-secondary { 312 + button, .btn, .btn-primary, .btn-secondary { 326 313 padding: 0.5rem 1rem; 327 314 background: var(--button-primary); 328 315 color: var(--btn-text); ··· 335 322 transition: opacity 0.2s; 336 323 } 337 324 338 - button:hover, 339 - .btn:hover, 340 - .btn-primary:hover, 341 - .btn-secondary:hover { 325 + button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { 342 326 opacity: 0.9; 343 327 } 344 328 ··· 409 393 } 410 394 411 395 /* Cards */ 412 - .push-card, 413 - .repository-card { 396 + .push-card, .repository-card { 414 397 border: 1px solid var(--border); 415 398 border-radius: 8px; 416 399 padding: 1rem; 417 400 margin-bottom: 1rem; 418 - background: var(--bg); 401 + background:var(--bg); 419 402 box-shadow: var(--shadow-sm); 420 403 } 421 404 ··· 466 449 } 467 450 468 451 .digest { 469 - font-family: "Monaco", "Courier New", monospace; 452 + font-family: 'Monaco', 'Courier New', monospace; 470 453 font-size: 0.85rem; 471 454 background: var(--code-bg); 472 455 padding: 0.1rem 0.3rem; ··· 509 492 } 510 493 511 494 .docker-command-text { 512 - font-family: "Monaco", "Courier New", monospace; 495 + font-family: 'Monaco', 'Courier New', monospace; 513 496 font-size: 0.85rem; 514 497 color: var(--fg); 515 498 flex: 0 1 auto; ··· 527 510 border-radius: 4px; 528 511 opacity: 0; 529 512 visibility: hidden; 530 - transition: 531 - opacity 0.2s, 532 - visibility 0.2s; 513 + transition: opacity 0.2s, visibility 0.2s; 533 514 } 534 515 535 516 .docker-command:hover .copy-btn { ··· 771 752 } 772 753 773 754 .repo-stats { 774 - color: var(--border-dark); 755 + color:var(--border-dark); 775 756 font-size: 0.9rem; 776 757 display: flex; 777 758 gap: 0.5rem; ··· 800 781 padding-top: 1rem; 801 782 } 802 783 803 - .tags-section, 804 - .manifests-section { 784 + .tags-section, .manifests-section { 805 785 margin-bottom: 1.5rem; 806 786 } 807 787 808 - .tags-section h3, 809 - .manifests-section h3 { 788 + .tags-section h3, .manifests-section h3 { 810 789 font-size: 1.1rem; 811 790 margin-bottom: 0.5rem; 812 791 color: var(--secondary); 813 792 } 814 793 815 - .tag-row, 816 - .manifest-row { 794 + .tag-row, .manifest-row { 817 795 display: flex; 818 796 gap: 1rem; 819 797 align-items: center; ··· 821 799 border-bottom: 1px solid var(--border); 822 800 } 823 801 824 - .tag-row:last-child, 825 - .manifest-row:last-child { 802 + .tag-row:last-child, .manifest-row:last-child { 826 803 border-bottom: none; 827 804 } 828 805 ··· 844 821 } 845 822 846 823 .settings-section { 847 - background: var(--bg); 824 + background:var(--bg); 848 825 border: 1px solid var(--border); 849 826 border-radius: 8px; 850 827 padding: 1.5rem; ··· 941 918 padding: 1rem; 942 919 border-radius: 4px; 943 920 overflow-x: auto; 944 - font-family: "Monaco", "Courier New", monospace; 921 + font-family: 'Monaco', 'Courier New', monospace; 945 922 font-size: 0.85rem; 946 923 border: 1px solid var(--border); 947 924 } ··· 1027 1004 margin: 1rem 0; 1028 1005 } 1029 1006 1007 + /* Load More Button */ 1008 + .load-more { 1009 + width: 100%; 1010 + margin-top: 1rem; 1011 + background: var(--secondary); 1012 + } 1013 + 1030 1014 /* Login Page */ 1031 1015 .login-page { 1032 1016 max-width: 450px; ··· 1047 1031 } 1048 1032 1049 1033 .login-form { 1050 - background: var(--bg); 1034 + background:var(--bg); 1051 1035 padding: 2rem; 1052 1036 border-radius: 8px; 1053 1037 border: 1px solid var(--border); ··· 1198 1182 } 1199 1183 1200 1184 .repository-header { 1201 - background: var(--bg); 1185 + background:var(--bg); 1202 1186 border: 1px solid var(--border); 1203 1187 border-radius: 8px; 1204 1188 padding: 2rem; ··· 1236 1220 flex-shrink: 0; 1237 1221 } 1238 1222 1239 - .repo-hero-icon-wrapper { 1240 - position: relative; 1241 - display: inline-block; 1242 - flex-shrink: 0; 1243 - } 1244 - 1245 - .avatar-upload-overlay { 1246 - position: absolute; 1247 - inset: 0; 1248 - display: flex; 1249 - align-items: center; 1250 - justify-content: center; 1251 - background: rgba(0, 0, 0, 0.5); 1252 - border-radius: 12px; 1253 - opacity: 0; 1254 - cursor: pointer; 1255 - transition: opacity 0.2s ease; 1256 - } 1257 - 1258 - .avatar-upload-overlay i { 1259 - color: white; 1260 - width: 24px; 1261 - height: 24px; 1262 - } 1263 - 1264 - .repo-hero-icon-wrapper:hover .avatar-upload-overlay { 1265 - opacity: 1; 1266 - } 1267 - 1268 1223 .repo-hero-info { 1269 1224 flex: 1; 1270 1225 } ··· 1335 1290 } 1336 1291 1337 1292 .star-btn.starred { 1338 - border-color: var(--star); 1293 + border-color:var(--star); 1339 1294 background: var(--code-bg); 1340 1295 } 1341 1296 ··· 1419 1374 } 1420 1375 1421 1376 .repo-section { 1422 - background: var(--bg); 1377 + background:var(--bg); 1423 1378 border: 1px solid var(--border); 1424 1379 border-radius: 8px; 1425 1380 padding: 1.5rem; ··· 1434 1389 border-bottom: 2px solid var(--border); 1435 1390 } 1436 1391 1437 - .tags-list, 1438 - .manifests-list { 1392 + .tags-list, .manifests-list { 1439 1393 display: flex; 1440 1394 flex-direction: column; 1441 1395 gap: 1rem; 1442 1396 } 1443 1397 1444 - .tag-item, 1445 - .manifest-item { 1398 + .tag-item, .manifest-item { 1446 1399 border: 1px solid var(--border); 1447 1400 border-radius: 6px; 1448 1401 padding: 1rem; 1449 1402 background: var(--hover-bg); 1450 1403 } 1451 1404 1452 - .tag-item-header, 1453 - .manifest-item-header { 1405 + .tag-item-header, .manifest-item-header { 1454 1406 display: flex; 1455 1407 justify-content: space-between; 1456 1408 align-items: center; ··· 1580 1532 color: var(--fg); 1581 1533 border: 1px solid var(--border); 1582 1534 white-space: nowrap; 1583 - font-family: "Monaco", "Courier New", monospace; 1535 + font-family: 'Monaco', 'Courier New', monospace; 1584 1536 } 1585 1537 1586 1538 .platforms-inline { ··· 1618 1570 .badge-attestation { 1619 1571 display: inline-flex; 1620 1572 align-items: center; 1621 - gap: 0.3rem; 1622 - padding: 0.25rem 0.6rem; 1623 - background: var(--attestation-badge-bg); 1624 - color: var(--attestation-badge-text); 1625 - border-radius: 12px; 1626 - font-size: 0.75rem; 1573 + gap: 0.35rem; 1574 + padding: 0.25rem 0.5rem; 1575 + background: #f3e8ff; 1576 + color: #7c3aed; 1577 + border: 1px solid #c4b5fd; 1578 + border-radius: 4px; 1579 + font-size: 0.85rem; 1627 1580 font-weight: 600; 1628 1581 margin-left: 0.5rem; 1629 - vertical-align: middle; 1630 - white-space: nowrap; 1631 1582 } 1632 1583 1633 1584 .badge-attestation .lucide { 1634 - width: 0.75rem; 1635 - height: 0.75rem; 1585 + width: 0.9rem; 1586 + height: 0.9rem; 1636 1587 } 1637 1588 1638 1589 /* Featured Repositories Section */ ··· 1785 1736 1786 1737 /* Hero Section */ 1787 1738 .hero-section { 1788 - background: linear-gradient( 1789 - 135deg, 1790 - var(--hero-bg-start) 0%, 1791 - var(--hero-bg-end) 100% 1792 - ); 1739 + background: linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-end) 100%); 1793 1740 padding: 4rem 2rem; 1794 1741 border-bottom: 1px solid var(--border); 1795 1742 } ··· 1854 1801 .terminal-content { 1855 1802 padding: 1.5rem; 1856 1803 margin: 0; 1857 - font-family: "Monaco", "Courier New", monospace; 1804 + font-family: 'Monaco', 'Courier New', monospace; 1858 1805 font-size: 0.95rem; 1859 1806 line-height: 1.8; 1860 1807 color: var(--terminal-text); ··· 2010 1957 } 2011 1958 2012 1959 .code-block code { 2013 - font-family: "Monaco", "Menlo", monospace; 1960 + font-family: 'Monaco', 'Menlo', monospace; 2014 1961 font-size: 0.9rem; 2015 1962 line-height: 1.5; 2016 1963 white-space: pre-wrap; ··· 2067 2014 flex-wrap: wrap; 2068 2015 } 2069 2016 2070 - .tag-row, 2071 - .manifest-row { 2017 + .tag-row, .manifest-row { 2072 2018 flex-wrap: wrap; 2073 2019 } 2074 2020 ··· 2157 2103 /* README and Repository Layout */ 2158 2104 .repo-content-layout { 2159 2105 display: grid; 2160 - grid-template-columns: 6fr 4fr; 2106 + grid-template-columns: 7fr 3fr; 2161 2107 gap: 2rem; 2162 2108 margin-top: 2rem; 2163 2109 } ··· 2268 2214 background: var(--code-bg); 2269 2215 padding: 0.2rem 0.4rem; 2270 2216 border-radius: 3px; 2271 - font-family: 2272 - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; 2217 + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 2273 2218 font-size: 0.9em; 2274 2219 } 2275 2220 ··· 2373 2318 padding: 0.75rem; 2374 2319 } 2375 2320 } 2376 - 2377 - /* 404 Error Page */ 2378 - .error-page { 2379 - display: flex; 2380 - align-items: center; 2381 - justify-content: center; 2382 - min-height: calc(100vh - 60px); 2383 - text-align: center; 2384 - padding: 2rem; 2385 - } 2386 - 2387 - .error-content { 2388 - max-width: 480px; 2389 - } 2390 - 2391 - .error-icon { 2392 - width: 80px; 2393 - height: 80px; 2394 - color: var(--secondary); 2395 - margin-bottom: 1.5rem; 2396 - } 2397 - 2398 - .error-code { 2399 - font-size: 8rem; 2400 - font-weight: 700; 2401 - color: var(--primary); 2402 - line-height: 1; 2403 - margin-bottom: 0.5rem; 2404 - } 2405 - 2406 - .error-content h1 { 2407 - font-size: 2rem; 2408 - margin-bottom: 0.75rem; 2409 - color: var(--fg); 2410 - } 2411 - 2412 - .error-content p { 2413 - font-size: 1.125rem; 2414 - color: var(--secondary); 2415 - margin-bottom: 2rem; 2416 - } 2417 - 2418 - @media (max-width: 768px) { 2419 - .error-code { 2420 - font-size: 5rem; 2421 - } 2422 - 2423 - .error-icon { 2424 - width: 60px; 2425 - height: 60px; 2426 - } 2427 - 2428 - .error-content h1 { 2429 - font-size: 1.5rem; 2430 - } 2431 - }
-63
pkg/appview/static/js/app.js
··· 434 434 } 435 435 } 436 436 437 - // Upload repository avatar 438 - async function uploadAvatar(input, repository) { 439 - const file = input.files[0]; 440 - if (!file) return; 441 - 442 - // Client-side validation 443 - const validTypes = ['image/png', 'image/jpeg', 'image/webp']; 444 - if (!validTypes.includes(file.type)) { 445 - alert('Please select a PNG, JPEG, or WebP image'); 446 - return; 447 - } 448 - if (file.size > 3 * 1024 * 1024) { 449 - alert('Image must be less than 3MB'); 450 - return; 451 - } 452 - 453 - const formData = new FormData(); 454 - formData.append('avatar', file); 455 - 456 - try { 457 - const response = await fetch(`/api/images/${repository}/avatar`, { 458 - method: 'POST', 459 - credentials: 'include', 460 - body: formData 461 - }); 462 - 463 - if (response.status === 401) { 464 - window.location.href = '/auth/oauth/login'; 465 - return; 466 - } 467 - 468 - if (!response.ok) { 469 - const error = await response.text(); 470 - throw new Error(error); 471 - } 472 - 473 - const data = await response.json(); 474 - 475 - // Update the avatar image on the page 476 - const wrapper = document.querySelector('.repo-hero-icon-wrapper'); 477 - if (!wrapper) return; 478 - 479 - const existingImg = wrapper.querySelector('.repo-hero-icon'); 480 - const placeholder = wrapper.querySelector('.repo-hero-icon-placeholder'); 481 - 482 - if (existingImg) { 483 - existingImg.src = data.avatarURL; 484 - } else if (placeholder) { 485 - const newImg = document.createElement('img'); 486 - newImg.src = data.avatarURL; 487 - newImg.alt = repository; 488 - newImg.className = 'repo-hero-icon'; 489 - placeholder.replaceWith(newImg); 490 - } 491 - } catch (err) { 492 - console.error('Error uploading avatar:', err); 493 - alert('Failed to upload avatar: ' + err.message); 494 - } 495 - 496 - // Clear input so same file can be selected again 497 - input.value = ''; 498 - } 499 - 500 437 // Close modal when clicking outside 501 438 document.addEventListener('DOMContentLoaded', () => { 502 439 const modal = document.getElementById('manifest-delete-modal');
+12 -5
pkg/appview/storage/context.go
··· 1 1 package storage 2 2 3 3 import ( 4 - "atcr.io/pkg/appview/readme" 4 + "context" 5 + 5 6 "atcr.io/pkg/atproto" 6 7 "atcr.io/pkg/auth" 7 8 "atcr.io/pkg/auth/oauth" ··· 12 13 IncrementPullCount(did, repository string) error 13 14 IncrementPushCount(did, repository string) error 14 15 GetLatestHoldDIDForRepo(did, repository string) (string, error) 16 + } 17 + 18 + // ReadmeCache interface for README content caching 19 + type ReadmeCache interface { 20 + Get(ctx context.Context, url string) (string, error) 21 + Invalidate(url string) error 15 22 } 16 23 17 24 // RegistryContext bundles all the context needed for registry operations ··· 28 35 AuthMethod string // Auth method used ("oauth" or "app_password") 29 36 30 37 // Shared services (same for all requests) 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 38 + Database DatabaseMetrics // Metrics tracking database 39 + Authorizer auth.HoldAuthorizer // Hold access authorization 40 + Refresher *oauth.Refresher // OAuth session manager 41 + ReadmeCache ReadmeCache // README content cache 35 42 }
+34 -1
pkg/appview/storage/context_test.go
··· 1 1 package storage 2 2 3 3 import ( 4 + "context" 4 5 "sync" 5 6 "testing" 6 7 ··· 45 46 return m.pushCount 46 47 } 47 48 49 + type mockReadmeCache struct{} 50 + 51 + func (m *mockReadmeCache) Get(ctx context.Context, url string) (string, error) { 52 + return "# Test README", nil 53 + } 54 + 55 + func (m *mockReadmeCache) Invalidate(url string) error { 56 + return nil 57 + } 58 + 48 59 type mockHoldAuthorizer struct{} 49 60 50 61 func (m *mockHoldAuthorizer) Authorize(holdDID, userDID, permission string) (bool, error) { ··· 63 74 ATProtoClient: &atproto.Client{ 64 75 // Mock client - would need proper initialization in real tests 65 76 }, 66 - Database: &mockDatabaseMetrics{}, 77 + Database: &mockDatabaseMetrics{}, 78 + ReadmeCache: &mockReadmeCache{}, 67 79 } 68 80 69 81 // Verify fields are accessible ··· 100 112 } 101 113 102 114 err = ctx.Database.IncrementPushCount("did:plc:test", "repo") 115 + if err != nil { 116 + t.Errorf("Unexpected error: %v", err) 117 + } 118 + } 119 + 120 + func TestRegistryContext_ReadmeCacheInterface(t *testing.T) { 121 + cache := &mockReadmeCache{} 122 + ctx := &RegistryContext{ 123 + ReadmeCache: cache, 124 + } 125 + 126 + // Test that interface methods are callable 127 + content, err := ctx.ReadmeCache.Get(context.Background(), "https://example.com/README.md") 128 + if err != nil { 129 + t.Errorf("Unexpected error: %v", err) 130 + } 131 + if content != "# Test README" { 132 + t.Errorf("Expected content %q, got %q", "# Test README", content) 133 + } 134 + 135 + err = ctx.ReadmeCache.Invalidate("https://example.com/README.md") 103 136 if err != nil { 104 137 t.Errorf("Unexpected error: %v", err) 105 138 }
+34 -277
pkg/appview/storage/manifest_store.go
··· 8 8 "fmt" 9 9 "io" 10 10 "log/slog" 11 - "maps" 12 11 "net/http" 13 12 "strings" 14 13 "sync" 15 - "time" 16 14 17 - "atcr.io/pkg/appview/readme" 18 15 "atcr.io/pkg/atproto" 19 16 "github.com/distribution/distribution/v3" 20 17 "github.com/opencontainers/go-digest" ··· 62 59 } 63 60 } 64 61 65 - var manifestRecord atproto.ManifestRecord 62 + var manifestRecord atproto.Manifest 66 63 if err := json.Unmarshal(record.Value, &manifestRecord); err != nil { 67 64 return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err) 68 65 } 69 66 70 67 // Store the hold DID for subsequent blob requests during pull 71 - // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format) 68 + // Prefer HoldDid (new format) with fallback to HoldEndpoint (legacy URL format) 72 69 // The routing repository will cache this for concurrent blob fetches 73 70 s.mu.Lock() 74 - if manifestRecord.HoldDID != "" { 71 + if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" { 75 72 // New format: DID reference (preferred) 76 - s.lastFetchedHoldDID = manifestRecord.HoldDID 77 - } else if manifestRecord.HoldEndpoint != "" { 73 + s.lastFetchedHoldDID = *manifestRecord.HoldDid 74 + } else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" { 78 75 // Legacy format: URL reference - convert to DID 79 - s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 76 + s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint) 80 77 } 81 78 s.mu.Unlock() 82 79 83 80 var ociManifest []byte 84 81 85 82 // New records: Download blob from ATProto blob storage 86 - if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Link != "" { 87 - ociManifest, err = s.ctx.ATProtoClient.GetBlob(ctx, manifestRecord.ManifestBlob.Ref.Link) 83 + if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Defined() { 84 + ociManifest, err = s.ctx.ATProtoClient.GetBlob(ctx, manifestRecord.ManifestBlob.Ref.String()) 88 85 if err != nil { 89 86 return nil, fmt.Errorf("failed to download manifest blob: %w", err) 90 87 } ··· 137 134 138 135 // Set the blob reference, hold DID, and hold endpoint 139 136 manifestRecord.ManifestBlob = blobRef 140 - manifestRecord.HoldDID = s.ctx.HoldDID // Primary reference (DID) 137 + if s.ctx.HoldDID != "" { 138 + manifestRecord.HoldDid = &s.ctx.HoldDID // Primary reference (DID) 139 + } 141 140 142 141 // Extract Dockerfile labels from config blob and add to annotations 143 142 // Only for image manifests (not manifest lists which don't have config blobs) ··· 164 163 if !exists { 165 164 platform := "unknown" 166 165 if ref.Platform != nil { 167 - platform = fmt.Sprintf("%s/%s", ref.Platform.OS, ref.Platform.Architecture) 166 + platform = fmt.Sprintf("%s/%s", ref.Platform.Os, ref.Platform.Architecture) 168 167 } 169 168 slog.Warn("Manifest list references non-existent child manifest", 170 169 "repository", s.ctx.Repository, ··· 175 174 } 176 175 } 177 176 178 - if !isManifestList && s.blobStore != nil && manifestRecord.Config != nil && manifestRecord.Config.Digest != "" { 179 - labels, err := s.extractConfigLabels(ctx, manifestRecord.Config.Digest) 180 - if err != nil { 181 - // Log error but don't fail the push - labels are optional 182 - slog.Warn("Failed to extract config labels", "error", err) 183 - } else { 184 - // Initialize annotations map if needed 185 - if manifestRecord.Annotations == nil { 186 - manifestRecord.Annotations = make(map[string]string) 187 - } 188 - 189 - // Copy labels to annotations (Dockerfile LABELs โ†’ manifest annotations) 190 - maps.Copy(manifestRecord.Annotations, labels) 191 - 192 - slog.Debug("Extracted labels from config blob", "count", len(labels)) 193 - } 194 - } 177 + // Note: Label extraction from config blob is currently disabled because the generated 178 + // Manifest_Annotations type doesn't support arbitrary keys. The lexicon schema would 179 + // need to use "unknown" type for annotations to support dynamic key-value pairs. 180 + // TODO: Update lexicon schema if label extraction is needed. 181 + _ = isManifestList // silence unused variable warning for now 195 182 196 183 // Store manifest record in ATProto 197 184 rkey := digestToRKey(dgst) ··· 238 225 }() 239 226 } 240 227 241 - // Create or update repo page asynchronously if manifest has relevant annotations 242 - // This ensures repository metadata is synced to user's PDS 228 + // Refresh README cache asynchronously if manifest has io.atcr.readme annotation 229 + // This ensures fresh README content is available on repository pages 243 230 go func() { 244 231 defer func() { 245 232 if r := recover(); r != nil { 246 - slog.Error("Panic in ensureRepoPage", "panic", r) 233 + slog.Error("Panic in refreshReadmeCache", "panic", r) 247 234 } 248 235 }() 249 - s.ensureRepoPage(context.Background(), manifestRecord) 236 + s.refreshReadmeCache(context.Background(), manifestRecord) 250 237 }() 251 238 252 239 return dgst, nil ··· 318 305 319 306 // notifyHoldAboutManifest notifies the hold service about a manifest upload 320 307 // This enables the hold to create layer records and Bluesky posts 321 - func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.ManifestRecord, tag, manifestDigest string) error { 308 + func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.Manifest, tag, manifestDigest string) error { 322 309 // Skip if no service token configured (e.g., anonymous pulls) 323 310 if s.ctx.ServiceToken == "" { 324 311 return nil ··· 368 355 } 369 356 if m.Platform != nil { 370 357 mData["platform"] = map[string]any{ 371 - "os": m.Platform.OS, 358 + "os": m.Platform.Os, 372 359 "architecture": m.Platform.Architecture, 373 360 } 374 361 } ··· 425 412 return nil 426 413 } 427 414 428 - // ensureRepoPage creates or updates a repo page record in the user's PDS if needed 429 - // This syncs repository metadata from manifest annotations to the io.atcr.repo.page collection 430 - // Only creates a new record if one doesn't exist (doesn't overwrite user's custom content) 431 - func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) { 432 - // Check if repo page already exists (don't overwrite user's custom content) 433 - rkey := s.ctx.Repository 434 - _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey) 435 - if err == nil { 436 - // Record already exists - don't overwrite 437 - slog.Debug("Repo page already exists, skipping creation", "did", s.ctx.DID, "repository", s.ctx.Repository) 438 - return 439 - } 440 - 441 - // Only continue if it's a "not found" error - other errors mean we should skip 442 - if !errors.Is(err, atproto.ErrRecordNotFound) { 443 - slog.Warn("Failed to check for existing repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err) 444 - return 445 - } 446 - 447 - // Get annotations (may be nil if image has no OCI labels) 448 - annotations := manifestRecord.Annotations 449 - if annotations == nil { 450 - annotations = make(map[string]string) 451 - } 452 - 453 - // Try to fetch README content from external sources 454 - // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description 455 - description := s.fetchReadmeContent(ctx, annotations) 456 - 457 - // If no README content could be fetched, fall back to description annotation 458 - if description == "" { 459 - description = annotations["org.opencontainers.image.description"] 460 - } 461 - 462 - // Try to fetch and upload icon from io.atcr.icon annotation 463 - var avatarRef *atproto.ATProtoBlobRef 464 - if iconURL := annotations["io.atcr.icon"]; iconURL != "" { 465 - avatarRef = s.fetchAndUploadIcon(ctx, iconURL) 466 - } 467 - 468 - // Create new repo page record with description and optional avatar 469 - repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef) 470 - 471 - slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository, "descriptionLength", len(description), "hasAvatar", avatarRef != nil) 472 - 473 - _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.RepoPageCollection, rkey, repoPage) 474 - if err != nil { 475 - slog.Warn("Failed to create repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err) 415 + // refreshReadmeCache refreshes the README cache for this manifest if it has io.atcr.readme annotation 416 + // This should be called asynchronously after manifest push to keep README content fresh 417 + // NOTE: Currently disabled because the generated Manifest_Annotations type doesn't support 418 + // arbitrary key-value pairs. Would need to update lexicon schema with "unknown" type. 419 + func (s *ManifestStore) refreshReadmeCache(ctx context.Context, manifestRecord *atproto.Manifest) { 420 + // Skip if no README cache configured 421 + if s.ctx.ReadmeCache == nil { 476 422 return 477 423 } 478 424 479 - slog.Info("Repo page created successfully", "did", s.ctx.DID, "repository", s.ctx.Repository) 480 - } 481 - 482 - // fetchReadmeContent attempts to fetch README content from external sources 483 - // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source 484 - // Returns the raw markdown content, or empty string if not available 485 - func (s *ManifestStore) fetchReadmeContent(ctx context.Context, annotations map[string]string) string { 486 - if s.ctx.ReadmeFetcher == nil { 487 - return "" 488 - } 489 - 490 - // Create a context with timeout for README fetching (don't block push too long) 491 - fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 492 - defer cancel() 493 - 494 - // Priority 1: Direct README URL from io.atcr.readme annotation 495 - if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" { 496 - content, err := s.fetchRawReadme(fetchCtx, readmeURL) 497 - if err != nil { 498 - slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err) 499 - } else if content != "" { 500 - slog.Info("Fetched README from io.atcr.readme annotation", "url", readmeURL, "length", len(content)) 501 - return content 502 - } 503 - } 504 - 505 - // Priority 2: Derive README URL from org.opencontainers.image.source 506 - if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" { 507 - // Try main branch first, then master 508 - for _, branch := range []string{"main", "master"} { 509 - readmeURL := readme.DeriveReadmeURL(sourceURL, branch) 510 - if readmeURL == "" { 511 - continue 512 - } 513 - 514 - content, err := s.fetchRawReadme(fetchCtx, readmeURL) 515 - if err != nil { 516 - // Only log non-404 errors (404 is expected when trying main vs master) 517 - if !readme.Is404(err) { 518 - slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err) 519 - } 520 - continue 521 - } 522 - 523 - if content != "" { 524 - slog.Info("Fetched README from source URL", "sourceURL", sourceURL, "branch", branch, "length", len(content)) 525 - return content 526 - } 527 - } 528 - } 529 - 530 - return "" 531 - } 532 - 533 - // fetchRawReadme fetches raw markdown content from a URL 534 - // Returns the raw markdown (not rendered HTML) for storage in the repo page record 535 - func (s *ManifestStore) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) { 536 - // Use a simple HTTP client to fetch raw content 537 - // We want raw markdown, not rendered HTML (the Fetcher renders to HTML) 538 - req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil) 539 - if err != nil { 540 - return "", fmt.Errorf("failed to create request: %w", err) 541 - } 542 - 543 - req.Header.Set("User-Agent", "ATCR-README-Fetcher/1.0") 544 - 545 - client := &http.Client{ 546 - Timeout: 10 * time.Second, 547 - CheckRedirect: func(req *http.Request, via []*http.Request) error { 548 - if len(via) >= 5 { 549 - return fmt.Errorf("too many redirects") 550 - } 551 - return nil 552 - }, 553 - } 554 - 555 - resp, err := client.Do(req) 556 - if err != nil { 557 - return "", fmt.Errorf("failed to fetch URL: %w", err) 558 - } 559 - defer resp.Body.Close() 560 - 561 - if resp.StatusCode != http.StatusOK { 562 - return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) 563 - } 564 - 565 - // Limit content size to 100KB (repo page description has 100KB limit in lexicon) 566 - limitedReader := io.LimitReader(resp.Body, 100*1024) 567 - content, err := io.ReadAll(limitedReader) 568 - if err != nil { 569 - return "", fmt.Errorf("failed to read response body: %w", err) 570 - } 571 - 572 - return string(content), nil 573 - } 574 - 575 - // fetchAndUploadIcon fetches an image from a URL and uploads it as a blob to the user's PDS 576 - // Returns the blob reference for use in the repo page record, or nil on error 577 - func (s *ManifestStore) fetchAndUploadIcon(ctx context.Context, iconURL string) *atproto.ATProtoBlobRef { 578 - // Create a context with timeout for icon fetching 579 - fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 580 - defer cancel() 581 - 582 - // Fetch the icon 583 - req, err := http.NewRequestWithContext(fetchCtx, "GET", iconURL, nil) 584 - if err != nil { 585 - slog.Debug("Failed to create icon request", "url", iconURL, "error", err) 586 - return nil 587 - } 588 - 589 - req.Header.Set("User-Agent", "ATCR-Icon-Fetcher/1.0") 590 - 591 - client := &http.Client{ 592 - Timeout: 10 * time.Second, 593 - CheckRedirect: func(req *http.Request, via []*http.Request) error { 594 - if len(via) >= 5 { 595 - return fmt.Errorf("too many redirects") 596 - } 597 - return nil 598 - }, 599 - } 600 - 601 - resp, err := client.Do(req) 602 - if err != nil { 603 - slog.Debug("Failed to fetch icon", "url", iconURL, "error", err) 604 - return nil 605 - } 606 - defer resp.Body.Close() 607 - 608 - if resp.StatusCode != http.StatusOK { 609 - slog.Debug("Icon fetch returned non-OK status", "url", iconURL, "status", resp.StatusCode) 610 - return nil 611 - } 612 - 613 - // Validate content type - only allow images 614 - contentType := resp.Header.Get("Content-Type") 615 - mimeType := detectImageMimeType(contentType, iconURL) 616 - if mimeType == "" { 617 - slog.Debug("Icon has unsupported content type", "url", iconURL, "contentType", contentType) 618 - return nil 619 - } 620 - 621 - // Limit icon size to 3MB (matching lexicon maxSize) 622 - limitedReader := io.LimitReader(resp.Body, 3*1024*1024) 623 - iconData, err := io.ReadAll(limitedReader) 624 - if err != nil { 625 - slog.Debug("Failed to read icon data", "url", iconURL, "error", err) 626 - return nil 627 - } 628 - 629 - if len(iconData) == 0 { 630 - slog.Debug("Icon data is empty", "url", iconURL) 631 - return nil 632 - } 633 - 634 - // Upload the icon as a blob to the user's PDS 635 - blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, iconData, mimeType) 636 - if err != nil { 637 - slog.Warn("Failed to upload icon blob", "url", iconURL, "error", err) 638 - return nil 639 - } 640 - 641 - slog.Info("Uploaded icon blob", "url", iconURL, "size", len(iconData), "mimeType", mimeType, "cid", blobRef.Ref.Link) 642 - return blobRef 643 - } 644 - 645 - // detectImageMimeType determines the MIME type for an image 646 - // Uses Content-Type header first, then falls back to extension-based detection 647 - // Only allows types accepted by the lexicon: image/png, image/jpeg, image/webp 648 - func detectImageMimeType(contentType, url string) string { 649 - // Check Content-Type header first 650 - switch { 651 - case strings.HasPrefix(contentType, "image/png"): 652 - return "image/png" 653 - case strings.HasPrefix(contentType, "image/jpeg"): 654 - return "image/jpeg" 655 - case strings.HasPrefix(contentType, "image/webp"): 656 - return "image/webp" 657 - } 658 - 659 - // Fall back to URL extension detection 660 - lowerURL := strings.ToLower(url) 661 - switch { 662 - case strings.HasSuffix(lowerURL, ".png"): 663 - return "image/png" 664 - case strings.HasSuffix(lowerURL, ".jpg"), strings.HasSuffix(lowerURL, ".jpeg"): 665 - return "image/jpeg" 666 - case strings.HasSuffix(lowerURL, ".webp"): 667 - return "image/webp" 668 - } 669 - 670 - // Unknown or unsupported type - reject 671 - return "" 425 + // TODO: Re-enable once lexicon supports annotations as map[string]string 426 + // The generated Manifest_Annotations is an empty struct that doesn't support map access. 427 + // For now, README cache refresh on push is disabled. 428 + _ = manifestRecord // silence unused variable warning 672 429 }
+31 -25
pkg/appview/storage/manifest_store_test.go
··· 171 171 store := NewManifestStore(ctx, nil) 172 172 173 173 // Simulate what happens in Get() when parsing a manifest record 174 - var manifestRecord atproto.ManifestRecord 175 - manifestRecord.HoldDID = tt.manifestHoldDID 176 - manifestRecord.HoldEndpoint = tt.manifestHoldURL 174 + var manifestRecord atproto.Manifest 175 + if tt.manifestHoldDID != "" { 176 + manifestRecord.HoldDid = &tt.manifestHoldDID 177 + } 178 + if tt.manifestHoldURL != "" { 179 + manifestRecord.HoldEndpoint = &tt.manifestHoldURL 180 + } 177 181 178 182 // Mimic the hold DID extraction logic from Get() 179 - if manifestRecord.HoldDID != "" { 180 - store.lastFetchedHoldDID = manifestRecord.HoldDID 181 - } else if manifestRecord.HoldEndpoint != "" { 182 - store.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 183 + if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" { 184 + store.lastFetchedHoldDID = *manifestRecord.HoldDid 185 + } else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" { 186 + store.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint) 183 187 } 184 188 185 189 got := store.GetLastFetchedHoldDID() ··· 368 372 name: "manifest exists", 369 373 digest: "sha256:abc123", 370 374 serverStatus: http.StatusOK, 371 - serverResp: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{}}`, 375 + serverResp: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`, 372 376 wantExists: true, 373 377 wantErr: false, 374 378 }, ··· 433 437 digest: "sha256:abc123", 434 438 serverResp: `{ 435 439 "uri":"at://did:plc:test123/io.atcr.manifest/abc123", 436 - "cid":"bafytest", 440 + "cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", 437 441 "value":{ 438 442 "$type":"io.atcr.manifest", 439 443 "repository":"myapp", ··· 443 447 "mediaType":"application/vnd.oci.image.manifest.v1+json", 444 448 "manifestBlob":{ 445 449 "$type":"blob", 446 - "ref":{"$link":"bafytest"}, 450 + "ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}, 447 451 "mimeType":"application/vnd.oci.image.manifest.v1+json", 448 452 "size":100 449 453 } ··· 477 481 "holdEndpoint":"https://hold02.atcr.io", 478 482 "mediaType":"application/vnd.oci.image.manifest.v1+json", 479 483 "manifestBlob":{ 480 - "ref":{"$link":"bafylegacy"}, 484 + "$type":"blob", 485 + "ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}, 486 + "mimeType":"application/json", 481 487 "size":100 482 488 } 483 489 } ··· 559 565 "holdDid":"did:web:hold01.atcr.io", 560 566 "holdEndpoint":"https://hold01.atcr.io", 561 567 "mediaType":"application/vnd.oci.image.manifest.v1+json", 562 - "manifestBlob":{"ref":{"$link":"bafytest"},"size":100} 568 + "manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100} 563 569 } 564 570 }`, 565 571 expectedHoldDID: "did:web:hold01.atcr.io", ··· 572 578 "$type":"io.atcr.manifest", 573 579 "holdEndpoint":"https://hold02.atcr.io", 574 580 "mediaType":"application/vnd.oci.image.manifest.v1+json", 575 - "manifestBlob":{"ref":{"$link":"bafytest"},"size":100} 581 + "manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100} 576 582 } 577 583 }`, 578 584 expectedHoldDID: "did:web:hold02.atcr.io", ··· 646 652 "$type":"io.atcr.manifest", 647 653 "holdDid":"did:web:hold01.atcr.io", 648 654 "mediaType":"application/vnd.oci.image.manifest.v1+json", 649 - "manifestBlob":{"ref":{"$link":"bafytest"},"size":100} 655 + "manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100} 650 656 } 651 657 }`)) 652 658 })) ··· 754 760 // Handle uploadBlob 755 761 if r.URL.Path == atproto.RepoUploadBlob { 756 762 w.WriteHeader(http.StatusOK) 757 - w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"mimeType":"application/json","size":100}}`)) 763 + w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}}`)) 758 764 return 759 765 } 760 766 ··· 763 769 json.NewDecoder(r.Body).Decode(&lastBody) 764 770 w.WriteHeader(tt.serverStatus) 765 771 if tt.serverStatus == http.StatusOK { 766 - w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest"}`)) 772 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`)) 767 773 } else { 768 774 w.Write([]byte(`{"error":"ServerError"}`)) 769 775 } ··· 815 821 816 822 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 817 823 if r.URL.Path == atproto.RepoUploadBlob { 818 - w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"size":100}}`)) 824 + w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"size":100}}`)) 819 825 return 820 826 } 821 827 if r.URL.Path == atproto.RepoPutRecord { 822 - w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/config123","cid":"bafytest"}`)) 828 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/config123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`)) 823 829 return 824 830 } 825 831 w.WriteHeader(http.StatusOK) ··· 870 876 name: "successful delete", 871 877 digest: "sha256:abc123", 872 878 serverStatus: http.StatusOK, 873 - serverResp: `{"commit":{"cid":"bafytest","rev":"12345"}}`, 879 + serverResp: `{"commit":{"cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","rev":"12345"}}`, 874 880 wantErr: false, 875 881 }, 876 882 { ··· 1027 1033 // Handle uploadBlob 1028 1034 if r.URL.Path == atproto.RepoUploadBlob { 1029 1035 w.WriteHeader(http.StatusOK) 1030 - w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"mimeType":"application/json","size":100}}`)) 1036 + w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}}`)) 1031 1037 return 1032 1038 } 1033 1039 ··· 1039 1045 // If child should exist, return it; otherwise return RecordNotFound 1040 1046 if tt.childExists || rkey == childDigest.Encoded() { 1041 1047 w.WriteHeader(http.StatusOK) 1042 - w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafytest","value":{}}`)) 1048 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`)) 1043 1049 } else { 1044 1050 w.WriteHeader(http.StatusBadRequest) 1045 1051 w.Write([]byte(`{"error":"RecordNotFound","message":"Record not found"}`)) ··· 1050 1056 // Handle putRecord 1051 1057 if r.URL.Path == atproto.RepoPutRecord { 1052 1058 w.WriteHeader(http.StatusOK) 1053 - w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafytest"}`)) 1059 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`)) 1054 1060 return 1055 1061 } 1056 1062 ··· 1111 1117 1112 1118 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1113 1119 if r.URL.Path == atproto.RepoUploadBlob { 1114 - w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"size":100}}`)) 1120 + w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"size":100}}`)) 1115 1121 return 1116 1122 } 1117 1123 1118 1124 if r.URL.Path == atproto.RepoGetRecord { 1119 1125 rkey := r.URL.Query().Get("rkey") 1120 1126 if existingManifests[rkey] { 1121 - w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafytest","value":{}}`)) 1127 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`)) 1122 1128 } else { 1123 1129 w.WriteHeader(http.StatusBadRequest) 1124 1130 w.Write([]byte(`{"error":"RecordNotFound"}`)) ··· 1127 1133 } 1128 1134 1129 1135 if r.URL.Path == atproto.RepoPutRecord { 1130 - w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafytest"}`)) 1136 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`)) 1131 1137 return 1132 1138 } 1133 1139
+12 -10
pkg/appview/storage/profile.go
··· 54 54 // GetProfile retrieves the user's profile from their PDS 55 55 // Returns nil if profile doesn't exist 56 56 // Automatically migrates old URL-based defaultHold values to DIDs 57 - func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfileRecord, error) { 57 + func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfile, error) { 58 58 record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey) 59 59 if err != nil { 60 60 // Check if it's a 404 (profile doesn't exist) ··· 65 65 } 66 66 67 67 // Parse the profile record 68 - var profile atproto.SailorProfileRecord 68 + var profile atproto.SailorProfile 69 69 if err := json.Unmarshal(record.Value, &profile); err != nil { 70 70 return nil, fmt.Errorf("failed to parse profile: %w", err) 71 71 } 72 72 73 73 // Migrate old URL-based defaultHold to DID format 74 74 // This ensures backward compatibility with profiles created before DID migration 75 - if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { 75 + if profile.DefaultHold != nil && *profile.DefaultHold != "" && !atproto.IsDID(*profile.DefaultHold) { 76 76 // Convert URL to DID transparently 77 - migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 78 - profile.DefaultHold = migratedDID 77 + migratedDID := atproto.ResolveHoldDIDFromURL(*profile.DefaultHold) 78 + profile.DefaultHold = &migratedDID 79 79 80 80 // Persist the migration to PDS in a background goroutine 81 81 // Use a lock to ensure only one goroutine migrates this DID ··· 94 94 defer cancel() 95 95 96 96 // Update the profile on the PDS 97 - profile.UpdatedAt = time.Now() 97 + now := time.Now().Format(time.RFC3339) 98 + profile.UpdatedAt = &now 98 99 if err := UpdateProfile(ctx, client, &profile); err != nil { 99 100 slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err) 100 101 } else { ··· 109 110 110 111 // UpdateProfile updates the user's profile 111 112 // Normalizes defaultHold to DID format before saving 112 - func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfileRecord) error { 113 + func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfile) error { 113 114 // Normalize defaultHold to DID if it's a URL 114 115 // This ensures we always store DIDs, even if user provides a URL 115 - if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { 116 - profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 117 - slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold) 116 + if profile.DefaultHold != nil && *profile.DefaultHold != "" && !atproto.IsDID(*profile.DefaultHold) { 117 + normalized := atproto.ResolveHoldDIDFromURL(*profile.DefaultHold) 118 + profile.DefaultHold = &normalized 119 + slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", normalized) 118 120 } 119 121 120 122 _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile)
+46 -40
pkg/appview/storage/profile_test.go
··· 39 39 40 40 for _, tt := range tests { 41 41 t.Run(tt.name, func(t *testing.T) { 42 - var createdProfile *atproto.SailorProfileRecord 42 + var createdProfile *atproto.SailorProfile 43 43 44 44 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 45 // First request: GetRecord (should 404) ··· 95 95 t.Fatal("Profile was not created") 96 96 } 97 97 98 - if createdProfile.Type != atproto.SailorProfileCollection { 99 - t.Errorf("Type = %v, want %v", createdProfile.Type, atproto.SailorProfileCollection) 98 + if createdProfile.LexiconTypeID != atproto.SailorProfileCollection { 99 + t.Errorf("LexiconTypeID = %v, want %v", createdProfile.LexiconTypeID, atproto.SailorProfileCollection) 100 100 } 101 101 102 - if createdProfile.DefaultHold != tt.wantNormalized { 103 - t.Errorf("DefaultHold = %v, want %v", createdProfile.DefaultHold, tt.wantNormalized) 102 + gotDefaultHold := "" 103 + if createdProfile.DefaultHold != nil { 104 + gotDefaultHold = *createdProfile.DefaultHold 105 + } 106 + if gotDefaultHold != tt.wantNormalized { 107 + t.Errorf("DefaultHold = %v, want %v", gotDefaultHold, tt.wantNormalized) 104 108 } 105 109 }) 106 110 } ··· 154 158 name string 155 159 serverResponse string 156 160 serverStatus int 157 - wantProfile *atproto.SailorProfileRecord 161 + wantProfile *atproto.SailorProfile 158 162 wantNil bool 159 163 wantErr bool 160 164 expectMigration bool // Whether URL-to-DID migration should happen ··· 265 269 } 266 270 267 271 // Check that defaultHold is migrated to DID in returned profile 268 - if profile.DefaultHold != tt.expectedHoldDID { 269 - t.Errorf("DefaultHold = %v, want %v", profile.DefaultHold, tt.expectedHoldDID) 272 + gotDefaultHold := "" 273 + if profile.DefaultHold != nil { 274 + gotDefaultHold = *profile.DefaultHold 275 + } 276 + if gotDefaultHold != tt.expectedHoldDID { 277 + t.Errorf("DefaultHold = %v, want %v", gotDefaultHold, tt.expectedHoldDID) 270 278 } 271 279 272 280 if tt.expectMigration { ··· 366 374 } 367 375 } 368 376 377 + // testSailorProfile creates a test profile with the given default hold 378 + func testSailorProfile(defaultHold string) *atproto.SailorProfile { 379 + now := time.Now().Format(time.RFC3339) 380 + profile := &atproto.SailorProfile{ 381 + LexiconTypeID: atproto.SailorProfileCollection, 382 + CreatedAt: now, 383 + UpdatedAt: &now, 384 + } 385 + if defaultHold != "" { 386 + profile.DefaultHold = &defaultHold 387 + } 388 + return profile 389 + } 390 + 369 391 // TestUpdateProfile tests updating a user's profile 370 392 func TestUpdateProfile(t *testing.T) { 371 393 tests := []struct { 372 394 name string 373 - profile *atproto.SailorProfileRecord 395 + profile *atproto.SailorProfile 374 396 wantNormalized string // Expected defaultHold after normalization 375 397 wantErr bool 376 398 }{ 377 399 { 378 - name: "update with DID", 379 - profile: &atproto.SailorProfileRecord{ 380 - Type: atproto.SailorProfileCollection, 381 - DefaultHold: "did:web:hold02.atcr.io", 382 - CreatedAt: time.Now(), 383 - UpdatedAt: time.Now(), 384 - }, 400 + name: "update with DID", 401 + profile: testSailorProfile("did:web:hold02.atcr.io"), 385 402 wantNormalized: "did:web:hold02.atcr.io", 386 403 wantErr: false, 387 404 }, 388 405 { 389 - name: "update with URL - should normalize", 390 - profile: &atproto.SailorProfileRecord{ 391 - Type: atproto.SailorProfileCollection, 392 - DefaultHold: "https://hold02.atcr.io", 393 - CreatedAt: time.Now(), 394 - UpdatedAt: time.Now(), 395 - }, 406 + name: "update with URL - should normalize", 407 + profile: testSailorProfile("https://hold02.atcr.io"), 396 408 wantNormalized: "did:web:hold02.atcr.io", 397 409 wantErr: false, 398 410 }, 399 411 { 400 - name: "clear default hold", 401 - profile: &atproto.SailorProfileRecord{ 402 - Type: atproto.SailorProfileCollection, 403 - DefaultHold: "", 404 - CreatedAt: time.Now(), 405 - UpdatedAt: time.Now(), 406 - }, 412 + name: "clear default hold", 413 + profile: testSailorProfile(""), 407 414 wantNormalized: "", 408 415 wantErr: false, 409 416 }, ··· 454 461 } 455 462 456 463 // Verify normalization also updated the profile object 457 - if tt.profile.DefaultHold != tt.wantNormalized { 458 - t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", tt.profile.DefaultHold, tt.wantNormalized) 464 + gotProfileHold := "" 465 + if tt.profile.DefaultHold != nil { 466 + gotProfileHold = *tt.profile.DefaultHold 467 + } 468 + if gotProfileHold != tt.wantNormalized { 469 + t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", gotProfileHold, tt.wantNormalized) 459 470 } 460 471 } 461 472 }) ··· 539 550 t.Fatalf("GetProfile() error = %v", err) 540 551 } 541 552 542 - if profile.DefaultHold != "" { 543 - t.Errorf("DefaultHold = %v, want empty string", profile.DefaultHold) 553 + if profile.DefaultHold != nil && *profile.DefaultHold != "" { 554 + t.Errorf("DefaultHold = %v, want empty or nil", profile.DefaultHold) 544 555 } 545 556 } 546 557 ··· 553 564 defer server.Close() 554 565 555 566 client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 556 - profile := &atproto.SailorProfileRecord{ 557 - Type: atproto.SailorProfileCollection, 558 - DefaultHold: "did:web:hold01.atcr.io", 559 - CreatedAt: time.Now(), 560 - UpdatedAt: time.Now(), 561 - } 567 + profile := testSailorProfile("did:web:hold01.atcr.io") 562 568 563 569 err := UpdateProfile(context.Background(), client, profile) 564 570
+3 -3
pkg/appview/storage/tag_store.go
··· 36 36 return distribution.Descriptor{}, distribution.ErrTagUnknown{Tag: tag} 37 37 } 38 38 39 - var tagRecord atproto.TagRecord 39 + var tagRecord atproto.Tag 40 40 if err := json.Unmarshal(record.Value, &tagRecord); err != nil { 41 41 return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err) 42 42 } ··· 91 91 92 92 var tags []string 93 93 for _, record := range records { 94 - var tagRecord atproto.TagRecord 94 + var tagRecord atproto.Tag 95 95 if err := json.Unmarshal(record.Value, &tagRecord); err != nil { 96 96 // Skip invalid records 97 97 continue ··· 116 116 117 117 var tags []string 118 118 for _, record := range records { 119 - var tagRecord atproto.TagRecord 119 + var tagRecord atproto.Tag 120 120 if err := json.Unmarshal(record.Value, &tagRecord); err != nil { 121 121 // Skip invalid records 122 122 continue
+6 -6
pkg/appview/storage/tag_store_test.go
··· 229 229 230 230 for _, tt := range tests { 231 231 t.Run(tt.name, func(t *testing.T) { 232 - var sentTagRecord *atproto.TagRecord 232 + var sentTagRecord *atproto.Tag 233 233 234 234 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 235 235 if r.Method != "POST" { ··· 254 254 // Parse and verify tag record 255 255 recordData := body["record"].(map[string]any) 256 256 recordBytes, _ := json.Marshal(recordData) 257 - var tagRecord atproto.TagRecord 257 + var tagRecord atproto.Tag 258 258 json.Unmarshal(recordBytes, &tagRecord) 259 259 sentTagRecord = &tagRecord 260 260 ··· 284 284 285 285 if !tt.wantErr && sentTagRecord != nil { 286 286 // Verify the tag record 287 - if sentTagRecord.Type != atproto.TagCollection { 288 - t.Errorf("Type = %v, want %v", sentTagRecord.Type, atproto.TagCollection) 287 + if sentTagRecord.LexiconTypeID != atproto.TagCollection { 288 + t.Errorf("LexiconTypeID = %v, want %v", sentTagRecord.LexiconTypeID, atproto.TagCollection) 289 289 } 290 290 if sentTagRecord.Repository != "myapp" { 291 291 t.Errorf("Repository = %v, want myapp", sentTagRecord.Repository) ··· 295 295 } 296 296 // New records should have manifest field 297 297 expectedURI := atproto.BuildManifestURI("did:plc:test123", tt.digest.String()) 298 - if sentTagRecord.Manifest != expectedURI { 298 + if sentTagRecord.Manifest == nil || *sentTagRecord.Manifest != expectedURI { 299 299 t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI) 300 300 } 301 301 // New records should NOT have manifestDigest field 302 - if sentTagRecord.ManifestDigest != "" { 302 + if sentTagRecord.ManifestDigest != nil && *sentTagRecord.ManifestDigest != "" { 303 303 t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest) 304 304 } 305 305 }
-22
pkg/appview/templates/pages/404.html
··· 1 - {{ define "404" }} 2 - <!DOCTYPE html> 3 - <html lang="en"> 4 - <head> 5 - <title>404 - Lost at Sea | ATCR</title> 6 - {{ template "head" . }} 7 - </head> 8 - <body> 9 - {{ template "nav-simple" . }} 10 - <main class="error-page"> 11 - <div class="error-content"> 12 - <i data-lucide="anchor" class="error-icon"></i> 13 - <div class="error-code">404</div> 14 - <h1>Lost at Sea</h1> 15 - <p>The page you're looking for has drifted into uncharted waters.</p> 16 - <a href="/" class="btn btn-primary">Return to Port</a> 17 - </div> 18 - </main> 19 - <script>lucide.createIcons();</script> 20 - </body> 21 - </html> 22 - {{ end }}
+5 -17
pkg/appview/templates/pages/repository.html
··· 27 27 <!-- Repository Header --> 28 28 <div class="repository-header"> 29 29 <div class="repo-hero"> 30 - <div class="repo-hero-icon-wrapper"> 31 - {{ if .Repository.IconURL }} 32 - <img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon"> 33 - {{ else }} 34 - <div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div> 35 - {{ end }} 36 - {{ if $.IsOwner }} 37 - <label class="avatar-upload-overlay" for="avatar-upload"> 38 - <i data-lucide="plus"></i> 39 - </label> 40 - <input type="file" id="avatar-upload" accept="image/png,image/jpeg,image/webp" 41 - onchange="uploadAvatar(this, '{{ .Repository.Name }}')" hidden> 42 - {{ end }} 43 - </div> 30 + {{ if .Repository.IconURL }} 31 + <img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon"> 32 + {{ else }} 33 + <div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div> 34 + {{ end }} 44 35 <div class="repo-hero-info"> 45 36 <h1> 46 37 <a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a> ··· 138 129 <span class="tag-name-large">{{ .Tag.Tag }}</span> 139 130 {{ if .IsMultiArch }} 140 131 <span class="badge-multi">Multi-arch</span> 141 - {{ end }} 142 - {{ if .HasAttestations }} 143 - <span class="badge-attestation"><i data-lucide="shield-check"></i> Attestations</span> 144 132 {{ end }} 145 133 </div> 146 134 <div style="display: flex; gap: 1rem; align-items: center;">
+9
pkg/appview/templates/partials/push-list.html
··· 44 44 </div> 45 45 {{ end }} 46 46 47 + {{ if .HasMore }} 48 + <button class="load-more" 49 + hx-get="/api/recent-pushes?offset={{ .NextOffset }}" 50 + hx-target="#push-list" 51 + hx-swap="beforeend"> 52 + Load More 53 + </button> 54 + {{ end }} 55 + 47 56 {{ if eq (len .Pushes) 0 }} 48 57 <div class="empty-state"> 49 58 <p>No pushes yet. Start using ATCR by pushing your first image!</p>
+2958 -126
pkg/atproto/cbor_gen.go
··· 8 8 "math" 9 9 "sort" 10 10 11 + util "github.com/bluesky-social/indigo/lex/util" 11 12 cid "github.com/ipfs/go-cid" 12 13 cbg "github.com/whyrusleeping/cbor-gen" 13 14 xerrors "golang.org/x/xerrors" ··· 18 19 var _ = math.E 19 20 var _ = sort.Sort 20 21 21 - func (t *CrewRecord) MarshalCBOR(w io.Writer) error { 22 + func (t *Manifest) MarshalCBOR(w io.Writer) error { 22 23 if t == nil { 23 24 _, err := w.Write(cbg.CborNull) 24 25 return err 25 26 } 26 27 27 28 cw := cbg.NewCborWriter(w) 29 + fieldCount := 14 28 30 29 - if _, err := cw.Write([]byte{165}); err != nil { 30 - return err 31 + if t.Annotations == nil { 32 + fieldCount-- 33 + } 34 + 35 + if t.Config == nil { 36 + fieldCount-- 37 + } 38 + 39 + if t.HoldDid == nil { 40 + fieldCount-- 31 41 } 32 42 33 - // t.Role (string) (string) 34 - if len("role") > 8192 { 35 - return xerrors.Errorf("Value in field \"role\" was too long") 43 + if t.HoldEndpoint == nil { 44 + fieldCount-- 36 45 } 37 46 38 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("role"))); err != nil { 39 - return err 47 + if t.Layers == nil { 48 + fieldCount-- 40 49 } 41 - if _, err := cw.WriteString(string("role")); err != nil { 42 - return err 50 + 51 + if t.ManifestBlob == nil { 52 + fieldCount-- 43 53 } 44 54 45 - if len(t.Role) > 8192 { 46 - return xerrors.Errorf("Value in field t.Role was too long") 55 + if t.Manifests == nil { 56 + fieldCount-- 47 57 } 48 58 49 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Role))); err != nil { 50 - return err 59 + if t.Subject == nil { 60 + fieldCount-- 51 61 } 52 - if _, err := cw.WriteString(string(t.Role)); err != nil { 62 + 63 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 53 64 return err 54 65 } 55 66 56 - // t.Type (string) (string) 67 + // t.LexiconTypeID (string) (string) 57 68 if len("$type") > 8192 { 58 69 return xerrors.Errorf("Value in field \"$type\" was too long") 59 70 } ··· 65 76 return err 66 77 } 67 78 68 - if len(t.Type) > 8192 { 69 - return xerrors.Errorf("Value in field t.Type was too long") 79 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest"))); err != nil { 80 + return err 81 + } 82 + if _, err := cw.WriteString(string("io.atcr.manifest")); err != nil { 83 + return err 84 + } 85 + 86 + // t.Config (atproto.Manifest_BlobReference) (struct) 87 + if t.Config != nil { 88 + 89 + if len("config") > 8192 { 90 + return xerrors.Errorf("Value in field \"config\" was too long") 91 + } 92 + 93 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("config"))); err != nil { 94 + return err 95 + } 96 + if _, err := cw.WriteString(string("config")); err != nil { 97 + return err 98 + } 99 + 100 + if err := t.Config.MarshalCBOR(cw); err != nil { 101 + return err 102 + } 70 103 } 71 104 72 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 105 + // t.Digest (string) (string) 106 + if len("digest") > 8192 { 107 + return xerrors.Errorf("Value in field \"digest\" was too long") 108 + } 109 + 110 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil { 73 111 return err 74 112 } 75 - if _, err := cw.WriteString(string(t.Type)); err != nil { 113 + if _, err := cw.WriteString(string("digest")); err != nil { 76 114 return err 77 115 } 78 116 79 - // t.Member (string) (string) 80 - if len("member") > 8192 { 81 - return xerrors.Errorf("Value in field \"member\" was too long") 117 + if len(t.Digest) > 8192 { 118 + return xerrors.Errorf("Value in field t.Digest was too long") 82 119 } 83 120 84 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil { 121 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil { 85 122 return err 86 123 } 87 - if _, err := cw.WriteString(string("member")); err != nil { 124 + if _, err := cw.WriteString(string(t.Digest)); err != nil { 88 125 return err 89 126 } 90 127 91 - if len(t.Member) > 8192 { 92 - return xerrors.Errorf("Value in field t.Member was too long") 128 + // t.Layers ([]atproto.Manifest_BlobReference) (slice) 129 + if t.Layers != nil { 130 + 131 + if len("layers") > 8192 { 132 + return xerrors.Errorf("Value in field \"layers\" was too long") 133 + } 134 + 135 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("layers"))); err != nil { 136 + return err 137 + } 138 + if _, err := cw.WriteString(string("layers")); err != nil { 139 + return err 140 + } 141 + 142 + if len(t.Layers) > 8192 { 143 + return xerrors.Errorf("Slice value in field t.Layers was too long") 144 + } 145 + 146 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Layers))); err != nil { 147 + return err 148 + } 149 + for _, v := range t.Layers { 150 + if err := v.MarshalCBOR(cw); err != nil { 151 + return err 152 + } 153 + 154 + } 93 155 } 94 156 95 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil { 157 + // t.HoldDid (string) (string) 158 + if t.HoldDid != nil { 159 + 160 + if len("holdDid") > 8192 { 161 + return xerrors.Errorf("Value in field \"holdDid\" was too long") 162 + } 163 + 164 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("holdDid"))); err != nil { 165 + return err 166 + } 167 + if _, err := cw.WriteString(string("holdDid")); err != nil { 168 + return err 169 + } 170 + 171 + if t.HoldDid == nil { 172 + if _, err := cw.Write(cbg.CborNull); err != nil { 173 + return err 174 + } 175 + } else { 176 + if len(*t.HoldDid) > 8192 { 177 + return xerrors.Errorf("Value in field t.HoldDid was too long") 178 + } 179 + 180 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.HoldDid))); err != nil { 181 + return err 182 + } 183 + if _, err := cw.WriteString(string(*t.HoldDid)); err != nil { 184 + return err 185 + } 186 + } 187 + } 188 + 189 + // t.Subject (atproto.Manifest_BlobReference) (struct) 190 + if t.Subject != nil { 191 + 192 + if len("subject") > 8192 { 193 + return xerrors.Errorf("Value in field \"subject\" was too long") 194 + } 195 + 196 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 197 + return err 198 + } 199 + if _, err := cw.WriteString(string("subject")); err != nil { 200 + return err 201 + } 202 + 203 + if err := t.Subject.MarshalCBOR(cw); err != nil { 204 + return err 205 + } 206 + } 207 + 208 + // t.CreatedAt (string) (string) 209 + if len("createdAt") > 8192 { 210 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 211 + } 212 + 213 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 96 214 return err 97 215 } 98 - if _, err := cw.WriteString(string(t.Member)); err != nil { 216 + if _, err := cw.WriteString(string("createdAt")); err != nil { 99 217 return err 100 218 } 101 219 102 - // t.AddedAt (string) (string) 103 - if len("addedAt") > 8192 { 104 - return xerrors.Errorf("Value in field \"addedAt\" was too long") 220 + if len(t.CreatedAt) > 8192 { 221 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 105 222 } 106 223 107 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil { 224 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 108 225 return err 109 226 } 110 - if _, err := cw.WriteString(string("addedAt")); err != nil { 227 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 111 228 return err 112 229 } 113 230 114 - if len(t.AddedAt) > 8192 { 115 - return xerrors.Errorf("Value in field t.AddedAt was too long") 231 + // t.Manifests ([]atproto.Manifest_ManifestReference) (slice) 232 + if t.Manifests != nil { 233 + 234 + if len("manifests") > 8192 { 235 + return xerrors.Errorf("Value in field \"manifests\" was too long") 236 + } 237 + 238 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifests"))); err != nil { 239 + return err 240 + } 241 + if _, err := cw.WriteString(string("manifests")); err != nil { 242 + return err 243 + } 244 + 245 + if len(t.Manifests) > 8192 { 246 + return xerrors.Errorf("Slice value in field t.Manifests was too long") 247 + } 248 + 249 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Manifests))); err != nil { 250 + return err 251 + } 252 + for _, v := range t.Manifests { 253 + if err := v.MarshalCBOR(cw); err != nil { 254 + return err 255 + } 256 + 257 + } 116 258 } 117 259 118 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.AddedAt))); err != nil { 260 + // t.MediaType (string) (string) 261 + if len("mediaType") > 8192 { 262 + return xerrors.Errorf("Value in field \"mediaType\" was too long") 263 + } 264 + 265 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil { 119 266 return err 120 267 } 121 - if _, err := cw.WriteString(string(t.AddedAt)); err != nil { 268 + if _, err := cw.WriteString(string("mediaType")); err != nil { 122 269 return err 123 270 } 124 271 125 - // t.Permissions ([]string) (slice) 126 - if len("permissions") > 8192 { 127 - return xerrors.Errorf("Value in field \"permissions\" was too long") 272 + if len(t.MediaType) > 8192 { 273 + return xerrors.Errorf("Value in field t.MediaType was too long") 128 274 } 129 275 130 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("permissions"))); err != nil { 276 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil { 277 + return err 278 + } 279 + if _, err := cw.WriteString(string(t.MediaType)); err != nil { 280 + return err 281 + } 282 + 283 + // t.Repository (string) (string) 284 + if len("repository") > 8192 { 285 + return xerrors.Errorf("Value in field \"repository\" was too long") 286 + } 287 + 288 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil { 131 289 return err 132 290 } 133 - if _, err := cw.WriteString(string("permissions")); err != nil { 291 + if _, err := cw.WriteString(string("repository")); err != nil { 134 292 return err 135 293 } 136 294 137 - if len(t.Permissions) > 8192 { 138 - return xerrors.Errorf("Slice value in field t.Permissions was too long") 295 + if len(t.Repository) > 8192 { 296 + return xerrors.Errorf("Value in field t.Repository was too long") 139 297 } 140 298 141 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Permissions))); err != nil { 299 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil { 142 300 return err 143 301 } 144 - for _, v := range t.Permissions { 145 - if len(v) > 8192 { 146 - return xerrors.Errorf("Value in field v was too long") 302 + if _, err := cw.WriteString(string(t.Repository)); err != nil { 303 + return err 304 + } 305 + 306 + // t.Annotations (atproto.Manifest_Annotations) (struct) 307 + if t.Annotations != nil { 308 + 309 + if len("annotations") > 8192 { 310 + return xerrors.Errorf("Value in field \"annotations\" was too long") 311 + } 312 + 313 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil { 314 + return err 315 + } 316 + if _, err := cw.WriteString(string("annotations")); err != nil { 317 + return err 318 + } 319 + 320 + if err := t.Annotations.MarshalCBOR(cw); err != nil { 321 + return err 322 + } 323 + } 324 + 325 + // t.HoldEndpoint (string) (string) 326 + if t.HoldEndpoint != nil { 327 + 328 + if len("holdEndpoint") > 8192 { 329 + return xerrors.Errorf("Value in field \"holdEndpoint\" was too long") 330 + } 331 + 332 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("holdEndpoint"))); err != nil { 333 + return err 334 + } 335 + if _, err := cw.WriteString(string("holdEndpoint")); err != nil { 336 + return err 337 + } 338 + 339 + if t.HoldEndpoint == nil { 340 + if _, err := cw.Write(cbg.CborNull); err != nil { 341 + return err 342 + } 343 + } else { 344 + if len(*t.HoldEndpoint) > 8192 { 345 + return xerrors.Errorf("Value in field t.HoldEndpoint was too long") 346 + } 347 + 348 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.HoldEndpoint))); err != nil { 349 + return err 350 + } 351 + if _, err := cw.WriteString(string(*t.HoldEndpoint)); err != nil { 352 + return err 353 + } 147 354 } 355 + } 148 356 149 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 357 + // t.ManifestBlob (util.LexBlob) (struct) 358 + if t.ManifestBlob != nil { 359 + 360 + if len("manifestBlob") > 8192 { 361 + return xerrors.Errorf("Value in field \"manifestBlob\" was too long") 362 + } 363 + 364 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifestBlob"))); err != nil { 150 365 return err 151 366 } 152 - if _, err := cw.WriteString(string(v)); err != nil { 367 + if _, err := cw.WriteString(string("manifestBlob")); err != nil { 153 368 return err 154 369 } 155 370 371 + if err := t.ManifestBlob.MarshalCBOR(cw); err != nil { 372 + return err 373 + } 156 374 } 375 + 376 + // t.SchemaVersion (int64) (int64) 377 + if len("schemaVersion") > 8192 { 378 + return xerrors.Errorf("Value in field \"schemaVersion\" was too long") 379 + } 380 + 381 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("schemaVersion"))); err != nil { 382 + return err 383 + } 384 + if _, err := cw.WriteString(string("schemaVersion")); err != nil { 385 + return err 386 + } 387 + 388 + if t.SchemaVersion >= 0 { 389 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.SchemaVersion)); err != nil { 390 + return err 391 + } 392 + } else { 393 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.SchemaVersion-1)); err != nil { 394 + return err 395 + } 396 + } 397 + 157 398 return nil 158 399 } 159 400 160 - func (t *CrewRecord) UnmarshalCBOR(r io.Reader) (err error) { 161 - *t = CrewRecord{} 401 + func (t *Manifest) UnmarshalCBOR(r io.Reader) (err error) { 402 + *t = Manifest{} 162 403 163 404 cr := cbg.NewCborReader(r) 164 405 ··· 177 418 } 178 419 179 420 if extra > cbg.MaxLength { 180 - return fmt.Errorf("CrewRecord: map struct too large (%d)", extra) 421 + return fmt.Errorf("Manifest: map struct too large (%d)", extra) 422 + } 423 + 424 + n := extra 425 + 426 + nameBuf := make([]byte, 13) 427 + for i := uint64(0); i < n; i++ { 428 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 429 + if err != nil { 430 + return err 431 + } 432 + 433 + if !ok { 434 + // Field doesn't exist on this type, so ignore it 435 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 436 + return err 437 + } 438 + continue 439 + } 440 + 441 + switch string(nameBuf[:nameLen]) { 442 + // t.LexiconTypeID (string) (string) 443 + case "$type": 444 + 445 + { 446 + sval, err := cbg.ReadStringWithMax(cr, 8192) 447 + if err != nil { 448 + return err 449 + } 450 + 451 + t.LexiconTypeID = string(sval) 452 + } 453 + // t.Config (atproto.Manifest_BlobReference) (struct) 454 + case "config": 455 + 456 + { 457 + 458 + b, err := cr.ReadByte() 459 + if err != nil { 460 + return err 461 + } 462 + if b != cbg.CborNull[0] { 463 + if err := cr.UnreadByte(); err != nil { 464 + return err 465 + } 466 + t.Config = new(Manifest_BlobReference) 467 + if err := t.Config.UnmarshalCBOR(cr); err != nil { 468 + return xerrors.Errorf("unmarshaling t.Config pointer: %w", err) 469 + } 470 + } 471 + 472 + } 473 + // t.Digest (string) (string) 474 + case "digest": 475 + 476 + { 477 + sval, err := cbg.ReadStringWithMax(cr, 8192) 478 + if err != nil { 479 + return err 480 + } 481 + 482 + t.Digest = string(sval) 483 + } 484 + // t.Layers ([]atproto.Manifest_BlobReference) (slice) 485 + case "layers": 486 + 487 + maj, extra, err = cr.ReadHeader() 488 + if err != nil { 489 + return err 490 + } 491 + 492 + if extra > 8192 { 493 + return fmt.Errorf("t.Layers: array too large (%d)", extra) 494 + } 495 + 496 + if maj != cbg.MajArray { 497 + return fmt.Errorf("expected cbor array") 498 + } 499 + 500 + if extra > 0 { 501 + t.Layers = make([]Manifest_BlobReference, extra) 502 + } 503 + 504 + for i := 0; i < int(extra); i++ { 505 + { 506 + var maj byte 507 + var extra uint64 508 + var err error 509 + _ = maj 510 + _ = extra 511 + _ = err 512 + 513 + { 514 + 515 + if err := t.Layers[i].UnmarshalCBOR(cr); err != nil { 516 + return xerrors.Errorf("unmarshaling t.Layers[i]: %w", err) 517 + } 518 + 519 + } 520 + 521 + } 522 + } 523 + // t.HoldDid (string) (string) 524 + case "holdDid": 525 + 526 + { 527 + b, err := cr.ReadByte() 528 + if err != nil { 529 + return err 530 + } 531 + if b != cbg.CborNull[0] { 532 + if err := cr.UnreadByte(); err != nil { 533 + return err 534 + } 535 + 536 + sval, err := cbg.ReadStringWithMax(cr, 8192) 537 + if err != nil { 538 + return err 539 + } 540 + 541 + t.HoldDid = (*string)(&sval) 542 + } 543 + } 544 + // t.Subject (atproto.Manifest_BlobReference) (struct) 545 + case "subject": 546 + 547 + { 548 + 549 + b, err := cr.ReadByte() 550 + if err != nil { 551 + return err 552 + } 553 + if b != cbg.CborNull[0] { 554 + if err := cr.UnreadByte(); err != nil { 555 + return err 556 + } 557 + t.Subject = new(Manifest_BlobReference) 558 + if err := t.Subject.UnmarshalCBOR(cr); err != nil { 559 + return xerrors.Errorf("unmarshaling t.Subject pointer: %w", err) 560 + } 561 + } 562 + 563 + } 564 + // t.CreatedAt (string) (string) 565 + case "createdAt": 566 + 567 + { 568 + sval, err := cbg.ReadStringWithMax(cr, 8192) 569 + if err != nil { 570 + return err 571 + } 572 + 573 + t.CreatedAt = string(sval) 574 + } 575 + // t.Manifests ([]atproto.Manifest_ManifestReference) (slice) 576 + case "manifests": 577 + 578 + maj, extra, err = cr.ReadHeader() 579 + if err != nil { 580 + return err 581 + } 582 + 583 + if extra > 8192 { 584 + return fmt.Errorf("t.Manifests: array too large (%d)", extra) 585 + } 586 + 587 + if maj != cbg.MajArray { 588 + return fmt.Errorf("expected cbor array") 589 + } 590 + 591 + if extra > 0 { 592 + t.Manifests = make([]Manifest_ManifestReference, extra) 593 + } 594 + 595 + for i := 0; i < int(extra); i++ { 596 + { 597 + var maj byte 598 + var extra uint64 599 + var err error 600 + _ = maj 601 + _ = extra 602 + _ = err 603 + 604 + { 605 + 606 + if err := t.Manifests[i].UnmarshalCBOR(cr); err != nil { 607 + return xerrors.Errorf("unmarshaling t.Manifests[i]: %w", err) 608 + } 609 + 610 + } 611 + 612 + } 613 + } 614 + // t.MediaType (string) (string) 615 + case "mediaType": 616 + 617 + { 618 + sval, err := cbg.ReadStringWithMax(cr, 8192) 619 + if err != nil { 620 + return err 621 + } 622 + 623 + t.MediaType = string(sval) 624 + } 625 + // t.Repository (string) (string) 626 + case "repository": 627 + 628 + { 629 + sval, err := cbg.ReadStringWithMax(cr, 8192) 630 + if err != nil { 631 + return err 632 + } 633 + 634 + t.Repository = string(sval) 635 + } 636 + // t.Annotations (atproto.Manifest_Annotations) (struct) 637 + case "annotations": 638 + 639 + { 640 + 641 + b, err := cr.ReadByte() 642 + if err != nil { 643 + return err 644 + } 645 + if b != cbg.CborNull[0] { 646 + if err := cr.UnreadByte(); err != nil { 647 + return err 648 + } 649 + t.Annotations = new(Manifest_Annotations) 650 + if err := t.Annotations.UnmarshalCBOR(cr); err != nil { 651 + return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err) 652 + } 653 + } 654 + 655 + } 656 + // t.HoldEndpoint (string) (string) 657 + case "holdEndpoint": 658 + 659 + { 660 + b, err := cr.ReadByte() 661 + if err != nil { 662 + return err 663 + } 664 + if b != cbg.CborNull[0] { 665 + if err := cr.UnreadByte(); err != nil { 666 + return err 667 + } 668 + 669 + sval, err := cbg.ReadStringWithMax(cr, 8192) 670 + if err != nil { 671 + return err 672 + } 673 + 674 + t.HoldEndpoint = (*string)(&sval) 675 + } 676 + } 677 + // t.ManifestBlob (util.LexBlob) (struct) 678 + case "manifestBlob": 679 + 680 + { 681 + 682 + b, err := cr.ReadByte() 683 + if err != nil { 684 + return err 685 + } 686 + if b != cbg.CborNull[0] { 687 + if err := cr.UnreadByte(); err != nil { 688 + return err 689 + } 690 + t.ManifestBlob = new(util.LexBlob) 691 + if err := t.ManifestBlob.UnmarshalCBOR(cr); err != nil { 692 + return xerrors.Errorf("unmarshaling t.ManifestBlob pointer: %w", err) 693 + } 694 + } 695 + 696 + } 697 + // t.SchemaVersion (int64) (int64) 698 + case "schemaVersion": 699 + { 700 + maj, extra, err := cr.ReadHeader() 701 + if err != nil { 702 + return err 703 + } 704 + var extraI int64 705 + switch maj { 706 + case cbg.MajUnsignedInt: 707 + extraI = int64(extra) 708 + if extraI < 0 { 709 + return fmt.Errorf("int64 positive overflow") 710 + } 711 + case cbg.MajNegativeInt: 712 + extraI = int64(extra) 713 + if extraI < 0 { 714 + return fmt.Errorf("int64 negative overflow") 715 + } 716 + extraI = -1 - extraI 717 + default: 718 + return fmt.Errorf("wrong type for int64 field: %d", maj) 719 + } 720 + 721 + t.SchemaVersion = int64(extraI) 722 + } 723 + 724 + default: 725 + // Field doesn't exist on this type, so ignore it 726 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 727 + return err 728 + } 729 + } 730 + } 731 + 732 + return nil 733 + } 734 + func (t *Manifest_BlobReference) MarshalCBOR(w io.Writer) error { 735 + if t == nil { 736 + _, err := w.Write(cbg.CborNull) 737 + return err 738 + } 739 + 740 + cw := cbg.NewCborWriter(w) 741 + fieldCount := 6 742 + 743 + if t.LexiconTypeID == "" { 744 + fieldCount-- 745 + } 746 + 747 + if t.Annotations == nil { 748 + fieldCount-- 749 + } 750 + 751 + if t.Urls == nil { 752 + fieldCount-- 753 + } 754 + 755 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 756 + return err 757 + } 758 + 759 + // t.Size (int64) (int64) 760 + if len("size") > 8192 { 761 + return xerrors.Errorf("Value in field \"size\" was too long") 762 + } 763 + 764 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 765 + return err 766 + } 767 + if _, err := cw.WriteString(string("size")); err != nil { 768 + return err 769 + } 770 + 771 + if t.Size >= 0 { 772 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 773 + return err 774 + } 775 + } else { 776 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 777 + return err 778 + } 779 + } 780 + 781 + // t.Urls ([]string) (slice) 782 + if t.Urls != nil { 783 + 784 + if len("urls") > 8192 { 785 + return xerrors.Errorf("Value in field \"urls\" was too long") 786 + } 787 + 788 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("urls"))); err != nil { 789 + return err 790 + } 791 + if _, err := cw.WriteString(string("urls")); err != nil { 792 + return err 793 + } 794 + 795 + if len(t.Urls) > 8192 { 796 + return xerrors.Errorf("Slice value in field t.Urls was too long") 797 + } 798 + 799 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Urls))); err != nil { 800 + return err 801 + } 802 + for _, v := range t.Urls { 803 + if len(v) > 8192 { 804 + return xerrors.Errorf("Value in field v was too long") 805 + } 806 + 807 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 808 + return err 809 + } 810 + if _, err := cw.WriteString(string(v)); err != nil { 811 + return err 812 + } 813 + 814 + } 815 + } 816 + 817 + // t.LexiconTypeID (string) (string) 818 + if t.LexiconTypeID != "" { 819 + 820 + if len("$type") > 8192 { 821 + return xerrors.Errorf("Value in field \"$type\" was too long") 822 + } 823 + 824 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 825 + return err 826 + } 827 + if _, err := cw.WriteString(string("$type")); err != nil { 828 + return err 829 + } 830 + 831 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#blobReference"))); err != nil { 832 + return err 833 + } 834 + if _, err := cw.WriteString(string("io.atcr.manifest#blobReference")); err != nil { 835 + return err 836 + } 837 + } 838 + 839 + // t.Digest (string) (string) 840 + if len("digest") > 8192 { 841 + return xerrors.Errorf("Value in field \"digest\" was too long") 842 + } 843 + 844 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil { 845 + return err 846 + } 847 + if _, err := cw.WriteString(string("digest")); err != nil { 848 + return err 849 + } 850 + 851 + if len(t.Digest) > 8192 { 852 + return xerrors.Errorf("Value in field t.Digest was too long") 853 + } 854 + 855 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil { 856 + return err 857 + } 858 + if _, err := cw.WriteString(string(t.Digest)); err != nil { 859 + return err 860 + } 861 + 862 + // t.MediaType (string) (string) 863 + if len("mediaType") > 8192 { 864 + return xerrors.Errorf("Value in field \"mediaType\" was too long") 865 + } 866 + 867 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil { 868 + return err 869 + } 870 + if _, err := cw.WriteString(string("mediaType")); err != nil { 871 + return err 872 + } 873 + 874 + if len(t.MediaType) > 8192 { 875 + return xerrors.Errorf("Value in field t.MediaType was too long") 876 + } 877 + 878 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil { 879 + return err 880 + } 881 + if _, err := cw.WriteString(string(t.MediaType)); err != nil { 882 + return err 883 + } 884 + 885 + // t.Annotations (atproto.Manifest_BlobReference_Annotations) (struct) 886 + if t.Annotations != nil { 887 + 888 + if len("annotations") > 8192 { 889 + return xerrors.Errorf("Value in field \"annotations\" was too long") 890 + } 891 + 892 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil { 893 + return err 894 + } 895 + if _, err := cw.WriteString(string("annotations")); err != nil { 896 + return err 897 + } 898 + 899 + if err := t.Annotations.MarshalCBOR(cw); err != nil { 900 + return err 901 + } 902 + } 903 + return nil 904 + } 905 + 906 + func (t *Manifest_BlobReference) UnmarshalCBOR(r io.Reader) (err error) { 907 + *t = Manifest_BlobReference{} 908 + 909 + cr := cbg.NewCborReader(r) 910 + 911 + maj, extra, err := cr.ReadHeader() 912 + if err != nil { 913 + return err 914 + } 915 + defer func() { 916 + if err == io.EOF { 917 + err = io.ErrUnexpectedEOF 918 + } 919 + }() 920 + 921 + if maj != cbg.MajMap { 922 + return fmt.Errorf("cbor input should be of type map") 923 + } 924 + 925 + if extra > cbg.MaxLength { 926 + return fmt.Errorf("Manifest_BlobReference: map struct too large (%d)", extra) 181 927 } 182 928 183 929 n := extra ··· 198 944 } 199 945 200 946 switch string(nameBuf[:nameLen]) { 201 - // t.Role (string) (string) 202 - case "role": 947 + // t.Size (int64) (int64) 948 + case "size": 949 + { 950 + maj, extra, err := cr.ReadHeader() 951 + if err != nil { 952 + return err 953 + } 954 + var extraI int64 955 + switch maj { 956 + case cbg.MajUnsignedInt: 957 + extraI = int64(extra) 958 + if extraI < 0 { 959 + return fmt.Errorf("int64 positive overflow") 960 + } 961 + case cbg.MajNegativeInt: 962 + extraI = int64(extra) 963 + if extraI < 0 { 964 + return fmt.Errorf("int64 negative overflow") 965 + } 966 + extraI = -1 - extraI 967 + default: 968 + return fmt.Errorf("wrong type for int64 field: %d", maj) 969 + } 970 + 971 + t.Size = int64(extraI) 972 + } 973 + // t.Urls ([]string) (slice) 974 + case "urls": 975 + 976 + maj, extra, err = cr.ReadHeader() 977 + if err != nil { 978 + return err 979 + } 980 + 981 + if extra > 8192 { 982 + return fmt.Errorf("t.Urls: array too large (%d)", extra) 983 + } 984 + 985 + if maj != cbg.MajArray { 986 + return fmt.Errorf("expected cbor array") 987 + } 988 + 989 + if extra > 0 { 990 + t.Urls = make([]string, extra) 991 + } 992 + 993 + for i := 0; i < int(extra); i++ { 994 + { 995 + var maj byte 996 + var extra uint64 997 + var err error 998 + _ = maj 999 + _ = extra 1000 + _ = err 1001 + 1002 + { 1003 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1004 + if err != nil { 1005 + return err 1006 + } 1007 + 1008 + t.Urls[i] = string(sval) 1009 + } 1010 + 1011 + } 1012 + } 1013 + // t.LexiconTypeID (string) (string) 1014 + case "$type": 203 1015 204 1016 { 205 1017 sval, err := cbg.ReadStringWithMax(cr, 8192) ··· 207 1019 return err 208 1020 } 209 1021 210 - t.Role = string(sval) 1022 + t.LexiconTypeID = string(sval) 1023 + } 1024 + // t.Digest (string) (string) 1025 + case "digest": 1026 + 1027 + { 1028 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1029 + if err != nil { 1030 + return err 1031 + } 1032 + 1033 + t.Digest = string(sval) 1034 + } 1035 + // t.MediaType (string) (string) 1036 + case "mediaType": 1037 + 1038 + { 1039 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1040 + if err != nil { 1041 + return err 1042 + } 1043 + 1044 + t.MediaType = string(sval) 1045 + } 1046 + // t.Annotations (atproto.Manifest_BlobReference_Annotations) (struct) 1047 + case "annotations": 1048 + 1049 + { 1050 + 1051 + b, err := cr.ReadByte() 1052 + if err != nil { 1053 + return err 1054 + } 1055 + if b != cbg.CborNull[0] { 1056 + if err := cr.UnreadByte(); err != nil { 1057 + return err 1058 + } 1059 + t.Annotations = new(Manifest_BlobReference_Annotations) 1060 + if err := t.Annotations.UnmarshalCBOR(cr); err != nil { 1061 + return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err) 1062 + } 1063 + } 1064 + 1065 + } 1066 + 1067 + default: 1068 + // Field doesn't exist on this type, so ignore it 1069 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1070 + return err 1071 + } 1072 + } 1073 + } 1074 + 1075 + return nil 1076 + } 1077 + func (t *Manifest_ManifestReference) MarshalCBOR(w io.Writer) error { 1078 + if t == nil { 1079 + _, err := w.Write(cbg.CborNull) 1080 + return err 1081 + } 1082 + 1083 + cw := cbg.NewCborWriter(w) 1084 + fieldCount := 6 1085 + 1086 + if t.LexiconTypeID == "" { 1087 + fieldCount-- 1088 + } 1089 + 1090 + if t.Annotations == nil { 1091 + fieldCount-- 1092 + } 1093 + 1094 + if t.Platform == nil { 1095 + fieldCount-- 1096 + } 1097 + 1098 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1099 + return err 1100 + } 1101 + 1102 + // t.Size (int64) (int64) 1103 + if len("size") > 8192 { 1104 + return xerrors.Errorf("Value in field \"size\" was too long") 1105 + } 1106 + 1107 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 1108 + return err 1109 + } 1110 + if _, err := cw.WriteString(string("size")); err != nil { 1111 + return err 1112 + } 1113 + 1114 + if t.Size >= 0 { 1115 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1116 + return err 1117 + } 1118 + } else { 1119 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1120 + return err 1121 + } 1122 + } 1123 + 1124 + // t.LexiconTypeID (string) (string) 1125 + if t.LexiconTypeID != "" { 1126 + 1127 + if len("$type") > 8192 { 1128 + return xerrors.Errorf("Value in field \"$type\" was too long") 1129 + } 1130 + 1131 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 1132 + return err 1133 + } 1134 + if _, err := cw.WriteString(string("$type")); err != nil { 1135 + return err 1136 + } 1137 + 1138 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#manifestReference"))); err != nil { 1139 + return err 1140 + } 1141 + if _, err := cw.WriteString(string("io.atcr.manifest#manifestReference")); err != nil { 1142 + return err 1143 + } 1144 + } 1145 + 1146 + // t.Digest (string) (string) 1147 + if len("digest") > 8192 { 1148 + return xerrors.Errorf("Value in field \"digest\" was too long") 1149 + } 1150 + 1151 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil { 1152 + return err 1153 + } 1154 + if _, err := cw.WriteString(string("digest")); err != nil { 1155 + return err 1156 + } 1157 + 1158 + if len(t.Digest) > 8192 { 1159 + return xerrors.Errorf("Value in field t.Digest was too long") 1160 + } 1161 + 1162 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil { 1163 + return err 1164 + } 1165 + if _, err := cw.WriteString(string(t.Digest)); err != nil { 1166 + return err 1167 + } 1168 + 1169 + // t.Platform (atproto.Manifest_Platform) (struct) 1170 + if t.Platform != nil { 1171 + 1172 + if len("platform") > 8192 { 1173 + return xerrors.Errorf("Value in field \"platform\" was too long") 1174 + } 1175 + 1176 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("platform"))); err != nil { 1177 + return err 1178 + } 1179 + if _, err := cw.WriteString(string("platform")); err != nil { 1180 + return err 1181 + } 1182 + 1183 + if err := t.Platform.MarshalCBOR(cw); err != nil { 1184 + return err 1185 + } 1186 + } 1187 + 1188 + // t.MediaType (string) (string) 1189 + if len("mediaType") > 8192 { 1190 + return xerrors.Errorf("Value in field \"mediaType\" was too long") 1191 + } 1192 + 1193 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil { 1194 + return err 1195 + } 1196 + if _, err := cw.WriteString(string("mediaType")); err != nil { 1197 + return err 1198 + } 1199 + 1200 + if len(t.MediaType) > 8192 { 1201 + return xerrors.Errorf("Value in field t.MediaType was too long") 1202 + } 1203 + 1204 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil { 1205 + return err 1206 + } 1207 + if _, err := cw.WriteString(string(t.MediaType)); err != nil { 1208 + return err 1209 + } 1210 + 1211 + // t.Annotations (atproto.Manifest_ManifestReference_Annotations) (struct) 1212 + if t.Annotations != nil { 1213 + 1214 + if len("annotations") > 8192 { 1215 + return xerrors.Errorf("Value in field \"annotations\" was too long") 1216 + } 1217 + 1218 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil { 1219 + return err 1220 + } 1221 + if _, err := cw.WriteString(string("annotations")); err != nil { 1222 + return err 1223 + } 1224 + 1225 + if err := t.Annotations.MarshalCBOR(cw); err != nil { 1226 + return err 1227 + } 1228 + } 1229 + return nil 1230 + } 1231 + 1232 + func (t *Manifest_ManifestReference) UnmarshalCBOR(r io.Reader) (err error) { 1233 + *t = Manifest_ManifestReference{} 1234 + 1235 + cr := cbg.NewCborReader(r) 1236 + 1237 + maj, extra, err := cr.ReadHeader() 1238 + if err != nil { 1239 + return err 1240 + } 1241 + defer func() { 1242 + if err == io.EOF { 1243 + err = io.ErrUnexpectedEOF 1244 + } 1245 + }() 1246 + 1247 + if maj != cbg.MajMap { 1248 + return fmt.Errorf("cbor input should be of type map") 1249 + } 1250 + 1251 + if extra > cbg.MaxLength { 1252 + return fmt.Errorf("Manifest_ManifestReference: map struct too large (%d)", extra) 1253 + } 1254 + 1255 + n := extra 1256 + 1257 + nameBuf := make([]byte, 11) 1258 + for i := uint64(0); i < n; i++ { 1259 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 1260 + if err != nil { 1261 + return err 1262 + } 1263 + 1264 + if !ok { 1265 + // Field doesn't exist on this type, so ignore it 1266 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1267 + return err 1268 + } 1269 + continue 1270 + } 1271 + 1272 + switch string(nameBuf[:nameLen]) { 1273 + // t.Size (int64) (int64) 1274 + case "size": 1275 + { 1276 + maj, extra, err := cr.ReadHeader() 1277 + if err != nil { 1278 + return err 1279 + } 1280 + var extraI int64 1281 + switch maj { 1282 + case cbg.MajUnsignedInt: 1283 + extraI = int64(extra) 1284 + if extraI < 0 { 1285 + return fmt.Errorf("int64 positive overflow") 1286 + } 1287 + case cbg.MajNegativeInt: 1288 + extraI = int64(extra) 1289 + if extraI < 0 { 1290 + return fmt.Errorf("int64 negative overflow") 1291 + } 1292 + extraI = -1 - extraI 1293 + default: 1294 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1295 + } 1296 + 1297 + t.Size = int64(extraI) 211 1298 } 212 - // t.Type (string) (string) 1299 + // t.LexiconTypeID (string) (string) 213 1300 case "$type": 214 1301 215 1302 { ··· 218 1305 return err 219 1306 } 220 1307 221 - t.Type = string(sval) 1308 + t.LexiconTypeID = string(sval) 1309 + } 1310 + // t.Digest (string) (string) 1311 + case "digest": 1312 + 1313 + { 1314 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1315 + if err != nil { 1316 + return err 1317 + } 1318 + 1319 + t.Digest = string(sval) 1320 + } 1321 + // t.Platform (atproto.Manifest_Platform) (struct) 1322 + case "platform": 1323 + 1324 + { 1325 + 1326 + b, err := cr.ReadByte() 1327 + if err != nil { 1328 + return err 1329 + } 1330 + if b != cbg.CborNull[0] { 1331 + if err := cr.UnreadByte(); err != nil { 1332 + return err 1333 + } 1334 + t.Platform = new(Manifest_Platform) 1335 + if err := t.Platform.UnmarshalCBOR(cr); err != nil { 1336 + return xerrors.Errorf("unmarshaling t.Platform pointer: %w", err) 1337 + } 1338 + } 1339 + 1340 + } 1341 + // t.MediaType (string) (string) 1342 + case "mediaType": 1343 + 1344 + { 1345 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1346 + if err != nil { 1347 + return err 1348 + } 1349 + 1350 + t.MediaType = string(sval) 1351 + } 1352 + // t.Annotations (atproto.Manifest_ManifestReference_Annotations) (struct) 1353 + case "annotations": 1354 + 1355 + { 1356 + 1357 + b, err := cr.ReadByte() 1358 + if err != nil { 1359 + return err 1360 + } 1361 + if b != cbg.CborNull[0] { 1362 + if err := cr.UnreadByte(); err != nil { 1363 + return err 1364 + } 1365 + t.Annotations = new(Manifest_ManifestReference_Annotations) 1366 + if err := t.Annotations.UnmarshalCBOR(cr); err != nil { 1367 + return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err) 1368 + } 1369 + } 1370 + 1371 + } 1372 + 1373 + default: 1374 + // Field doesn't exist on this type, so ignore it 1375 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1376 + return err 1377 + } 1378 + } 1379 + } 1380 + 1381 + return nil 1382 + } 1383 + func (t *Manifest_Platform) MarshalCBOR(w io.Writer) error { 1384 + if t == nil { 1385 + _, err := w.Write(cbg.CborNull) 1386 + return err 1387 + } 1388 + 1389 + cw := cbg.NewCborWriter(w) 1390 + fieldCount := 6 1391 + 1392 + if t.LexiconTypeID == "" { 1393 + fieldCount-- 1394 + } 1395 + 1396 + if t.OsFeatures == nil { 1397 + fieldCount-- 1398 + } 1399 + 1400 + if t.OsVersion == nil { 1401 + fieldCount-- 1402 + } 1403 + 1404 + if t.Variant == nil { 1405 + fieldCount-- 1406 + } 1407 + 1408 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1409 + return err 1410 + } 1411 + 1412 + // t.Os (string) (string) 1413 + if len("os") > 8192 { 1414 + return xerrors.Errorf("Value in field \"os\" was too long") 1415 + } 1416 + 1417 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("os"))); err != nil { 1418 + return err 1419 + } 1420 + if _, err := cw.WriteString(string("os")); err != nil { 1421 + return err 1422 + } 1423 + 1424 + if len(t.Os) > 8192 { 1425 + return xerrors.Errorf("Value in field t.Os was too long") 1426 + } 1427 + 1428 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Os))); err != nil { 1429 + return err 1430 + } 1431 + if _, err := cw.WriteString(string(t.Os)); err != nil { 1432 + return err 1433 + } 1434 + 1435 + // t.LexiconTypeID (string) (string) 1436 + if t.LexiconTypeID != "" { 1437 + 1438 + if len("$type") > 8192 { 1439 + return xerrors.Errorf("Value in field \"$type\" was too long") 1440 + } 1441 + 1442 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 1443 + return err 1444 + } 1445 + if _, err := cw.WriteString(string("$type")); err != nil { 1446 + return err 1447 + } 1448 + 1449 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#platform"))); err != nil { 1450 + return err 1451 + } 1452 + if _, err := cw.WriteString(string("io.atcr.manifest#platform")); err != nil { 1453 + return err 1454 + } 1455 + } 1456 + 1457 + // t.Variant (string) (string) 1458 + if t.Variant != nil { 1459 + 1460 + if len("variant") > 8192 { 1461 + return xerrors.Errorf("Value in field \"variant\" was too long") 1462 + } 1463 + 1464 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("variant"))); err != nil { 1465 + return err 1466 + } 1467 + if _, err := cw.WriteString(string("variant")); err != nil { 1468 + return err 1469 + } 1470 + 1471 + if t.Variant == nil { 1472 + if _, err := cw.Write(cbg.CborNull); err != nil { 1473 + return err 222 1474 } 223 - // t.Member (string) (string) 224 - case "member": 1475 + } else { 1476 + if len(*t.Variant) > 8192 { 1477 + return xerrors.Errorf("Value in field t.Variant was too long") 1478 + } 1479 + 1480 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Variant))); err != nil { 1481 + return err 1482 + } 1483 + if _, err := cw.WriteString(string(*t.Variant)); err != nil { 1484 + return err 1485 + } 1486 + } 1487 + } 1488 + 1489 + // t.OsVersion (string) (string) 1490 + if t.OsVersion != nil { 1491 + 1492 + if len("osVersion") > 8192 { 1493 + return xerrors.Errorf("Value in field \"osVersion\" was too long") 1494 + } 1495 + 1496 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("osVersion"))); err != nil { 1497 + return err 1498 + } 1499 + if _, err := cw.WriteString(string("osVersion")); err != nil { 1500 + return err 1501 + } 1502 + 1503 + if t.OsVersion == nil { 1504 + if _, err := cw.Write(cbg.CborNull); err != nil { 1505 + return err 1506 + } 1507 + } else { 1508 + if len(*t.OsVersion) > 8192 { 1509 + return xerrors.Errorf("Value in field t.OsVersion was too long") 1510 + } 1511 + 1512 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.OsVersion))); err != nil { 1513 + return err 1514 + } 1515 + if _, err := cw.WriteString(string(*t.OsVersion)); err != nil { 1516 + return err 1517 + } 1518 + } 1519 + } 1520 + 1521 + // t.OsFeatures ([]string) (slice) 1522 + if t.OsFeatures != nil { 1523 + 1524 + if len("osFeatures") > 8192 { 1525 + return xerrors.Errorf("Value in field \"osFeatures\" was too long") 1526 + } 1527 + 1528 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("osFeatures"))); err != nil { 1529 + return err 1530 + } 1531 + if _, err := cw.WriteString(string("osFeatures")); err != nil { 1532 + return err 1533 + } 1534 + 1535 + if len(t.OsFeatures) > 8192 { 1536 + return xerrors.Errorf("Slice value in field t.OsFeatures was too long") 1537 + } 1538 + 1539 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.OsFeatures))); err != nil { 1540 + return err 1541 + } 1542 + for _, v := range t.OsFeatures { 1543 + if len(v) > 8192 { 1544 + return xerrors.Errorf("Value in field v was too long") 1545 + } 1546 + 1547 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 1548 + return err 1549 + } 1550 + if _, err := cw.WriteString(string(v)); err != nil { 1551 + return err 1552 + } 1553 + 1554 + } 1555 + } 1556 + 1557 + // t.Architecture (string) (string) 1558 + if len("architecture") > 8192 { 1559 + return xerrors.Errorf("Value in field \"architecture\" was too long") 1560 + } 1561 + 1562 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("architecture"))); err != nil { 1563 + return err 1564 + } 1565 + if _, err := cw.WriteString(string("architecture")); err != nil { 1566 + return err 1567 + } 1568 + 1569 + if len(t.Architecture) > 8192 { 1570 + return xerrors.Errorf("Value in field t.Architecture was too long") 1571 + } 1572 + 1573 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Architecture))); err != nil { 1574 + return err 1575 + } 1576 + if _, err := cw.WriteString(string(t.Architecture)); err != nil { 1577 + return err 1578 + } 1579 + return nil 1580 + } 1581 + 1582 + func (t *Manifest_Platform) UnmarshalCBOR(r io.Reader) (err error) { 1583 + *t = Manifest_Platform{} 1584 + 1585 + cr := cbg.NewCborReader(r) 1586 + 1587 + maj, extra, err := cr.ReadHeader() 1588 + if err != nil { 1589 + return err 1590 + } 1591 + defer func() { 1592 + if err == io.EOF { 1593 + err = io.ErrUnexpectedEOF 1594 + } 1595 + }() 1596 + 1597 + if maj != cbg.MajMap { 1598 + return fmt.Errorf("cbor input should be of type map") 1599 + } 1600 + 1601 + if extra > cbg.MaxLength { 1602 + return fmt.Errorf("Manifest_Platform: map struct too large (%d)", extra) 1603 + } 1604 + 1605 + n := extra 1606 + 1607 + nameBuf := make([]byte, 12) 1608 + for i := uint64(0); i < n; i++ { 1609 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 1610 + if err != nil { 1611 + return err 1612 + } 1613 + 1614 + if !ok { 1615 + // Field doesn't exist on this type, so ignore it 1616 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1617 + return err 1618 + } 1619 + continue 1620 + } 1621 + 1622 + switch string(nameBuf[:nameLen]) { 1623 + // t.Os (string) (string) 1624 + case "os": 225 1625 226 1626 { 227 1627 sval, err := cbg.ReadStringWithMax(cr, 8192) ··· 229 1629 return err 230 1630 } 231 1631 232 - t.Member = string(sval) 1632 + t.Os = string(sval) 233 1633 } 234 - // t.AddedAt (string) (string) 235 - case "addedAt": 1634 + // t.LexiconTypeID (string) (string) 1635 + case "$type": 236 1636 237 1637 { 238 1638 sval, err := cbg.ReadStringWithMax(cr, 8192) ··· 240 1640 return err 241 1641 } 242 1642 243 - t.AddedAt = string(sval) 1643 + t.LexiconTypeID = string(sval) 244 1644 } 245 - // t.Permissions ([]string) (slice) 246 - case "permissions": 1645 + // t.Variant (string) (string) 1646 + case "variant": 1647 + 1648 + { 1649 + b, err := cr.ReadByte() 1650 + if err != nil { 1651 + return err 1652 + } 1653 + if b != cbg.CborNull[0] { 1654 + if err := cr.UnreadByte(); err != nil { 1655 + return err 1656 + } 1657 + 1658 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1659 + if err != nil { 1660 + return err 1661 + } 1662 + 1663 + t.Variant = (*string)(&sval) 1664 + } 1665 + } 1666 + // t.OsVersion (string) (string) 1667 + case "osVersion": 1668 + 1669 + { 1670 + b, err := cr.ReadByte() 1671 + if err != nil { 1672 + return err 1673 + } 1674 + if b != cbg.CborNull[0] { 1675 + if err := cr.UnreadByte(); err != nil { 1676 + return err 1677 + } 1678 + 1679 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1680 + if err != nil { 1681 + return err 1682 + } 1683 + 1684 + t.OsVersion = (*string)(&sval) 1685 + } 1686 + } 1687 + // t.OsFeatures ([]string) (slice) 1688 + case "osFeatures": 247 1689 248 1690 maj, extra, err = cr.ReadHeader() 249 1691 if err != nil { ··· 251 1693 } 252 1694 253 1695 if extra > 8192 { 254 - return fmt.Errorf("t.Permissions: array too large (%d)", extra) 1696 + return fmt.Errorf("t.OsFeatures: array too large (%d)", extra) 255 1697 } 256 1698 257 1699 if maj != cbg.MajArray { ··· 259 1701 } 260 1702 261 1703 if extra > 0 { 262 - t.Permissions = make([]string, extra) 1704 + t.OsFeatures = make([]string, extra) 263 1705 } 264 1706 265 1707 for i := 0; i < int(extra); i++ { ··· 277 1719 return err 278 1720 } 279 1721 280 - t.Permissions[i] = string(sval) 1722 + t.OsFeatures[i] = string(sval) 1723 + } 1724 + 1725 + } 1726 + } 1727 + // t.Architecture (string) (string) 1728 + case "architecture": 1729 + 1730 + { 1731 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1732 + if err != nil { 1733 + return err 1734 + } 1735 + 1736 + t.Architecture = string(sval) 1737 + } 1738 + 1739 + default: 1740 + // Field doesn't exist on this type, so ignore it 1741 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1742 + return err 1743 + } 1744 + } 1745 + } 1746 + 1747 + return nil 1748 + } 1749 + func (t *Manifest_Annotations) MarshalCBOR(w io.Writer) error { 1750 + if t == nil { 1751 + _, err := w.Write(cbg.CborNull) 1752 + return err 1753 + } 1754 + 1755 + cw := cbg.NewCborWriter(w) 1756 + 1757 + if _, err := cw.Write([]byte{160}); err != nil { 1758 + return err 1759 + } 1760 + return nil 1761 + } 1762 + 1763 + func (t *Manifest_Annotations) UnmarshalCBOR(r io.Reader) (err error) { 1764 + *t = Manifest_Annotations{} 1765 + 1766 + cr := cbg.NewCborReader(r) 1767 + 1768 + maj, extra, err := cr.ReadHeader() 1769 + if err != nil { 1770 + return err 1771 + } 1772 + defer func() { 1773 + if err == io.EOF { 1774 + err = io.ErrUnexpectedEOF 1775 + } 1776 + }() 1777 + 1778 + if maj != cbg.MajMap { 1779 + return fmt.Errorf("cbor input should be of type map") 1780 + } 1781 + 1782 + if extra > cbg.MaxLength { 1783 + return fmt.Errorf("Manifest_Annotations: map struct too large (%d)", extra) 1784 + } 1785 + 1786 + n := extra 1787 + 1788 + nameBuf := make([]byte, 0) 1789 + for i := uint64(0); i < n; i++ { 1790 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 1791 + if err != nil { 1792 + return err 1793 + } 1794 + 1795 + if !ok { 1796 + // Field doesn't exist on this type, so ignore it 1797 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1798 + return err 1799 + } 1800 + continue 1801 + } 1802 + 1803 + switch string(nameBuf[:nameLen]) { 1804 + 1805 + default: 1806 + // Field doesn't exist on this type, so ignore it 1807 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1808 + return err 1809 + } 1810 + } 1811 + } 1812 + 1813 + return nil 1814 + } 1815 + func (t *Manifest_BlobReference_Annotations) MarshalCBOR(w io.Writer) error { 1816 + if t == nil { 1817 + _, err := w.Write(cbg.CborNull) 1818 + return err 1819 + } 1820 + 1821 + cw := cbg.NewCborWriter(w) 1822 + 1823 + if _, err := cw.Write([]byte{160}); err != nil { 1824 + return err 1825 + } 1826 + return nil 1827 + } 1828 + 1829 + func (t *Manifest_BlobReference_Annotations) UnmarshalCBOR(r io.Reader) (err error) { 1830 + *t = Manifest_BlobReference_Annotations{} 1831 + 1832 + cr := cbg.NewCborReader(r) 1833 + 1834 + maj, extra, err := cr.ReadHeader() 1835 + if err != nil { 1836 + return err 1837 + } 1838 + defer func() { 1839 + if err == io.EOF { 1840 + err = io.ErrUnexpectedEOF 1841 + } 1842 + }() 1843 + 1844 + if maj != cbg.MajMap { 1845 + return fmt.Errorf("cbor input should be of type map") 1846 + } 1847 + 1848 + if extra > cbg.MaxLength { 1849 + return fmt.Errorf("Manifest_BlobReference_Annotations: map struct too large (%d)", extra) 1850 + } 1851 + 1852 + n := extra 1853 + 1854 + nameBuf := make([]byte, 0) 1855 + for i := uint64(0); i < n; i++ { 1856 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 1857 + if err != nil { 1858 + return err 1859 + } 1860 + 1861 + if !ok { 1862 + // Field doesn't exist on this type, so ignore it 1863 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1864 + return err 1865 + } 1866 + continue 1867 + } 1868 + 1869 + switch string(nameBuf[:nameLen]) { 1870 + 1871 + default: 1872 + // Field doesn't exist on this type, so ignore it 1873 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1874 + return err 1875 + } 1876 + } 1877 + } 1878 + 1879 + return nil 1880 + } 1881 + func (t *Manifest_ManifestReference_Annotations) MarshalCBOR(w io.Writer) error { 1882 + if t == nil { 1883 + _, err := w.Write(cbg.CborNull) 1884 + return err 1885 + } 1886 + 1887 + cw := cbg.NewCborWriter(w) 1888 + 1889 + if _, err := cw.Write([]byte{160}); err != nil { 1890 + return err 1891 + } 1892 + return nil 1893 + } 1894 + 1895 + func (t *Manifest_ManifestReference_Annotations) UnmarshalCBOR(r io.Reader) (err error) { 1896 + *t = Manifest_ManifestReference_Annotations{} 1897 + 1898 + cr := cbg.NewCborReader(r) 1899 + 1900 + maj, extra, err := cr.ReadHeader() 1901 + if err != nil { 1902 + return err 1903 + } 1904 + defer func() { 1905 + if err == io.EOF { 1906 + err = io.ErrUnexpectedEOF 1907 + } 1908 + }() 1909 + 1910 + if maj != cbg.MajMap { 1911 + return fmt.Errorf("cbor input should be of type map") 1912 + } 1913 + 1914 + if extra > cbg.MaxLength { 1915 + return fmt.Errorf("Manifest_ManifestReference_Annotations: map struct too large (%d)", extra) 1916 + } 1917 + 1918 + n := extra 1919 + 1920 + nameBuf := make([]byte, 0) 1921 + for i := uint64(0); i < n; i++ { 1922 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 1923 + if err != nil { 1924 + return err 1925 + } 1926 + 1927 + if !ok { 1928 + // Field doesn't exist on this type, so ignore it 1929 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1930 + return err 1931 + } 1932 + continue 1933 + } 1934 + 1935 + switch string(nameBuf[:nameLen]) { 1936 + 1937 + default: 1938 + // Field doesn't exist on this type, so ignore it 1939 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1940 + return err 1941 + } 1942 + } 1943 + } 1944 + 1945 + return nil 1946 + } 1947 + func (t *Tag) MarshalCBOR(w io.Writer) error { 1948 + if t == nil { 1949 + _, err := w.Write(cbg.CborNull) 1950 + return err 1951 + } 1952 + 1953 + cw := cbg.NewCborWriter(w) 1954 + fieldCount := 6 1955 + 1956 + if t.Manifest == nil { 1957 + fieldCount-- 1958 + } 1959 + 1960 + if t.ManifestDigest == nil { 1961 + fieldCount-- 1962 + } 1963 + 1964 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1965 + return err 1966 + } 1967 + 1968 + // t.Tag (string) (string) 1969 + if len("tag") > 8192 { 1970 + return xerrors.Errorf("Value in field \"tag\" was too long") 1971 + } 1972 + 1973 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tag"))); err != nil { 1974 + return err 1975 + } 1976 + if _, err := cw.WriteString(string("tag")); err != nil { 1977 + return err 1978 + } 1979 + 1980 + if len(t.Tag) > 8192 { 1981 + return xerrors.Errorf("Value in field t.Tag was too long") 1982 + } 1983 + 1984 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Tag))); err != nil { 1985 + return err 1986 + } 1987 + if _, err := cw.WriteString(string(t.Tag)); err != nil { 1988 + return err 1989 + } 1990 + 1991 + // t.LexiconTypeID (string) (string) 1992 + if len("$type") > 8192 { 1993 + return xerrors.Errorf("Value in field \"$type\" was too long") 1994 + } 1995 + 1996 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 1997 + return err 1998 + } 1999 + if _, err := cw.WriteString(string("$type")); err != nil { 2000 + return err 2001 + } 2002 + 2003 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.tag"))); err != nil { 2004 + return err 2005 + } 2006 + if _, err := cw.WriteString(string("io.atcr.tag")); err != nil { 2007 + return err 2008 + } 2009 + 2010 + // t.Manifest (string) (string) 2011 + if t.Manifest != nil { 2012 + 2013 + if len("manifest") > 8192 { 2014 + return xerrors.Errorf("Value in field \"manifest\" was too long") 2015 + } 2016 + 2017 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifest"))); err != nil { 2018 + return err 2019 + } 2020 + if _, err := cw.WriteString(string("manifest")); err != nil { 2021 + return err 2022 + } 2023 + 2024 + if t.Manifest == nil { 2025 + if _, err := cw.Write(cbg.CborNull); err != nil { 2026 + return err 2027 + } 2028 + } else { 2029 + if len(*t.Manifest) > 8192 { 2030 + return xerrors.Errorf("Value in field t.Manifest was too long") 2031 + } 2032 + 2033 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Manifest))); err != nil { 2034 + return err 2035 + } 2036 + if _, err := cw.WriteString(string(*t.Manifest)); err != nil { 2037 + return err 2038 + } 2039 + } 2040 + } 2041 + 2042 + // t.CreatedAt (string) (string) 2043 + if len("createdAt") > 8192 { 2044 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2045 + } 2046 + 2047 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2048 + return err 2049 + } 2050 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2051 + return err 2052 + } 2053 + 2054 + if len(t.CreatedAt) > 8192 { 2055 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2056 + } 2057 + 2058 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2059 + return err 2060 + } 2061 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2062 + return err 2063 + } 2064 + 2065 + // t.Repository (string) (string) 2066 + if len("repository") > 8192 { 2067 + return xerrors.Errorf("Value in field \"repository\" was too long") 2068 + } 2069 + 2070 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil { 2071 + return err 2072 + } 2073 + if _, err := cw.WriteString(string("repository")); err != nil { 2074 + return err 2075 + } 2076 + 2077 + if len(t.Repository) > 8192 { 2078 + return xerrors.Errorf("Value in field t.Repository was too long") 2079 + } 2080 + 2081 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil { 2082 + return err 2083 + } 2084 + if _, err := cw.WriteString(string(t.Repository)); err != nil { 2085 + return err 2086 + } 2087 + 2088 + // t.ManifestDigest (string) (string) 2089 + if t.ManifestDigest != nil { 2090 + 2091 + if len("manifestDigest") > 8192 { 2092 + return xerrors.Errorf("Value in field \"manifestDigest\" was too long") 2093 + } 2094 + 2095 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifestDigest"))); err != nil { 2096 + return err 2097 + } 2098 + if _, err := cw.WriteString(string("manifestDigest")); err != nil { 2099 + return err 2100 + } 2101 + 2102 + if t.ManifestDigest == nil { 2103 + if _, err := cw.Write(cbg.CborNull); err != nil { 2104 + return err 2105 + } 2106 + } else { 2107 + if len(*t.ManifestDigest) > 8192 { 2108 + return xerrors.Errorf("Value in field t.ManifestDigest was too long") 2109 + } 2110 + 2111 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ManifestDigest))); err != nil { 2112 + return err 2113 + } 2114 + if _, err := cw.WriteString(string(*t.ManifestDigest)); err != nil { 2115 + return err 2116 + } 2117 + } 2118 + } 2119 + return nil 2120 + } 2121 + 2122 + func (t *Tag) UnmarshalCBOR(r io.Reader) (err error) { 2123 + *t = Tag{} 2124 + 2125 + cr := cbg.NewCborReader(r) 2126 + 2127 + maj, extra, err := cr.ReadHeader() 2128 + if err != nil { 2129 + return err 2130 + } 2131 + defer func() { 2132 + if err == io.EOF { 2133 + err = io.ErrUnexpectedEOF 2134 + } 2135 + }() 2136 + 2137 + if maj != cbg.MajMap { 2138 + return fmt.Errorf("cbor input should be of type map") 2139 + } 2140 + 2141 + if extra > cbg.MaxLength { 2142 + return fmt.Errorf("Tag: map struct too large (%d)", extra) 2143 + } 2144 + 2145 + n := extra 2146 + 2147 + nameBuf := make([]byte, 14) 2148 + for i := uint64(0); i < n; i++ { 2149 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2150 + if err != nil { 2151 + return err 2152 + } 2153 + 2154 + if !ok { 2155 + // Field doesn't exist on this type, so ignore it 2156 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2157 + return err 2158 + } 2159 + continue 2160 + } 2161 + 2162 + switch string(nameBuf[:nameLen]) { 2163 + // t.Tag (string) (string) 2164 + case "tag": 2165 + 2166 + { 2167 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2168 + if err != nil { 2169 + return err 2170 + } 2171 + 2172 + t.Tag = string(sval) 2173 + } 2174 + // t.LexiconTypeID (string) (string) 2175 + case "$type": 2176 + 2177 + { 2178 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2179 + if err != nil { 2180 + return err 2181 + } 2182 + 2183 + t.LexiconTypeID = string(sval) 2184 + } 2185 + // t.Manifest (string) (string) 2186 + case "manifest": 2187 + 2188 + { 2189 + b, err := cr.ReadByte() 2190 + if err != nil { 2191 + return err 2192 + } 2193 + if b != cbg.CborNull[0] { 2194 + if err := cr.UnreadByte(); err != nil { 2195 + return err 281 2196 } 282 2197 2198 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2199 + if err != nil { 2200 + return err 2201 + } 2202 + 2203 + t.Manifest = (*string)(&sval) 2204 + } 2205 + } 2206 + // t.CreatedAt (string) (string) 2207 + case "createdAt": 2208 + 2209 + { 2210 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2211 + if err != nil { 2212 + return err 2213 + } 2214 + 2215 + t.CreatedAt = string(sval) 2216 + } 2217 + // t.Repository (string) (string) 2218 + case "repository": 2219 + 2220 + { 2221 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2222 + if err != nil { 2223 + return err 2224 + } 2225 + 2226 + t.Repository = string(sval) 2227 + } 2228 + // t.ManifestDigest (string) (string) 2229 + case "manifestDigest": 2230 + 2231 + { 2232 + b, err := cr.ReadByte() 2233 + if err != nil { 2234 + return err 2235 + } 2236 + if b != cbg.CborNull[0] { 2237 + if err := cr.UnreadByte(); err != nil { 2238 + return err 2239 + } 2240 + 2241 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2242 + if err != nil { 2243 + return err 2244 + } 2245 + 2246 + t.ManifestDigest = (*string)(&sval) 283 2247 } 284 2248 } 285 2249 ··· 293 2257 294 2258 return nil 295 2259 } 296 - func (t *CaptainRecord) MarshalCBOR(w io.Writer) error { 2260 + func (t *SailorProfile) MarshalCBOR(w io.Writer) error { 297 2261 if t == nil { 298 2262 _, err := w.Write(cbg.CborNull) 299 2263 return err 300 2264 } 301 2265 302 2266 cw := cbg.NewCborWriter(w) 303 - fieldCount := 8 2267 + fieldCount := 4 304 2268 305 - if t.Region == "" { 2269 + if t.DefaultHold == nil { 306 2270 fieldCount-- 307 2271 } 308 2272 309 - if t.Provider == "" { 2273 + if t.UpdatedAt == nil { 310 2274 fieldCount-- 311 2275 } 312 2276 ··· 314 2278 return err 315 2279 } 316 2280 317 - // t.Type (string) (string) 2281 + // t.LexiconTypeID (string) (string) 318 2282 if len("$type") > 8192 { 319 2283 return xerrors.Errorf("Value in field \"$type\" was too long") 320 2284 } ··· 326 2290 return err 327 2291 } 328 2292 329 - if len(t.Type) > 8192 { 330 - return xerrors.Errorf("Value in field t.Type was too long") 2293 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.profile"))); err != nil { 2294 + return err 2295 + } 2296 + if _, err := cw.WriteString(string("io.atcr.sailor.profile")); err != nil { 2297 + return err 2298 + } 2299 + 2300 + // t.CreatedAt (string) (string) 2301 + if len("createdAt") > 8192 { 2302 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 331 2303 } 332 2304 333 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 2305 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 334 2306 return err 335 2307 } 336 - if _, err := cw.WriteString(string(t.Type)); err != nil { 2308 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2309 + return err 2310 + } 2311 + 2312 + if len(t.CreatedAt) > 8192 { 2313 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2314 + } 2315 + 2316 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2317 + return err 2318 + } 2319 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2320 + return err 2321 + } 2322 + 2323 + // t.UpdatedAt (string) (string) 2324 + if t.UpdatedAt != nil { 2325 + 2326 + if len("updatedAt") > 8192 { 2327 + return xerrors.Errorf("Value in field \"updatedAt\" was too long") 2328 + } 2329 + 2330 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updatedAt"))); err != nil { 2331 + return err 2332 + } 2333 + if _, err := cw.WriteString(string("updatedAt")); err != nil { 2334 + return err 2335 + } 2336 + 2337 + if t.UpdatedAt == nil { 2338 + if _, err := cw.Write(cbg.CborNull); err != nil { 2339 + return err 2340 + } 2341 + } else { 2342 + if len(*t.UpdatedAt) > 8192 { 2343 + return xerrors.Errorf("Value in field t.UpdatedAt was too long") 2344 + } 2345 + 2346 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.UpdatedAt))); err != nil { 2347 + return err 2348 + } 2349 + if _, err := cw.WriteString(string(*t.UpdatedAt)); err != nil { 2350 + return err 2351 + } 2352 + } 2353 + } 2354 + 2355 + // t.DefaultHold (string) (string) 2356 + if t.DefaultHold != nil { 2357 + 2358 + if len("defaultHold") > 8192 { 2359 + return xerrors.Errorf("Value in field \"defaultHold\" was too long") 2360 + } 2361 + 2362 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("defaultHold"))); err != nil { 2363 + return err 2364 + } 2365 + if _, err := cw.WriteString(string("defaultHold")); err != nil { 2366 + return err 2367 + } 2368 + 2369 + if t.DefaultHold == nil { 2370 + if _, err := cw.Write(cbg.CborNull); err != nil { 2371 + return err 2372 + } 2373 + } else { 2374 + if len(*t.DefaultHold) > 8192 { 2375 + return xerrors.Errorf("Value in field t.DefaultHold was too long") 2376 + } 2377 + 2378 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.DefaultHold))); err != nil { 2379 + return err 2380 + } 2381 + if _, err := cw.WriteString(string(*t.DefaultHold)); err != nil { 2382 + return err 2383 + } 2384 + } 2385 + } 2386 + return nil 2387 + } 2388 + 2389 + func (t *SailorProfile) UnmarshalCBOR(r io.Reader) (err error) { 2390 + *t = SailorProfile{} 2391 + 2392 + cr := cbg.NewCborReader(r) 2393 + 2394 + maj, extra, err := cr.ReadHeader() 2395 + if err != nil { 2396 + return err 2397 + } 2398 + defer func() { 2399 + if err == io.EOF { 2400 + err = io.ErrUnexpectedEOF 2401 + } 2402 + }() 2403 + 2404 + if maj != cbg.MajMap { 2405 + return fmt.Errorf("cbor input should be of type map") 2406 + } 2407 + 2408 + if extra > cbg.MaxLength { 2409 + return fmt.Errorf("SailorProfile: map struct too large (%d)", extra) 2410 + } 2411 + 2412 + n := extra 2413 + 2414 + nameBuf := make([]byte, 11) 2415 + for i := uint64(0); i < n; i++ { 2416 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2417 + if err != nil { 2418 + return err 2419 + } 2420 + 2421 + if !ok { 2422 + // Field doesn't exist on this type, so ignore it 2423 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2424 + return err 2425 + } 2426 + continue 2427 + } 2428 + 2429 + switch string(nameBuf[:nameLen]) { 2430 + // t.LexiconTypeID (string) (string) 2431 + case "$type": 2432 + 2433 + { 2434 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2435 + if err != nil { 2436 + return err 2437 + } 2438 + 2439 + t.LexiconTypeID = string(sval) 2440 + } 2441 + // t.CreatedAt (string) (string) 2442 + case "createdAt": 2443 + 2444 + { 2445 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2446 + if err != nil { 2447 + return err 2448 + } 2449 + 2450 + t.CreatedAt = string(sval) 2451 + } 2452 + // t.UpdatedAt (string) (string) 2453 + case "updatedAt": 2454 + 2455 + { 2456 + b, err := cr.ReadByte() 2457 + if err != nil { 2458 + return err 2459 + } 2460 + if b != cbg.CborNull[0] { 2461 + if err := cr.UnreadByte(); err != nil { 2462 + return err 2463 + } 2464 + 2465 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2466 + if err != nil { 2467 + return err 2468 + } 2469 + 2470 + t.UpdatedAt = (*string)(&sval) 2471 + } 2472 + } 2473 + // t.DefaultHold (string) (string) 2474 + case "defaultHold": 2475 + 2476 + { 2477 + b, err := cr.ReadByte() 2478 + if err != nil { 2479 + return err 2480 + } 2481 + if b != cbg.CborNull[0] { 2482 + if err := cr.UnreadByte(); err != nil { 2483 + return err 2484 + } 2485 + 2486 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2487 + if err != nil { 2488 + return err 2489 + } 2490 + 2491 + t.DefaultHold = (*string)(&sval) 2492 + } 2493 + } 2494 + 2495 + default: 2496 + // Field doesn't exist on this type, so ignore it 2497 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2498 + return err 2499 + } 2500 + } 2501 + } 2502 + 2503 + return nil 2504 + } 2505 + func (t *SailorStar) MarshalCBOR(w io.Writer) error { 2506 + if t == nil { 2507 + _, err := w.Write(cbg.CborNull) 2508 + return err 2509 + } 2510 + 2511 + cw := cbg.NewCborWriter(w) 2512 + 2513 + if _, err := cw.Write([]byte{163}); err != nil { 2514 + return err 2515 + } 2516 + 2517 + // t.LexiconTypeID (string) (string) 2518 + if len("$type") > 8192 { 2519 + return xerrors.Errorf("Value in field \"$type\" was too long") 2520 + } 2521 + 2522 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2523 + return err 2524 + } 2525 + if _, err := cw.WriteString(string("$type")); err != nil { 2526 + return err 2527 + } 2528 + 2529 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.star"))); err != nil { 2530 + return err 2531 + } 2532 + if _, err := cw.WriteString(string("io.atcr.sailor.star")); err != nil { 2533 + return err 2534 + } 2535 + 2536 + // t.Subject (atproto.SailorStar_Subject) (struct) 2537 + if len("subject") > 8192 { 2538 + return xerrors.Errorf("Value in field \"subject\" was too long") 2539 + } 2540 + 2541 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 2542 + return err 2543 + } 2544 + if _, err := cw.WriteString(string("subject")); err != nil { 2545 + return err 2546 + } 2547 + 2548 + if err := t.Subject.MarshalCBOR(cw); err != nil { 2549 + return err 2550 + } 2551 + 2552 + // t.CreatedAt (string) (string) 2553 + if len("createdAt") > 8192 { 2554 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2555 + } 2556 + 2557 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2558 + return err 2559 + } 2560 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2561 + return err 2562 + } 2563 + 2564 + if len(t.CreatedAt) > 8192 { 2565 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2566 + } 2567 + 2568 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2569 + return err 2570 + } 2571 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2572 + return err 2573 + } 2574 + return nil 2575 + } 2576 + 2577 + func (t *SailorStar) UnmarshalCBOR(r io.Reader) (err error) { 2578 + *t = SailorStar{} 2579 + 2580 + cr := cbg.NewCborReader(r) 2581 + 2582 + maj, extra, err := cr.ReadHeader() 2583 + if err != nil { 2584 + return err 2585 + } 2586 + defer func() { 2587 + if err == io.EOF { 2588 + err = io.ErrUnexpectedEOF 2589 + } 2590 + }() 2591 + 2592 + if maj != cbg.MajMap { 2593 + return fmt.Errorf("cbor input should be of type map") 2594 + } 2595 + 2596 + if extra > cbg.MaxLength { 2597 + return fmt.Errorf("SailorStar: map struct too large (%d)", extra) 2598 + } 2599 + 2600 + n := extra 2601 + 2602 + nameBuf := make([]byte, 9) 2603 + for i := uint64(0); i < n; i++ { 2604 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2605 + if err != nil { 2606 + return err 2607 + } 2608 + 2609 + if !ok { 2610 + // Field doesn't exist on this type, so ignore it 2611 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2612 + return err 2613 + } 2614 + continue 2615 + } 2616 + 2617 + switch string(nameBuf[:nameLen]) { 2618 + // t.LexiconTypeID (string) (string) 2619 + case "$type": 2620 + 2621 + { 2622 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2623 + if err != nil { 2624 + return err 2625 + } 2626 + 2627 + t.LexiconTypeID = string(sval) 2628 + } 2629 + // t.Subject (atproto.SailorStar_Subject) (struct) 2630 + case "subject": 2631 + 2632 + { 2633 + 2634 + if err := t.Subject.UnmarshalCBOR(cr); err != nil { 2635 + return xerrors.Errorf("unmarshaling t.Subject: %w", err) 2636 + } 2637 + 2638 + } 2639 + // t.CreatedAt (string) (string) 2640 + case "createdAt": 2641 + 2642 + { 2643 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2644 + if err != nil { 2645 + return err 2646 + } 2647 + 2648 + t.CreatedAt = string(sval) 2649 + } 2650 + 2651 + default: 2652 + // Field doesn't exist on this type, so ignore it 2653 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2654 + return err 2655 + } 2656 + } 2657 + } 2658 + 2659 + return nil 2660 + } 2661 + func (t *SailorStar_Subject) MarshalCBOR(w io.Writer) error { 2662 + if t == nil { 2663 + _, err := w.Write(cbg.CborNull) 2664 + return err 2665 + } 2666 + 2667 + cw := cbg.NewCborWriter(w) 2668 + fieldCount := 3 2669 + 2670 + if t.LexiconTypeID == "" { 2671 + fieldCount-- 2672 + } 2673 + 2674 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 2675 + return err 2676 + } 2677 + 2678 + // t.Did (string) (string) 2679 + if len("did") > 8192 { 2680 + return xerrors.Errorf("Value in field \"did\" was too long") 2681 + } 2682 + 2683 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("did"))); err != nil { 2684 + return err 2685 + } 2686 + if _, err := cw.WriteString(string("did")); err != nil { 2687 + return err 2688 + } 2689 + 2690 + if len(t.Did) > 8192 { 2691 + return xerrors.Errorf("Value in field t.Did was too long") 2692 + } 2693 + 2694 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Did))); err != nil { 2695 + return err 2696 + } 2697 + if _, err := cw.WriteString(string(t.Did)); err != nil { 2698 + return err 2699 + } 2700 + 2701 + // t.LexiconTypeID (string) (string) 2702 + if t.LexiconTypeID != "" { 2703 + 2704 + if len("$type") > 8192 { 2705 + return xerrors.Errorf("Value in field \"$type\" was too long") 2706 + } 2707 + 2708 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2709 + return err 2710 + } 2711 + if _, err := cw.WriteString(string("$type")); err != nil { 2712 + return err 2713 + } 2714 + 2715 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.star#subject"))); err != nil { 2716 + return err 2717 + } 2718 + if _, err := cw.WriteString(string("io.atcr.sailor.star#subject")); err != nil { 2719 + return err 2720 + } 2721 + } 2722 + 2723 + // t.Repository (string) (string) 2724 + if len("repository") > 8192 { 2725 + return xerrors.Errorf("Value in field \"repository\" was too long") 2726 + } 2727 + 2728 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil { 2729 + return err 2730 + } 2731 + if _, err := cw.WriteString(string("repository")); err != nil { 2732 + return err 2733 + } 2734 + 2735 + if len(t.Repository) > 8192 { 2736 + return xerrors.Errorf("Value in field t.Repository was too long") 2737 + } 2738 + 2739 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil { 2740 + return err 2741 + } 2742 + if _, err := cw.WriteString(string(t.Repository)); err != nil { 2743 + return err 2744 + } 2745 + return nil 2746 + } 2747 + 2748 + func (t *SailorStar_Subject) UnmarshalCBOR(r io.Reader) (err error) { 2749 + *t = SailorStar_Subject{} 2750 + 2751 + cr := cbg.NewCborReader(r) 2752 + 2753 + maj, extra, err := cr.ReadHeader() 2754 + if err != nil { 2755 + return err 2756 + } 2757 + defer func() { 2758 + if err == io.EOF { 2759 + err = io.ErrUnexpectedEOF 2760 + } 2761 + }() 2762 + 2763 + if maj != cbg.MajMap { 2764 + return fmt.Errorf("cbor input should be of type map") 2765 + } 2766 + 2767 + if extra > cbg.MaxLength { 2768 + return fmt.Errorf("SailorStar_Subject: map struct too large (%d)", extra) 2769 + } 2770 + 2771 + n := extra 2772 + 2773 + nameBuf := make([]byte, 10) 2774 + for i := uint64(0); i < n; i++ { 2775 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2776 + if err != nil { 2777 + return err 2778 + } 2779 + 2780 + if !ok { 2781 + // Field doesn't exist on this type, so ignore it 2782 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2783 + return err 2784 + } 2785 + continue 2786 + } 2787 + 2788 + switch string(nameBuf[:nameLen]) { 2789 + // t.Did (string) (string) 2790 + case "did": 2791 + 2792 + { 2793 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2794 + if err != nil { 2795 + return err 2796 + } 2797 + 2798 + t.Did = string(sval) 2799 + } 2800 + // t.LexiconTypeID (string) (string) 2801 + case "$type": 2802 + 2803 + { 2804 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2805 + if err != nil { 2806 + return err 2807 + } 2808 + 2809 + t.LexiconTypeID = string(sval) 2810 + } 2811 + // t.Repository (string) (string) 2812 + case "repository": 2813 + 2814 + { 2815 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2816 + if err != nil { 2817 + return err 2818 + } 2819 + 2820 + t.Repository = string(sval) 2821 + } 2822 + 2823 + default: 2824 + // Field doesn't exist on this type, so ignore it 2825 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2826 + return err 2827 + } 2828 + } 2829 + } 2830 + 2831 + return nil 2832 + } 2833 + func (t *HoldCaptain) MarshalCBOR(w io.Writer) error { 2834 + if t == nil { 2835 + _, err := w.Write(cbg.CborNull) 2836 + return err 2837 + } 2838 + 2839 + cw := cbg.NewCborWriter(w) 2840 + fieldCount := 8 2841 + 2842 + if t.Provider == nil { 2843 + fieldCount-- 2844 + } 2845 + 2846 + if t.Region == nil { 2847 + fieldCount-- 2848 + } 2849 + 2850 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 2851 + return err 2852 + } 2853 + 2854 + // t.LexiconTypeID (string) (string) 2855 + if len("$type") > 8192 { 2856 + return xerrors.Errorf("Value in field \"$type\" was too long") 2857 + } 2858 + 2859 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2860 + return err 2861 + } 2862 + if _, err := cw.WriteString(string("$type")); err != nil { 2863 + return err 2864 + } 2865 + 2866 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.captain"))); err != nil { 2867 + return err 2868 + } 2869 + if _, err := cw.WriteString(string("io.atcr.hold.captain")); err != nil { 337 2870 return err 338 2871 } 339 2872 ··· 377 2910 } 378 2911 379 2912 // t.Region (string) (string) 380 - if t.Region != "" { 2913 + if t.Region != nil { 381 2914 382 2915 if len("region") > 8192 { 383 2916 return xerrors.Errorf("Value in field \"region\" was too long") ··· 390 2923 return err 391 2924 } 392 2925 393 - if len(t.Region) > 8192 { 394 - return xerrors.Errorf("Value in field t.Region was too long") 395 - } 2926 + if t.Region == nil { 2927 + if _, err := cw.Write(cbg.CborNull); err != nil { 2928 + return err 2929 + } 2930 + } else { 2931 + if len(*t.Region) > 8192 { 2932 + return xerrors.Errorf("Value in field t.Region was too long") 2933 + } 396 2934 397 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Region))); err != nil { 398 - return err 399 - } 400 - if _, err := cw.WriteString(string(t.Region)); err != nil { 401 - return err 2935 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Region))); err != nil { 2936 + return err 2937 + } 2938 + if _, err := cw.WriteString(string(*t.Region)); err != nil { 2939 + return err 2940 + } 402 2941 } 403 2942 } 404 2943 405 2944 // t.Provider (string) (string) 406 - if t.Provider != "" { 2945 + if t.Provider != nil { 407 2946 408 2947 if len("provider") > 8192 { 409 2948 return xerrors.Errorf("Value in field \"provider\" was too long") ··· 416 2955 return err 417 2956 } 418 2957 419 - if len(t.Provider) > 8192 { 420 - return xerrors.Errorf("Value in field t.Provider was too long") 421 - } 2958 + if t.Provider == nil { 2959 + if _, err := cw.Write(cbg.CborNull); err != nil { 2960 + return err 2961 + } 2962 + } else { 2963 + if len(*t.Provider) > 8192 { 2964 + return xerrors.Errorf("Value in field t.Provider was too long") 2965 + } 422 2966 423 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Provider))); err != nil { 424 - return err 425 - } 426 - if _, err := cw.WriteString(string(t.Provider)); err != nil { 427 - return err 2967 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Provider))); err != nil { 2968 + return err 2969 + } 2970 + if _, err := cw.WriteString(string(*t.Provider)); err != nil { 2971 + return err 2972 + } 428 2973 } 429 2974 } 430 2975 ··· 485 3030 return nil 486 3031 } 487 3032 488 - func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) { 489 - *t = CaptainRecord{} 3033 + func (t *HoldCaptain) UnmarshalCBOR(r io.Reader) (err error) { 3034 + *t = HoldCaptain{} 490 3035 491 3036 cr := cbg.NewCborReader(r) 492 3037 ··· 505 3050 } 506 3051 507 3052 if extra > cbg.MaxLength { 508 - return fmt.Errorf("CaptainRecord: map struct too large (%d)", extra) 3053 + return fmt.Errorf("HoldCaptain: map struct too large (%d)", extra) 509 3054 } 510 3055 511 3056 n := extra ··· 526 3071 } 527 3072 528 3073 switch string(nameBuf[:nameLen]) { 529 - // t.Type (string) (string) 3074 + // t.LexiconTypeID (string) (string) 530 3075 case "$type": 531 3076 532 3077 { ··· 535 3080 return err 536 3081 } 537 3082 538 - t.Type = string(sval) 3083 + t.LexiconTypeID = string(sval) 539 3084 } 540 3085 // t.Owner (string) (string) 541 3086 case "owner": ··· 570 3115 case "region": 571 3116 572 3117 { 573 - sval, err := cbg.ReadStringWithMax(cr, 8192) 3118 + b, err := cr.ReadByte() 574 3119 if err != nil { 575 3120 return err 576 3121 } 3122 + if b != cbg.CborNull[0] { 3123 + if err := cr.UnreadByte(); err != nil { 3124 + return err 3125 + } 577 3126 578 - t.Region = string(sval) 3127 + sval, err := cbg.ReadStringWithMax(cr, 8192) 3128 + if err != nil { 3129 + return err 3130 + } 3131 + 3132 + t.Region = (*string)(&sval) 3133 + } 579 3134 } 580 3135 // t.Provider (string) (string) 581 3136 case "provider": 582 3137 583 3138 { 584 - sval, err := cbg.ReadStringWithMax(cr, 8192) 3139 + b, err := cr.ReadByte() 585 3140 if err != nil { 586 3141 return err 587 3142 } 3143 + if b != cbg.CborNull[0] { 3144 + if err := cr.UnreadByte(); err != nil { 3145 + return err 3146 + } 588 3147 589 - t.Provider = string(sval) 3148 + sval, err := cbg.ReadStringWithMax(cr, 8192) 3149 + if err != nil { 3150 + return err 3151 + } 3152 + 3153 + t.Provider = (*string)(&sval) 3154 + } 590 3155 } 591 3156 // t.DeployedAt (string) (string) 592 3157 case "deployedAt": ··· 646 3211 647 3212 return nil 648 3213 } 649 - func (t *LayerRecord) MarshalCBOR(w io.Writer) error { 3214 + func (t *HoldCrew) MarshalCBOR(w io.Writer) error { 3215 + if t == nil { 3216 + _, err := w.Write(cbg.CborNull) 3217 + return err 3218 + } 3219 + 3220 + cw := cbg.NewCborWriter(w) 3221 + 3222 + if _, err := cw.Write([]byte{165}); err != nil { 3223 + return err 3224 + } 3225 + 3226 + // t.Role (string) (string) 3227 + if len("role") > 8192 { 3228 + return xerrors.Errorf("Value in field \"role\" was too long") 3229 + } 3230 + 3231 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("role"))); err != nil { 3232 + return err 3233 + } 3234 + if _, err := cw.WriteString(string("role")); err != nil { 3235 + return err 3236 + } 3237 + 3238 + if len(t.Role) > 8192 { 3239 + return xerrors.Errorf("Value in field t.Role was too long") 3240 + } 3241 + 3242 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Role))); err != nil { 3243 + return err 3244 + } 3245 + if _, err := cw.WriteString(string(t.Role)); err != nil { 3246 + return err 3247 + } 3248 + 3249 + // t.LexiconTypeID (string) (string) 3250 + if len("$type") > 8192 { 3251 + return xerrors.Errorf("Value in field \"$type\" was too long") 3252 + } 3253 + 3254 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3255 + return err 3256 + } 3257 + if _, err := cw.WriteString(string("$type")); err != nil { 3258 + return err 3259 + } 3260 + 3261 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.crew"))); err != nil { 3262 + return err 3263 + } 3264 + if _, err := cw.WriteString(string("io.atcr.hold.crew")); err != nil { 3265 + return err 3266 + } 3267 + 3268 + // t.Member (string) (string) 3269 + if len("member") > 8192 { 3270 + return xerrors.Errorf("Value in field \"member\" was too long") 3271 + } 3272 + 3273 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil { 3274 + return err 3275 + } 3276 + if _, err := cw.WriteString(string("member")); err != nil { 3277 + return err 3278 + } 3279 + 3280 + if len(t.Member) > 8192 { 3281 + return xerrors.Errorf("Value in field t.Member was too long") 3282 + } 3283 + 3284 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil { 3285 + return err 3286 + } 3287 + if _, err := cw.WriteString(string(t.Member)); err != nil { 3288 + return err 3289 + } 3290 + 3291 + // t.AddedAt (string) (string) 3292 + if len("addedAt") > 8192 { 3293 + return xerrors.Errorf("Value in field \"addedAt\" was too long") 3294 + } 3295 + 3296 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil { 3297 + return err 3298 + } 3299 + if _, err := cw.WriteString(string("addedAt")); err != nil { 3300 + return err 3301 + } 3302 + 3303 + if len(t.AddedAt) > 8192 { 3304 + return xerrors.Errorf("Value in field t.AddedAt was too long") 3305 + } 3306 + 3307 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.AddedAt))); err != nil { 3308 + return err 3309 + } 3310 + if _, err := cw.WriteString(string(t.AddedAt)); err != nil { 3311 + return err 3312 + } 3313 + 3314 + // t.Permissions ([]string) (slice) 3315 + if len("permissions") > 8192 { 3316 + return xerrors.Errorf("Value in field \"permissions\" was too long") 3317 + } 3318 + 3319 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("permissions"))); err != nil { 3320 + return err 3321 + } 3322 + if _, err := cw.WriteString(string("permissions")); err != nil { 3323 + return err 3324 + } 3325 + 3326 + if len(t.Permissions) > 8192 { 3327 + return xerrors.Errorf("Slice value in field t.Permissions was too long") 3328 + } 3329 + 3330 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Permissions))); err != nil { 3331 + return err 3332 + } 3333 + for _, v := range t.Permissions { 3334 + if len(v) > 8192 { 3335 + return xerrors.Errorf("Value in field v was too long") 3336 + } 3337 + 3338 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3339 + return err 3340 + } 3341 + if _, err := cw.WriteString(string(v)); err != nil { 3342 + return err 3343 + } 3344 + 3345 + } 3346 + return nil 3347 + } 3348 + 3349 + func (t *HoldCrew) UnmarshalCBOR(r io.Reader) (err error) { 3350 + *t = HoldCrew{} 3351 + 3352 + cr := cbg.NewCborReader(r) 3353 + 3354 + maj, extra, err := cr.ReadHeader() 3355 + if err != nil { 3356 + return err 3357 + } 3358 + defer func() { 3359 + if err == io.EOF { 3360 + err = io.ErrUnexpectedEOF 3361 + } 3362 + }() 3363 + 3364 + if maj != cbg.MajMap { 3365 + return fmt.Errorf("cbor input should be of type map") 3366 + } 3367 + 3368 + if extra > cbg.MaxLength { 3369 + return fmt.Errorf("HoldCrew: map struct too large (%d)", extra) 3370 + } 3371 + 3372 + n := extra 3373 + 3374 + nameBuf := make([]byte, 11) 3375 + for i := uint64(0); i < n; i++ { 3376 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 3377 + if err != nil { 3378 + return err 3379 + } 3380 + 3381 + if !ok { 3382 + // Field doesn't exist on this type, so ignore it 3383 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3384 + return err 3385 + } 3386 + continue 3387 + } 3388 + 3389 + switch string(nameBuf[:nameLen]) { 3390 + // t.Role (string) (string) 3391 + case "role": 3392 + 3393 + { 3394 + sval, err := cbg.ReadStringWithMax(cr, 8192) 3395 + if err != nil { 3396 + return err 3397 + } 3398 + 3399 + t.Role = string(sval) 3400 + } 3401 + // t.LexiconTypeID (string) (string) 3402 + case "$type": 3403 + 3404 + { 3405 + sval, err := cbg.ReadStringWithMax(cr, 8192) 3406 + if err != nil { 3407 + return err 3408 + } 3409 + 3410 + t.LexiconTypeID = string(sval) 3411 + } 3412 + // t.Member (string) (string) 3413 + case "member": 3414 + 3415 + { 3416 + sval, err := cbg.ReadStringWithMax(cr, 8192) 3417 + if err != nil { 3418 + return err 3419 + } 3420 + 3421 + t.Member = string(sval) 3422 + } 3423 + // t.AddedAt (string) (string) 3424 + case "addedAt": 3425 + 3426 + { 3427 + sval, err := cbg.ReadStringWithMax(cr, 8192) 3428 + if err != nil { 3429 + return err 3430 + } 3431 + 3432 + t.AddedAt = string(sval) 3433 + } 3434 + // t.Permissions ([]string) (slice) 3435 + case "permissions": 3436 + 3437 + maj, extra, err = cr.ReadHeader() 3438 + if err != nil { 3439 + return err 3440 + } 3441 + 3442 + if extra > 8192 { 3443 + return fmt.Errorf("t.Permissions: array too large (%d)", extra) 3444 + } 3445 + 3446 + if maj != cbg.MajArray { 3447 + return fmt.Errorf("expected cbor array") 3448 + } 3449 + 3450 + if extra > 0 { 3451 + t.Permissions = make([]string, extra) 3452 + } 3453 + 3454 + for i := 0; i < int(extra); i++ { 3455 + { 3456 + var maj byte 3457 + var extra uint64 3458 + var err error 3459 + _ = maj 3460 + _ = extra 3461 + _ = err 3462 + 3463 + { 3464 + sval, err := cbg.ReadStringWithMax(cr, 8192) 3465 + if err != nil { 3466 + return err 3467 + } 3468 + 3469 + t.Permissions[i] = string(sval) 3470 + } 3471 + 3472 + } 3473 + } 3474 + 3475 + default: 3476 + // Field doesn't exist on this type, so ignore it 3477 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3478 + return err 3479 + } 3480 + } 3481 + } 3482 + 3483 + return nil 3484 + } 3485 + func (t *HoldLayer) MarshalCBOR(w io.Writer) error { 650 3486 if t == nil { 651 3487 _, err := w.Write(cbg.CborNull) 652 3488 return err ··· 680 3516 } 681 3517 } 682 3518 683 - // t.Type (string) (string) 3519 + // t.LexiconTypeID (string) (string) 684 3520 if len("$type") > 8192 { 685 3521 return xerrors.Errorf("Value in field \"$type\" was too long") 686 3522 } ··· 692 3528 return err 693 3529 } 694 3530 695 - if len(t.Type) > 8192 { 696 - return xerrors.Errorf("Value in field t.Type was too long") 697 - } 698 - 699 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 3531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.layer"))); err != nil { 700 3532 return err 701 3533 } 702 - if _, err := cw.WriteString(string(t.Type)); err != nil { 3534 + if _, err := cw.WriteString(string("io.atcr.hold.layer")); err != nil { 703 3535 return err 704 3536 } 705 3537 ··· 726 3558 return err 727 3559 } 728 3560 729 - // t.UserDID (string) (string) 3561 + // t.UserDid (string) (string) 730 3562 if len("userDid") > 8192 { 731 3563 return xerrors.Errorf("Value in field \"userDid\" was too long") 732 3564 } ··· 738 3570 return err 739 3571 } 740 3572 741 - if len(t.UserDID) > 8192 { 742 - return xerrors.Errorf("Value in field t.UserDID was too long") 3573 + if len(t.UserDid) > 8192 { 3574 + return xerrors.Errorf("Value in field t.UserDid was too long") 743 3575 } 744 3576 745 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil { 3577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDid))); err != nil { 746 3578 return err 747 3579 } 748 - if _, err := cw.WriteString(string(t.UserDID)); err != nil { 3580 + if _, err := cw.WriteString(string(t.UserDid)); err != nil { 749 3581 return err 750 3582 } 751 3583 ··· 843 3675 return nil 844 3676 } 845 3677 846 - func (t *LayerRecord) UnmarshalCBOR(r io.Reader) (err error) { 847 - *t = LayerRecord{} 3678 + func (t *HoldLayer) UnmarshalCBOR(r io.Reader) (err error) { 3679 + *t = HoldLayer{} 848 3680 849 3681 cr := cbg.NewCborReader(r) 850 3682 ··· 863 3695 } 864 3696 865 3697 if extra > cbg.MaxLength { 866 - return fmt.Errorf("LayerRecord: map struct too large (%d)", extra) 3698 + return fmt.Errorf("HoldLayer: map struct too large (%d)", extra) 867 3699 } 868 3700 869 3701 n := extra ··· 910 3742 911 3743 t.Size = int64(extraI) 912 3744 } 913 - // t.Type (string) (string) 3745 + // t.LexiconTypeID (string) (string) 914 3746 case "$type": 915 3747 916 3748 { ··· 919 3751 return err 920 3752 } 921 3753 922 - t.Type = string(sval) 3754 + t.LexiconTypeID = string(sval) 923 3755 } 924 3756 // t.Digest (string) (string) 925 3757 case "digest": ··· 932 3764 933 3765 t.Digest = string(sval) 934 3766 } 935 - // t.UserDID (string) (string) 3767 + // t.UserDid (string) (string) 936 3768 case "userDid": 937 3769 938 3770 { ··· 941 3773 return err 942 3774 } 943 3775 944 - t.UserDID = string(sval) 3776 + t.UserDid = string(sval) 945 3777 } 946 3778 // t.CreatedAt (string) (string) 947 3779 case "createdAt":
+21 -7
pkg/atproto/client.go
··· 13 13 14 14 "github.com/bluesky-social/indigo/atproto/atclient" 15 15 indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 + lexutil "github.com/bluesky-social/indigo/lex/util" 17 + "github.com/ipfs/go-cid" 16 18 ) 17 19 18 20 // Sentinel errors ··· 301 303 } 302 304 303 305 // UploadBlob uploads binary data to the PDS and returns a blob reference 304 - func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) { 306 + func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*lexutil.LexBlob, error) { 305 307 // Use session provider (locked OAuth with DPoP) - prevents nonce races 306 308 if c.sessionProvider != nil { 307 309 var result struct { ··· 310 312 311 313 err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error { 312 314 apiClient := session.APIClient() 313 - // IMPORTANT: Use io.Reader for blob uploads 314 - // LexDo JSON-encodes []byte (base64), but streams io.Reader as raw bytes 315 - // Use the actual MIME type so PDS can validate against blob:image/* scope 316 315 return apiClient.LexDo(ctx, 317 316 "POST", 318 317 mimeType, 319 318 "com.atproto.repo.uploadBlob", 320 319 nil, 321 - bytes.NewReader(data), 320 + data, 322 321 &result, 323 322 ) 324 323 }) ··· 326 325 return nil, fmt.Errorf("uploadBlob failed: %w", err) 327 326 } 328 327 329 - return &result.Blob, nil 328 + return atProtoBlobRefToLexBlob(&result.Blob) 330 329 } 331 330 332 331 // Basic Auth (app passwords) ··· 357 356 return nil, fmt.Errorf("failed to decode response: %w", err) 358 357 } 359 358 360 - return &result.Blob, nil 359 + return atProtoBlobRefToLexBlob(&result.Blob) 360 + } 361 + 362 + // atProtoBlobRefToLexBlob converts an ATProtoBlobRef to a lexutil.LexBlob 363 + func atProtoBlobRefToLexBlob(ref *ATProtoBlobRef) (*lexutil.LexBlob, error) { 364 + // Parse the CID string from the $link field 365 + c, err := cid.Decode(ref.Ref.Link) 366 + if err != nil { 367 + return nil, fmt.Errorf("failed to parse blob CID %q: %w", ref.Ref.Link, err) 368 + } 369 + 370 + return &lexutil.LexBlob{ 371 + Ref: lexutil.LexLink(c), 372 + MimeType: ref.MimeType, 373 + Size: ref.Size, 374 + }, nil 361 375 } 362 376 363 377 // GetBlob downloads a blob by its CID from the PDS
+8 -6
pkg/atproto/client_test.go
··· 386 386 t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType) 387 387 } 388 388 389 - // Send response 389 + // Send response - use a valid CIDv1 in base32 format 390 390 response := `{ 391 391 "blob": { 392 392 "$type": "blob", 393 - "ref": {"$link": "bafytest123"}, 393 + "ref": {"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}, 394 394 "mimeType": "application/octet-stream", 395 395 "size": 17 396 396 } ··· 406 406 t.Fatalf("UploadBlob() error = %v", err) 407 407 } 408 408 409 - if blobRef.Type != "blob" { 410 - t.Errorf("Type = %v, want blob", blobRef.Type) 409 + if blobRef.MimeType != mimeType { 410 + t.Errorf("MimeType = %v, want %v", blobRef.MimeType, mimeType) 411 411 } 412 412 413 - if blobRef.Ref.Link != "bafytest123" { 414 - t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link) 413 + // LexBlob.Ref is a LexLink (cid.Cid alias), use .String() to get the CID string 414 + expectedCID := "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 415 + if blobRef.Ref.String() != expectedCID { 416 + t.Errorf("Ref.String() = %v, want %v", blobRef.Ref.String(), expectedCID) 415 417 } 416 418 417 419 if blobRef.Size != 17 {
+255 -11
pkg/atproto/generate.go
··· 3 3 4 4 package main 5 5 6 - // CBOR Code Generator 6 + // Lexicon and CBOR Code Generator 7 7 // 8 - // This generates optimized CBOR marshaling code for ATProto records. 8 + // This generates: 9 + // 1. Go types from lexicon JSON files (via lex/lexgen library) 10 + // 2. CBOR marshaling code for ATProto records (via cbor-gen) 11 + // 3. Type registration for lexutil (register.go) 9 12 // 10 13 // Usage: 11 14 // go generate ./pkg/atproto/... 12 15 // 13 - // This creates pkg/atproto/cbor_gen.go which should be committed to git. 14 - // Only re-run when you modify types in pkg/atproto/types.go 15 - // 16 - // The //go:generate directive is in lexicon.go 16 + // Key insight: We use RegisterLexiconTypeID: false to avoid generating init() 17 + // blocks that require CBORMarshaler. This breaks the circular dependency between 18 + // lexgen and cbor-gen. See: https://github.com/bluesky-social/indigo/issues/931 19 + 20 + import ( 21 + "bytes" 22 + "encoding/json" 23 + "fmt" 24 + "os" 25 + "os/exec" 26 + "path/filepath" 27 + "strings" 28 + 29 + "github.com/bluesky-social/indigo/atproto/lexicon" 30 + "github.com/bluesky-social/indigo/lex/lexgen" 31 + "golang.org/x/tools/imports" 32 + ) 33 + 34 + func main() { 35 + // Find repo root 36 + repoRoot, err := findRepoRoot() 37 + if err != nil { 38 + fmt.Printf("failed to find repo root: %v\n", err) 39 + os.Exit(1) 40 + } 41 + 42 + pkgDir := filepath.Join(repoRoot, "pkg/atproto") 43 + lexDir := filepath.Join(repoRoot, "lexicons") 44 + 45 + // Step 0: Clean up old register.go to avoid conflicts 46 + // (It will be regenerated at the end) 47 + os.Remove(filepath.Join(pkgDir, "register.go")) 48 + 49 + // Step 1: Load all lexicon schemas into catalog (for cross-references) 50 + fmt.Println("Loading lexicons...") 51 + cat := lexicon.NewBaseCatalog() 52 + if err := cat.LoadDirectory(lexDir); err != nil { 53 + fmt.Printf("failed to load lexicons: %v\n", err) 54 + os.Exit(1) 55 + } 56 + 57 + // Step 2: Generate Go code for each lexicon file 58 + fmt.Println("Running lexgen...") 59 + config := &lexgen.GenConfig{ 60 + RegisterLexiconTypeID: false, // KEY: no init() blocks generated 61 + UnknownType: "map-string-any", 62 + WarningText: "Code generated by generate.go; DO NOT EDIT.", 63 + } 64 + 65 + // Track generated types for register.go 66 + var registeredTypes []typeInfo 67 + 68 + // Walk lexicon directory and generate code for each file 69 + err = filepath.Walk(lexDir, func(path string, info os.FileInfo, err error) error { 70 + if err != nil { 71 + return err 72 + } 73 + if info.IsDir() || !strings.HasSuffix(path, ".json") { 74 + return nil 75 + } 76 + 77 + // Load and parse the schema file 78 + data, err := os.ReadFile(path) 79 + if err != nil { 80 + return fmt.Errorf("failed to read %s: %w", path, err) 81 + } 82 + 83 + var sf lexicon.SchemaFile 84 + if err := json.Unmarshal(data, &sf); err != nil { 85 + return fmt.Errorf("failed to parse %s: %w", path, err) 86 + } 87 + 88 + if err := sf.FinishParse(); err != nil { 89 + return fmt.Errorf("failed to finish parse %s: %w", path, err) 90 + } 91 + 92 + // Flatten the schema 93 + flat, err := lexgen.FlattenSchemaFile(&sf) 94 + if err != nil { 95 + return fmt.Errorf("failed to flatten schema %s: %w", path, err) 96 + } 97 + 98 + // Generate code 99 + var buf bytes.Buffer 100 + gen := &lexgen.CodeGenerator{ 101 + Config: config, 102 + Lex: flat, 103 + Cat: &cat, 104 + Out: &buf, 105 + } 106 + 107 + if err := gen.WriteLexicon(); err != nil { 108 + return fmt.Errorf("failed to generate code for %s: %w", path, err) 109 + } 110 + 111 + // Fix package name: lexgen generates "ioatcr" but we want "atproto" 112 + code := bytes.Replace(buf.Bytes(), []byte("package ioatcr"), []byte("package atproto"), 1) 113 + 114 + // Format with goimports 115 + fileName := gen.FileName() 116 + formatted, err := imports.Process(fileName, code, nil) 117 + if err != nil { 118 + // Write unformatted for debugging 119 + outPath := filepath.Join(pkgDir, fileName) 120 + os.WriteFile(outPath+".broken", code, 0644) 121 + return fmt.Errorf("failed to format %s: %w (wrote to %s.broken)", fileName, err, outPath) 122 + } 123 + 124 + // Write output file 125 + outPath := filepath.Join(pkgDir, fileName) 126 + if err := os.WriteFile(outPath, formatted, 0644); err != nil { 127 + return fmt.Errorf("failed to write %s: %w", outPath, err) 128 + } 129 + 130 + fmt.Printf(" Generated %s\n", fileName) 131 + 132 + // Track type for registration - compute type name from NSID 133 + typeName := nsidToTypeName(sf.ID) 134 + registeredTypes = append(registeredTypes, typeInfo{ 135 + NSID: sf.ID, 136 + TypeName: typeName, 137 + }) 138 + 139 + return nil 140 + }) 141 + if err != nil { 142 + fmt.Printf("lexgen failed: %v\n", err) 143 + os.Exit(1) 144 + } 145 + 146 + // Step 3: Run cbor-gen via exec.Command 147 + // This must be a separate process so it can compile the freshly generated types 148 + fmt.Println("Running cbor-gen...") 149 + if err := runCborGen(repoRoot, pkgDir); err != nil { 150 + fmt.Printf("cbor-gen failed: %v\n", err) 151 + os.Exit(1) 152 + } 153 + 154 + // Step 4: Generate register.go 155 + fmt.Println("Generating register.go...") 156 + if err := generateRegisterFile(pkgDir, registeredTypes); err != nil { 157 + fmt.Printf("failed to generate register.go: %v\n", err) 158 + os.Exit(1) 159 + } 160 + 161 + fmt.Println("Code generation complete!") 162 + } 163 + 164 + type typeInfo struct { 165 + NSID string 166 + TypeName string 167 + } 168 + 169 + // nsidToTypeName converts an NSID to a Go type name 170 + // io.atcr.manifest โ†’ Manifest 171 + // io.atcr.hold.captain โ†’ HoldCaptain 172 + // io.atcr.sailor.profile โ†’ SailorProfile 173 + func nsidToTypeName(nsid string) string { 174 + parts := strings.Split(nsid, ".") 175 + if len(parts) < 3 { 176 + return "" 177 + } 178 + // Skip the first two parts (authority, e.g., "io.atcr") 179 + // and capitalize each remaining part 180 + var result string 181 + for _, part := range parts[2:] { 182 + if len(part) > 0 { 183 + result += strings.ToUpper(part[:1]) + part[1:] 184 + } 185 + } 186 + return result 187 + } 188 + 189 + func runCborGen(repoRoot, pkgDir string) error { 190 + // Create a temporary Go file that runs cbor-gen 191 + cborGenCode := `//go:build ignore 192 + 193 + package main 17 194 18 195 import ( 19 196 "fmt" ··· 25 202 ) 26 203 27 204 func main() { 28 - // Generate map-style encoders for CrewRecord, CaptainRecord, LayerRecord, and TangledProfileRecord 29 205 if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto", 30 - atproto.CrewRecord{}, 31 - atproto.CaptainRecord{}, 32 - atproto.LayerRecord{}, 206 + // Manifest types 207 + atproto.Manifest{}, 208 + atproto.Manifest_BlobReference{}, 209 + atproto.Manifest_ManifestReference{}, 210 + atproto.Manifest_Platform{}, 211 + atproto.Manifest_Annotations{}, 212 + atproto.Manifest_BlobReference_Annotations{}, 213 + atproto.Manifest_ManifestReference_Annotations{}, 214 + // Tag 215 + atproto.Tag{}, 216 + // Sailor types 217 + atproto.SailorProfile{}, 218 + atproto.SailorStar{}, 219 + atproto.SailorStar_Subject{}, 220 + // Hold types 221 + atproto.HoldCaptain{}, 222 + atproto.HoldCrew{}, 223 + atproto.HoldLayer{}, 224 + // External types 33 225 atproto.TangledProfileRecord{}, 34 226 ); err != nil { 35 - fmt.Printf("Failed to generate CBOR encoders: %v\n", err) 227 + fmt.Printf("cbor-gen failed: %v\n", err) 36 228 os.Exit(1) 37 229 } 38 230 } 231 + ` 232 + 233 + // Write temp file 234 + tmpFile := filepath.Join(pkgDir, "cborgen_tmp.go") 235 + if err := os.WriteFile(tmpFile, []byte(cborGenCode), 0644); err != nil { 236 + return fmt.Errorf("failed to write temp cbor-gen file: %w", err) 237 + } 238 + defer os.Remove(tmpFile) 239 + 240 + // Run it 241 + cmd := exec.Command("go", "run", tmpFile) 242 + cmd.Dir = pkgDir 243 + cmd.Stdout = os.Stdout 244 + cmd.Stderr = os.Stderr 245 + return cmd.Run() 246 + } 247 + 248 + func generateRegisterFile(pkgDir string, types []typeInfo) error { 249 + var buf bytes.Buffer 250 + 251 + buf.WriteString("// Code generated by generate.go; DO NOT EDIT.\n\n") 252 + buf.WriteString("package atproto\n\n") 253 + buf.WriteString("import lexutil \"github.com/bluesky-social/indigo/lex/util\"\n\n") 254 + buf.WriteString("func init() {\n") 255 + 256 + for _, t := range types { 257 + fmt.Fprintf(&buf, "\tlexutil.RegisterType(%q, &%s{})\n", t.NSID, t.TypeName) 258 + } 259 + 260 + buf.WriteString("}\n") 261 + 262 + outPath := filepath.Join(pkgDir, "register.go") 263 + return os.WriteFile(outPath, buf.Bytes(), 0644) 264 + } 265 + 266 + func findRepoRoot() (string, error) { 267 + dir, err := os.Getwd() 268 + if err != nil { 269 + return "", err 270 + } 271 + 272 + for { 273 + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { 274 + return dir, nil 275 + } 276 + parent := filepath.Dir(dir) 277 + if parent == dir { 278 + return "", fmt.Errorf("go.mod not found") 279 + } 280 + dir = parent 281 + } 282 + }
+24
pkg/atproto/holdcaptain.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + // Lexicon schema: io.atcr.hold.captain 4 + 5 + package atproto 6 + 7 + // Represents the hold's ownership and metadata. Stored as a singleton record at rkey 'self' in the hold's embedded PDS. 8 + type HoldCaptain struct { 9 + LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.captain"` 10 + // allowAllCrew: Allow any authenticated user to register as crew 11 + AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` 12 + // deployedAt: RFC3339 timestamp of when the hold was deployed 13 + DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` 14 + // enableBlueskyPosts: Enable Bluesky posts when manifests are pushed 15 + EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` 16 + // owner: DID of the hold owner 17 + Owner string `json:"owner" cborgen:"owner"` 18 + // provider: Deployment provider (e.g., fly.io, aws, etc.) 19 + Provider *string `json:"provider,omitempty" cborgen:"provider,omitempty"` 20 + // public: Whether this hold allows public blob reads (pulls) without authentication 21 + Public bool `json:"public" cborgen:"public"` 22 + // region: S3 region where blobs are stored 23 + Region *string `json:"region,omitempty" cborgen:"region,omitempty"` 24 + }
+18
pkg/atproto/holdcrew.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + // Lexicon schema: io.atcr.hold.crew 4 + 5 + package atproto 6 + 7 + // Crew member in a hold's embedded PDS. Grants access permissions to push blobs to the hold. Stored in the hold's embedded PDS (one record per member). 8 + type HoldCrew struct { 9 + LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.crew"` 10 + // addedAt: RFC3339 timestamp of when the member was added 11 + AddedAt string `json:"addedAt" cborgen:"addedAt"` 12 + // member: DID of the crew member 13 + Member string `json:"member" cborgen:"member"` 14 + // permissions: Specific permissions granted to this member 15 + Permissions []string `json:"permissions" cborgen:"permissions"` 16 + // role: Member's role in the hold 17 + Role string `json:"role" cborgen:"role"` 18 + }
+24
pkg/atproto/holdlayer.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + // Lexicon schema: io.atcr.hold.layer 4 + 5 + package atproto 6 + 7 + // Represents metadata about a container layer stored in the hold. Stored in the hold's embedded PDS for tracking and analytics. 8 + type HoldLayer struct { 9 + LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.layer"` 10 + // createdAt: RFC3339 timestamp of when the layer was uploaded 11 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 12 + // digest: Layer digest (e.g., sha256:abc123...) 13 + Digest string `json:"digest" cborgen:"digest"` 14 + // mediaType: Media type (e.g., application/vnd.oci.image.layer.v1.tar+gzip) 15 + MediaType string `json:"mediaType" cborgen:"mediaType"` 16 + // repository: Repository this layer belongs to 17 + Repository string `json:"repository" cborgen:"repository"` 18 + // size: Size in bytes 19 + Size int64 `json:"size" cborgen:"size"` 20 + // userDid: DID of user who uploaded this layer 21 + UserDid string `json:"userDid" cborgen:"userDid"` 22 + // userHandle: Handle of user (for display purposes) 23 + UserHandle string `json:"userHandle" cborgen:"userHandle"` 24 + }
+16 -40
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 + 21 24 // HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model 22 25 // Stored in owner's PDS for BYOS holds 23 26 HoldCrewCollection = "io.atcr.hold.crew" ··· 38 41 // TangledProfileCollection is the collection name for tangled profiles 39 42 // Stored in hold's embedded PDS (singleton record at rkey "self") 40 43 TangledProfileCollection = "sh.tangled.actor.profile" 44 + 45 + // BskyPostCollection is the collection name for Bluesky posts 46 + BskyPostCollection = "app.bsky.feed.post" 41 47 42 48 // BskyPostCollection is the collection name for Bluesky posts 43 49 BskyPostCollection = "app.bsky.feed.post" ··· 47 53 48 54 // StarCollection is the collection name for repository stars 49 55 StarCollection = "io.atcr.sailor.star" 50 - 51 - // RepoPageCollection is the collection name for repository page metadata 52 - // Stored in user's PDS with rkey = repository name 53 - RepoPageCollection = "io.atcr.repo.page" 54 56 ) 55 57 56 58 // ManifestRecord represents a container image manifest stored in ATProto ··· 310 312 CreatedAt time.Time `json:"createdAt"` 311 313 } 312 314 315 + // NewHoldRecord creates a new hold record 316 + func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord { 317 + return &HoldRecord{ 318 + Type: HoldCollection, 319 + Endpoint: endpoint, 320 + Owner: owner, 321 + Public: public, 322 + CreatedAt: time.Now(), 323 + } 324 + } 313 325 314 326 // SailorProfileRecord represents a user's profile with registry preferences 315 327 // Stored in the user's PDS to configure default hold and other settings ··· 336 348 return &SailorProfileRecord{ 337 349 Type: SailorProfileCollection, 338 350 DefaultHold: defaultHold, 339 - CreatedAt: now, 340 - UpdatedAt: now, 341 - } 342 - } 343 - 344 - // RepoPageRecord represents repository page metadata (description + avatar) 345 - // Stored in the user's PDS with rkey = repository name 346 - // Users can edit this directly in their PDS to customize their repository page 347 - type RepoPageRecord struct { 348 - // Type should be "io.atcr.repo.page" 349 - Type string `json:"$type"` 350 - 351 - // Repository is the name of the repository (e.g., "myapp") 352 - Repository string `json:"repository"` 353 - 354 - // Description is the markdown README/description content 355 - Description string `json:"description,omitempty"` 356 - 357 - // Avatar is the repository avatar/icon blob reference 358 - Avatar *ATProtoBlobRef `json:"avatar,omitempty"` 359 - 360 - // CreatedAt timestamp 361 - CreatedAt time.Time `json:"createdAt"` 362 - 363 - // UpdatedAt timestamp 364 - UpdatedAt time.Time `json:"updatedAt"` 365 - } 366 - 367 - // NewRepoPageRecord creates a new repo page record 368 - func NewRepoPageRecord(repository, description string, avatar *ATProtoBlobRef) *RepoPageRecord { 369 - now := time.Now() 370 - return &RepoPageRecord{ 371 - Type: RepoPageCollection, 372 - Repository: repository, 373 - Description: description, 374 - Avatar: avatar, 375 351 CreatedAt: now, 376 352 UpdatedAt: now, 377 353 }
+18
pkg/atproto/lexicon_embedded.go
··· 1 + package atproto 2 + 3 + // This file contains ATProto record types that are NOT generated from our lexicons. 4 + // These are either external schemas or special types that require manual definition. 5 + 6 + // TangledProfileRecord represents a Tangled profile for the hold 7 + // Collection: sh.tangled.actor.profile (external schema - not controlled by ATCR) 8 + // Stored in hold's embedded PDS (singleton record at rkey "self") 9 + // Uses CBOR encoding for efficient storage in hold's carstore 10 + type TangledProfileRecord struct { 11 + Type string `json:"$type" cborgen:"$type"` 12 + Links []string `json:"links" cborgen:"links"` 13 + Stats []string `json:"stats" cborgen:"stats"` 14 + Bluesky bool `json:"bluesky" cborgen:"bluesky"` 15 + Location string `json:"location" cborgen:"location"` 16 + Description string `json:"description" cborgen:"description"` 17 + PinnedRepositories []string `json:"pinnedRepositories" cborgen:"pinnedRepositories"` 18 + }
+360
pkg/atproto/lexicon_helpers.go
··· 1 + package atproto 2 + 3 + //go:generate go run generate.go 4 + 5 + import ( 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + // Collection names for ATProto records 14 + const ( 15 + // ManifestCollection is the collection name for container manifests 16 + ManifestCollection = "io.atcr.manifest" 17 + 18 + // TagCollection is the collection name for image tags 19 + TagCollection = "io.atcr.tag" 20 + 21 + // HoldCollection is the collection name for storage holds (BYOS) - LEGACY 22 + HoldCollection = "io.atcr.hold" 23 + 24 + // HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model 25 + // Stored in owner's PDS for BYOS holds 26 + HoldCrewCollection = "io.atcr.hold.crew" 27 + 28 + // CaptainCollection is the collection name for captain records (hold ownership) - EMBEDDED PDS model 29 + // Stored in hold's embedded PDS (singleton record at rkey "self") 30 + CaptainCollection = "io.atcr.hold.captain" 31 + 32 + // CrewCollection is the collection name for crew records (access control) - EMBEDDED PDS model 33 + // Stored in hold's embedded PDS (one record per member) 34 + // Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS) 35 + CrewCollection = "io.atcr.hold.crew" 36 + 37 + // LayerCollection is the collection name for container layer metadata 38 + // Stored in hold's embedded PDS to track which layers are stored 39 + LayerCollection = "io.atcr.hold.layer" 40 + 41 + // TangledProfileCollection is the collection name for tangled profiles 42 + // Stored in hold's embedded PDS (singleton record at rkey "self") 43 + TangledProfileCollection = "sh.tangled.actor.profile" 44 + 45 + // BskyPostCollection is the collection name for Bluesky posts 46 + BskyPostCollection = "app.bsky.feed.post" 47 + 48 + // SailorProfileCollection is the collection name for user profiles 49 + SailorProfileCollection = "io.atcr.sailor.profile" 50 + 51 + // StarCollection is the collection name for repository stars 52 + StarCollection = "io.atcr.sailor.star" 53 + ) 54 + 55 + // NewManifestRecord creates a new manifest record from OCI manifest JSON 56 + func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest, error) { 57 + // Parse the OCI manifest 58 + var ociData struct { 59 + SchemaVersion int `json:"schemaVersion"` 60 + MediaType string `json:"mediaType"` 61 + Config json.RawMessage `json:"config,omitempty"` 62 + Layers []json.RawMessage `json:"layers,omitempty"` 63 + Manifests []json.RawMessage `json:"manifests,omitempty"` 64 + Subject json.RawMessage `json:"subject,omitempty"` 65 + Annotations map[string]string `json:"annotations,omitempty"` 66 + } 67 + 68 + if err := json.Unmarshal(ociManifest, &ociData); err != nil { 69 + return nil, err 70 + } 71 + 72 + // Detect manifest type based on media type 73 + isManifestList := strings.Contains(ociData.MediaType, "manifest.list") || 74 + strings.Contains(ociData.MediaType, "image.index") 75 + 76 + // Validate: must have either (config+layers) OR (manifests), never both 77 + hasImageFields := len(ociData.Config) > 0 || len(ociData.Layers) > 0 78 + hasIndexFields := len(ociData.Manifests) > 0 79 + 80 + if hasImageFields && hasIndexFields { 81 + return nil, fmt.Errorf("manifest cannot have both image fields (config/layers) and index fields (manifests)") 82 + } 83 + if !hasImageFields && !hasIndexFields { 84 + return nil, fmt.Errorf("manifest must have either image fields (config/layers) or index fields (manifests)") 85 + } 86 + 87 + record := &Manifest{ 88 + LexiconTypeID: ManifestCollection, 89 + Repository: repository, 90 + Digest: digest, 91 + MediaType: ociData.MediaType, 92 + SchemaVersion: int64(ociData.SchemaVersion), 93 + // ManifestBlob will be set by the caller after uploading to blob storage 94 + CreatedAt: time.Now().Format(time.RFC3339), 95 + } 96 + 97 + // Handle annotations - Manifest_Annotations is an empty struct in generated code 98 + // We don't copy ociData.Annotations since the generated type doesn't support arbitrary keys 99 + 100 + if isManifestList { 101 + // Parse manifest list/index 102 + record.Manifests = make([]Manifest_ManifestReference, len(ociData.Manifests)) 103 + for i, m := range ociData.Manifests { 104 + var ref struct { 105 + MediaType string `json:"mediaType"` 106 + Digest string `json:"digest"` 107 + Size int64 `json:"size"` 108 + Platform *Manifest_Platform `json:"platform,omitempty"` 109 + Annotations map[string]string `json:"annotations,omitempty"` 110 + } 111 + if err := json.Unmarshal(m, &ref); err != nil { 112 + return nil, fmt.Errorf("failed to parse manifest reference %d: %w", i, err) 113 + } 114 + record.Manifests[i] = Manifest_ManifestReference{ 115 + MediaType: ref.MediaType, 116 + Digest: ref.Digest, 117 + Size: ref.Size, 118 + Platform: ref.Platform, 119 + } 120 + } 121 + } else { 122 + // Parse image manifest 123 + if len(ociData.Config) > 0 { 124 + var config Manifest_BlobReference 125 + if err := json.Unmarshal(ociData.Config, &config); err != nil { 126 + return nil, fmt.Errorf("failed to parse config: %w", err) 127 + } 128 + record.Config = &config 129 + } 130 + 131 + // Parse layers 132 + record.Layers = make([]Manifest_BlobReference, len(ociData.Layers)) 133 + for i, layer := range ociData.Layers { 134 + if err := json.Unmarshal(layer, &record.Layers[i]); err != nil { 135 + return nil, fmt.Errorf("failed to parse layer %d: %w", i, err) 136 + } 137 + } 138 + } 139 + 140 + // Parse subject if present (works for both types) 141 + if len(ociData.Subject) > 0 { 142 + var subject Manifest_BlobReference 143 + if err := json.Unmarshal(ociData.Subject, &subject); err != nil { 144 + return nil, err 145 + } 146 + record.Subject = &subject 147 + } 148 + 149 + return record, nil 150 + } 151 + 152 + // NewTagRecord creates a new tag record with manifest AT-URI 153 + // did: The DID of the user (e.g., "did:plc:xyz123") 154 + // repository: The repository name (e.g., "myapp") 155 + // tag: The tag name (e.g., "latest", "v1.0.0") 156 + // manifestDigest: The manifest digest (e.g., "sha256:abc123...") 157 + func NewTagRecord(did, repository, tag, manifestDigest string) *Tag { 158 + // Build AT-URI for the manifest 159 + // Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix> 160 + manifestURI := BuildManifestURI(did, manifestDigest) 161 + 162 + return &Tag{ 163 + LexiconTypeID: TagCollection, 164 + Repository: repository, 165 + Tag: tag, 166 + Manifest: &manifestURI, 167 + // Note: ManifestDigest is not set for new records (only for backward compat with old records) 168 + CreatedAt: time.Now().Format(time.RFC3339), 169 + } 170 + } 171 + 172 + // NewSailorProfileRecord creates a new sailor profile record 173 + func NewSailorProfileRecord(defaultHold string) *SailorProfile { 174 + now := time.Now().Format(time.RFC3339) 175 + var holdPtr *string 176 + if defaultHold != "" { 177 + holdPtr = &defaultHold 178 + } 179 + return &SailorProfile{ 180 + LexiconTypeID: SailorProfileCollection, 181 + DefaultHold: holdPtr, 182 + CreatedAt: now, 183 + UpdatedAt: &now, 184 + } 185 + } 186 + 187 + // NewStarRecord creates a new star record 188 + func NewStarRecord(ownerDID, repository string) *SailorStar { 189 + return &SailorStar{ 190 + LexiconTypeID: StarCollection, 191 + Subject: SailorStar_Subject{ 192 + Did: ownerDID, 193 + Repository: repository, 194 + }, 195 + CreatedAt: time.Now().Format(time.RFC3339), 196 + } 197 + } 198 + 199 + // NewLayerRecord creates a new layer record 200 + func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *HoldLayer { 201 + return &HoldLayer{ 202 + LexiconTypeID: LayerCollection, 203 + Digest: digest, 204 + Size: size, 205 + MediaType: mediaType, 206 + Repository: repository, 207 + UserDid: userDID, 208 + UserHandle: userHandle, 209 + CreatedAt: time.Now().Format(time.RFC3339), 210 + } 211 + } 212 + 213 + // StarRecordKey generates a record key for a star 214 + // Uses a simple hash to ensure uniqueness and prevent duplicate stars 215 + func StarRecordKey(ownerDID, repository string) string { 216 + // Use base64 encoding of "ownerDID/repository" as the record key 217 + // This is deterministic and prevents duplicate stars 218 + combined := ownerDID + "/" + repository 219 + return base64.RawURLEncoding.EncodeToString([]byte(combined)) 220 + } 221 + 222 + // ParseStarRecordKey decodes a star record key back to ownerDID and repository 223 + func ParseStarRecordKey(rkey string) (ownerDID, repository string, err error) { 224 + decoded, err := base64.RawURLEncoding.DecodeString(rkey) 225 + if err != nil { 226 + return "", "", fmt.Errorf("failed to decode star rkey: %w", err) 227 + } 228 + 229 + parts := strings.SplitN(string(decoded), "/", 2) 230 + if len(parts) != 2 { 231 + return "", "", fmt.Errorf("invalid star rkey format: %s", string(decoded)) 232 + } 233 + 234 + return parts[0], parts[1], nil 235 + } 236 + 237 + // ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID 238 + // This ensures that different representations of the same hold are deduplicated: 239 + // - http://172.28.0.3:8080 โ†’ did:web:172.28.0.3:8080 240 + // - http://hold01.atcr.io โ†’ did:web:hold01.atcr.io 241 + // - https://hold01.atcr.io โ†’ did:web:hold01.atcr.io 242 + // - did:web:hold01.atcr.io โ†’ did:web:hold01.atcr.io (passthrough) 243 + func ResolveHoldDIDFromURL(holdURL string) string { 244 + // Handle empty URLs 245 + if holdURL == "" { 246 + return "" 247 + } 248 + 249 + // If already a DID, return as-is 250 + if IsDID(holdURL) { 251 + return holdURL 252 + } 253 + 254 + // Parse URL to get hostname 255 + holdURL = strings.TrimPrefix(holdURL, "http://") 256 + holdURL = strings.TrimPrefix(holdURL, "https://") 257 + holdURL = strings.TrimSuffix(holdURL, "/") 258 + 259 + // Extract hostname (remove path if present) 260 + parts := strings.Split(holdURL, "/") 261 + hostname := parts[0] 262 + 263 + // Convert to did:web 264 + // did:web uses hostname directly (port included if non-standard) 265 + return "did:web:" + hostname 266 + } 267 + 268 + // IsDID checks if a string is a DID (starts with "did:") 269 + func IsDID(s string) bool { 270 + return len(s) > 4 && s[:4] == "did:" 271 + } 272 + 273 + // RepositoryTagToRKey converts a repository and tag to an ATProto record key 274 + // ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$ 275 + func RepositoryTagToRKey(repository, tag string) string { 276 + // Combine repository and tag to create a unique key 277 + // Replace invalid characters: slashes become tildes (~) 278 + // We use tilde instead of dash to avoid ambiguity with repository names that contain hyphens 279 + key := fmt.Sprintf("%s_%s", repository, tag) 280 + 281 + // Replace / with ~ (slash not allowed in rkeys, tilde is allowed and unlikely in repo names) 282 + key = strings.ReplaceAll(key, "/", "~") 283 + 284 + return key 285 + } 286 + 287 + // RKeyToRepositoryTag converts an ATProto record key back to repository and tag 288 + // This is the inverse of RepositoryTagToRKey 289 + // Note: If the tag contains underscores, this will split on the LAST underscore 290 + func RKeyToRepositoryTag(rkey string) (repository, tag string) { 291 + // Find the last underscore to split repository and tag 292 + lastUnderscore := strings.LastIndex(rkey, "_") 293 + if lastUnderscore == -1 { 294 + // No underscore found - treat entire string as tag with empty repository 295 + return "", rkey 296 + } 297 + 298 + repository = rkey[:lastUnderscore] 299 + tag = rkey[lastUnderscore+1:] 300 + 301 + // Convert tildes back to slashes in repository (tilde was used to encode slashes) 302 + repository = strings.ReplaceAll(repository, "~", "/") 303 + 304 + return repository, tag 305 + } 306 + 307 + // BuildManifestURI creates an AT-URI for a manifest record 308 + // did: The DID of the user (e.g., "did:plc:xyz123") 309 + // manifestDigest: The manifest digest (e.g., "sha256:abc123...") 310 + // Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>" 311 + func BuildManifestURI(did, manifestDigest string) string { 312 + // Remove the "sha256:" prefix from the digest to get the rkey 313 + rkey := strings.TrimPrefix(manifestDigest, "sha256:") 314 + return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey) 315 + } 316 + 317 + // ParseManifestURI extracts the digest from a manifest AT-URI 318 + // manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>" 319 + // Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...") 320 + func ParseManifestURI(manifestURI string) (string, error) { 321 + // Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey> 322 + if !strings.HasPrefix(manifestURI, "at://") { 323 + return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'") 324 + } 325 + 326 + // Remove "at://" prefix 327 + remainder := strings.TrimPrefix(manifestURI, "at://") 328 + 329 + // Split by "/" 330 + parts := strings.Split(remainder, "/") 331 + if len(parts) != 3 { 332 + return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts)) 333 + } 334 + 335 + // Validate collection 336 + if parts[1] != ManifestCollection { 337 + return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1]) 338 + } 339 + 340 + // The rkey is the digest without the "sha256:" prefix 341 + // Add it back to get the full digest 342 + rkey := parts[2] 343 + return "sha256:" + rkey, nil 344 + } 345 + 346 + // GetManifestDigest extracts the digest from a Tag, preferring the manifest field 347 + // Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...") 348 + func (t *Tag) GetManifestDigest() (string, error) { 349 + // Prefer the new manifest field 350 + if t.Manifest != nil && *t.Manifest != "" { 351 + return ParseManifestURI(*t.Manifest) 352 + } 353 + 354 + // Fall back to the legacy manifestDigest field 355 + if t.ManifestDigest != nil && *t.ManifestDigest != "" { 356 + return *t.ManifestDigest, nil 357 + } 358 + 359 + return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field") 360 + }
+109 -215
pkg/atproto/lexicon_test.go
··· 104 104 digest string 105 105 ociManifest string 106 106 wantErr bool 107 - checkFunc func(*testing.T, *ManifestRecord) 107 + checkFunc func(*testing.T, *Manifest) 108 108 }{ 109 109 { 110 110 name: "valid OCI manifest", ··· 112 112 digest: "sha256:abc123", 113 113 ociManifest: validOCIManifest, 114 114 wantErr: false, 115 - checkFunc: func(t *testing.T, record *ManifestRecord) { 116 - if record.Type != ManifestCollection { 117 - t.Errorf("Type = %v, want %v", record.Type, ManifestCollection) 115 + checkFunc: func(t *testing.T, record *Manifest) { 116 + if record.LexiconTypeID != ManifestCollection { 117 + t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, ManifestCollection) 118 118 } 119 119 if record.Repository != "myapp" { 120 120 t.Errorf("Repository = %v, want myapp", record.Repository) ··· 143 143 if record.Layers[1].Digest != "sha256:layer2" { 144 144 t.Errorf("Layers[1].Digest = %v, want sha256:layer2", record.Layers[1].Digest) 145 145 } 146 - if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" { 147 - t.Errorf("Annotations missing expected key") 148 - } 149 - if record.CreatedAt.IsZero() { 150 - t.Error("CreatedAt should not be zero") 146 + // Note: Annotations are not copied to generated type (empty struct) 147 + if record.CreatedAt == "" { 148 + t.Error("CreatedAt should not be empty") 151 149 } 152 150 if record.Subject != nil { 153 151 t.Error("Subject should be nil") ··· 160 158 digest: "sha256:abc123", 161 159 ociManifest: manifestWithSubject, 162 160 wantErr: false, 163 - checkFunc: func(t *testing.T, record *ManifestRecord) { 161 + checkFunc: func(t *testing.T, record *Manifest) { 164 162 if record.Subject == nil { 165 163 t.Fatal("Subject should not be nil") 166 164 } ··· 192 190 digest: "sha256:multiarch", 193 191 ociManifest: manifestList, 194 192 wantErr: false, 195 - checkFunc: func(t *testing.T, record *ManifestRecord) { 193 + checkFunc: func(t *testing.T, record *Manifest) { 196 194 if record.MediaType != "application/vnd.oci.image.index.v1+json" { 197 195 t.Errorf("MediaType = %v, want application/vnd.oci.image.index.v1+json", record.MediaType) 198 196 } ··· 219 217 if record.Manifests[0].Platform.Architecture != "amd64" { 220 218 t.Errorf("Platform.Architecture = %v, want amd64", record.Manifests[0].Platform.Architecture) 221 219 } 222 - if record.Manifests[0].Platform.OS != "linux" { 223 - t.Errorf("Platform.OS = %v, want linux", record.Manifests[0].Platform.OS) 220 + if record.Manifests[0].Platform.Os != "linux" { 221 + t.Errorf("Platform.Os = %v, want linux", record.Manifests[0].Platform.Os) 224 222 } 225 223 226 224 // Check second manifest (arm64) ··· 230 228 if record.Manifests[1].Platform.Architecture != "arm64" { 231 229 t.Errorf("Platform.Architecture = %v, want arm64", record.Manifests[1].Platform.Architecture) 232 230 } 233 - if record.Manifests[1].Platform.Variant != "v8" { 231 + if record.Manifests[1].Platform.Variant == nil || *record.Manifests[1].Platform.Variant != "v8" { 234 232 t.Errorf("Platform.Variant = %v, want v8", record.Manifests[1].Platform.Variant) 235 233 } 236 234 }, ··· 268 266 269 267 func TestNewTagRecord(t *testing.T) { 270 268 did := "did:plc:test123" 271 - before := time.Now() 269 + // Truncate to second precision since RFC3339 doesn't have sub-second precision 270 + before := time.Now().Truncate(time.Second) 272 271 record := NewTagRecord(did, "myapp", "latest", "sha256:abc123") 273 - after := time.Now() 272 + after := time.Now().Truncate(time.Second).Add(time.Second) 274 273 275 - if record.Type != TagCollection { 276 - t.Errorf("Type = %v, want %v", record.Type, TagCollection) 274 + if record.LexiconTypeID != TagCollection { 275 + t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, TagCollection) 277 276 } 278 277 279 278 if record.Repository != "myapp" { ··· 286 285 287 286 // New records should have manifest field (AT-URI) 288 287 expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123" 289 - if record.Manifest != expectedURI { 288 + if record.Manifest == nil || *record.Manifest != expectedURI { 290 289 t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI) 291 290 } 292 291 293 292 // New records should NOT have manifestDigest field 294 - if record.ManifestDigest != "" { 295 - t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest) 293 + if record.ManifestDigest != nil && *record.ManifestDigest != "" { 294 + t.Errorf("ManifestDigest should be nil for new records, got %v", record.ManifestDigest) 296 295 } 297 296 298 - if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 299 - t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 297 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 298 + if err != nil { 299 + t.Errorf("CreatedAt is not valid RFC3339: %v", err) 300 + } 301 + if createdAt.Before(before) || createdAt.After(after) { 302 + t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after) 300 303 } 301 304 } 302 305 ··· 391 394 } 392 395 393 396 func TestTagRecord_GetManifestDigest(t *testing.T) { 397 + manifestURI := "at://did:plc:test123/io.atcr.manifest/abc123" 398 + digestValue := "sha256:def456" 399 + 394 400 tests := []struct { 395 401 name string 396 - record TagRecord 402 + record Tag 397 403 want string 398 404 wantErr bool 399 405 }{ 400 406 { 401 407 name: "new record with manifest field", 402 - record: TagRecord{ 403 - Manifest: "at://did:plc:test123/io.atcr.manifest/abc123", 408 + record: Tag{ 409 + Manifest: &manifestURI, 404 410 }, 405 411 want: "sha256:abc123", 406 412 wantErr: false, 407 413 }, 408 414 { 409 415 name: "old record with manifestDigest field", 410 - record: TagRecord{ 411 - ManifestDigest: "sha256:def456", 416 + record: Tag{ 417 + ManifestDigest: &digestValue, 412 418 }, 413 419 want: "sha256:def456", 414 420 wantErr: false, 415 421 }, 416 422 { 417 423 name: "prefers manifest over manifestDigest", 418 - record: TagRecord{ 419 - Manifest: "at://did:plc:test123/io.atcr.manifest/abc123", 420 - ManifestDigest: "sha256:def456", 424 + record: Tag{ 425 + Manifest: &manifestURI, 426 + ManifestDigest: &digestValue, 421 427 }, 422 428 want: "sha256:abc123", 423 429 wantErr: false, 424 430 }, 425 431 { 426 432 name: "no fields set", 427 - record: TagRecord{}, 433 + record: Tag{}, 428 434 want: "", 429 435 wantErr: true, 430 436 }, 431 437 { 432 438 name: "invalid manifest URI", 433 - record: TagRecord{ 434 - Manifest: "invalid-uri", 439 + record: Tag{ 440 + Manifest: func() *string { s := "invalid-uri"; return &s }(), 435 441 }, 436 442 want: "", 437 443 wantErr: true, ··· 451 457 }) 452 458 } 453 459 } 460 + 461 + // TestNewHoldRecord is removed - HoldRecord is no longer supported (legacy BYOS) 454 462 455 463 func TestNewSailorProfileRecord(t *testing.T) { 456 464 tests := []struct { ··· 473 481 474 482 for _, tt := range tests { 475 483 t.Run(tt.name, func(t *testing.T) { 476 - before := time.Now() 484 + // Truncate to second precision since RFC3339 doesn't have sub-second precision 485 + before := time.Now().Truncate(time.Second) 477 486 record := NewSailorProfileRecord(tt.defaultHold) 478 - after := time.Now() 487 + after := time.Now().Truncate(time.Second).Add(time.Second) 479 488 480 - if record.Type != SailorProfileCollection { 481 - t.Errorf("Type = %v, want %v", record.Type, SailorProfileCollection) 489 + if record.LexiconTypeID != SailorProfileCollection { 490 + t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, SailorProfileCollection) 482 491 } 483 492 484 - if record.DefaultHold != tt.defaultHold { 485 - t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold) 493 + if tt.defaultHold == "" { 494 + if record.DefaultHold != nil { 495 + t.Errorf("DefaultHold = %v, want nil", record.DefaultHold) 496 + } 497 + } else { 498 + if record.DefaultHold == nil || *record.DefaultHold != tt.defaultHold { 499 + t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold) 500 + } 486 501 } 487 502 488 - if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 489 - t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 503 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 504 + if err != nil { 505 + t.Errorf("CreatedAt is not valid RFC3339: %v", err) 490 506 } 491 - 492 - if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 493 - t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 507 + if createdAt.Before(before) || createdAt.After(after) { 508 + t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after) 494 509 } 495 510 496 - // CreatedAt and UpdatedAt should be equal for new records 497 - if !record.CreatedAt.Equal(record.UpdatedAt) { 498 - t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt) 511 + if record.UpdatedAt == nil { 512 + t.Error("UpdatedAt should not be nil") 513 + } else { 514 + updatedAt, err := time.Parse(time.RFC3339, *record.UpdatedAt) 515 + if err != nil { 516 + t.Errorf("UpdatedAt is not valid RFC3339: %v", err) 517 + } 518 + if updatedAt.Before(before) || updatedAt.After(after) { 519 + t.Errorf("UpdatedAt = %v, want between %v and %v", updatedAt, before, after) 520 + } 499 521 } 500 522 }) 501 523 } 502 524 } 503 525 504 526 func TestNewStarRecord(t *testing.T) { 505 - before := time.Now() 527 + // Truncate to second precision since RFC3339 doesn't have sub-second precision 528 + before := time.Now().Truncate(time.Second) 506 529 record := NewStarRecord("did:plc:alice123", "myapp") 507 - after := time.Now() 530 + after := time.Now().Truncate(time.Second).Add(time.Second) 508 531 509 - if record.Type != StarCollection { 510 - t.Errorf("Type = %v, want %v", record.Type, StarCollection) 532 + if record.LexiconTypeID != StarCollection { 533 + t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, StarCollection) 511 534 } 512 535 513 - if record.Subject.DID != "did:plc:alice123" { 514 - t.Errorf("Subject.DID = %v, want did:plc:alice123", record.Subject.DID) 536 + if record.Subject.Did != "did:plc:alice123" { 537 + t.Errorf("Subject.Did = %v, want did:plc:alice123", record.Subject.Did) 515 538 } 516 539 517 540 if record.Subject.Repository != "myapp" { 518 541 t.Errorf("Subject.Repository = %v, want myapp", record.Subject.Repository) 519 542 } 520 543 521 - if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 522 - t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 544 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 545 + if err != nil { 546 + t.Errorf("CreatedAt is not valid RFC3339: %v", err) 547 + } 548 + if createdAt.Before(before) || createdAt.After(after) { 549 + t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after) 523 550 } 524 551 } 525 552 ··· 807 834 } 808 835 809 836 // Add hold DID 810 - record.HoldDID = "did:web:hold01.atcr.io" 837 + holdDID := "did:web:hold01.atcr.io" 838 + record.HoldDid = &holdDID 811 839 812 840 // Serialize to JSON 813 841 jsonData, err := json.Marshal(record) ··· 816 844 } 817 845 818 846 // Deserialize from JSON 819 - var decoded ManifestRecord 847 + var decoded Manifest 820 848 if err := json.Unmarshal(jsonData, &decoded); err != nil { 821 849 t.Fatalf("json.Unmarshal() error = %v", err) 822 850 } 823 851 824 852 // Verify fields 825 - if decoded.Type != record.Type { 826 - t.Errorf("Type = %v, want %v", decoded.Type, record.Type) 853 + if decoded.LexiconTypeID != record.LexiconTypeID { 854 + t.Errorf("LexiconTypeID = %v, want %v", decoded.LexiconTypeID, record.LexiconTypeID) 827 855 } 828 856 if decoded.Repository != record.Repository { 829 857 t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository) ··· 831 859 if decoded.Digest != record.Digest { 832 860 t.Errorf("Digest = %v, want %v", decoded.Digest, record.Digest) 833 861 } 834 - if decoded.HoldDID != record.HoldDID { 835 - t.Errorf("HoldDID = %v, want %v", decoded.HoldDID, record.HoldDID) 862 + if decoded.HoldDid == nil || *decoded.HoldDid != *record.HoldDid { 863 + t.Errorf("HoldDid = %v, want %v", decoded.HoldDid, record.HoldDid) 836 864 } 837 865 if decoded.Config.Digest != record.Config.Digest { 838 866 t.Errorf("Config.Digest = %v, want %v", decoded.Config.Digest, record.Config.Digest) ··· 843 871 } 844 872 845 873 func TestBlobReference_JSONSerialization(t *testing.T) { 846 - blob := BlobReference{ 874 + blob := Manifest_BlobReference{ 847 875 MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", 848 876 Digest: "sha256:abc123", 849 877 Size: 12345, 850 - URLs: []string{"https://s3.example.com/blob"}, 851 - Annotations: map[string]string{ 852 - "key": "value", 853 - }, 878 + Urls: []string{"https://s3.example.com/blob"}, 879 + // Note: Annotations is now an empty struct, not a map 854 880 } 855 881 856 882 // Serialize ··· 860 886 } 861 887 862 888 // Deserialize 863 - var decoded BlobReference 889 + var decoded Manifest_BlobReference 864 890 if err := json.Unmarshal(jsonData, &decoded); err != nil { 865 891 t.Fatalf("json.Unmarshal() error = %v", err) 866 892 } ··· 878 904 } 879 905 880 906 func TestStarSubject_JSONSerialization(t *testing.T) { 881 - subject := StarSubject{ 882 - DID: "did:plc:alice123", 907 + subject := SailorStar_Subject{ 908 + Did: "did:plc:alice123", 883 909 Repository: "myapp", 884 910 } 885 911 ··· 890 916 } 891 917 892 918 // Deserialize 893 - var decoded StarSubject 919 + var decoded SailorStar_Subject 894 920 if err := json.Unmarshal(jsonData, &decoded); err != nil { 895 921 t.Fatalf("json.Unmarshal() error = %v", err) 896 922 } 897 923 898 924 // Verify 899 - if decoded.DID != subject.DID { 900 - t.Errorf("DID = %v, want %v", decoded.DID, subject.DID) 925 + if decoded.Did != subject.Did { 926 + t.Errorf("Did = %v, want %v", decoded.Did, subject.Did) 901 927 } 902 928 if decoded.Repository != subject.Repository { 903 929 t.Errorf("Repository = %v, want %v", decoded.Repository, subject.Repository) ··· 1144 1170 t.Fatal("NewLayerRecord() returned nil") 1145 1171 } 1146 1172 1147 - if record.Type != LayerCollection { 1148 - t.Errorf("Type = %q, want %q", record.Type, LayerCollection) 1173 + if record.LexiconTypeID != LayerCollection { 1174 + t.Errorf("LexiconTypeID = %q, want %q", record.LexiconTypeID, LayerCollection) 1149 1175 } 1150 1176 1151 1177 if record.Digest != tt.digest { ··· 1164 1190 t.Errorf("Repository = %q, want %q", record.Repository, tt.repository) 1165 1191 } 1166 1192 1167 - if record.UserDID != tt.userDID { 1168 - t.Errorf("UserDID = %q, want %q", record.UserDID, tt.userDID) 1193 + if record.UserDid != tt.userDID { 1194 + t.Errorf("UserDid = %q, want %q", record.UserDid, tt.userDID) 1169 1195 } 1170 1196 1171 1197 if record.UserHandle != tt.userHandle { ··· 1187 1213 } 1188 1214 1189 1215 func TestNewLayerRecordJSON(t *testing.T) { 1190 - // Test that LayerRecord can be marshaled/unmarshaled to/from JSON 1216 + // Test that HoldLayer can be marshaled/unmarshaled to/from JSON 1191 1217 record := NewLayerRecord( 1192 1218 "sha256:abc123", 1193 1219 1024, ··· 1204 1230 } 1205 1231 1206 1232 // Unmarshal back 1207 - var decoded LayerRecord 1233 + var decoded HoldLayer 1208 1234 if err := json.Unmarshal(jsonData, &decoded); err != nil { 1209 1235 t.Fatalf("json.Unmarshal() error = %v", err) 1210 1236 } 1211 1237 1212 1238 // Verify fields match 1213 - if decoded.Type != record.Type { 1214 - t.Errorf("Type = %q, want %q", decoded.Type, record.Type) 1239 + if decoded.LexiconTypeID != record.LexiconTypeID { 1240 + t.Errorf("LexiconTypeID = %q, want %q", decoded.LexiconTypeID, record.LexiconTypeID) 1215 1241 } 1216 1242 if decoded.Digest != record.Digest { 1217 1243 t.Errorf("Digest = %q, want %q", decoded.Digest, record.Digest) ··· 1225 1251 if decoded.Repository != record.Repository { 1226 1252 t.Errorf("Repository = %q, want %q", decoded.Repository, record.Repository) 1227 1253 } 1228 - if decoded.UserDID != record.UserDID { 1229 - t.Errorf("UserDID = %q, want %q", decoded.UserDID, record.UserDID) 1254 + if decoded.UserDid != record.UserDid { 1255 + t.Errorf("UserDid = %q, want %q", decoded.UserDid, record.UserDid) 1230 1256 } 1231 1257 if decoded.UserHandle != record.UserHandle { 1232 1258 t.Errorf("UserHandle = %q, want %q", decoded.UserHandle, record.UserHandle) ··· 1235 1261 t.Errorf("CreatedAt = %q, want %q", decoded.CreatedAt, record.CreatedAt) 1236 1262 } 1237 1263 } 1238 - 1239 - func TestNewRepoPageRecord(t *testing.T) { 1240 - tests := []struct { 1241 - name string 1242 - repository string 1243 - description string 1244 - avatar *ATProtoBlobRef 1245 - }{ 1246 - { 1247 - name: "with description only", 1248 - repository: "myapp", 1249 - description: "# My App\n\nA cool container image.", 1250 - avatar: nil, 1251 - }, 1252 - { 1253 - name: "with avatar only", 1254 - repository: "another-app", 1255 - description: "", 1256 - avatar: &ATProtoBlobRef{ 1257 - Type: "blob", 1258 - Ref: Link{Link: "bafyreiabc123"}, 1259 - MimeType: "image/png", 1260 - Size: 1024, 1261 - }, 1262 - }, 1263 - { 1264 - name: "with both description and avatar", 1265 - repository: "full-app", 1266 - description: "This is a full description.", 1267 - avatar: &ATProtoBlobRef{ 1268 - Type: "blob", 1269 - Ref: Link{Link: "bafyreiabc456"}, 1270 - MimeType: "image/jpeg", 1271 - Size: 2048, 1272 - }, 1273 - }, 1274 - { 1275 - name: "empty values", 1276 - repository: "", 1277 - description: "", 1278 - avatar: nil, 1279 - }, 1280 - } 1281 - 1282 - for _, tt := range tests { 1283 - t.Run(tt.name, func(t *testing.T) { 1284 - before := time.Now() 1285 - record := NewRepoPageRecord(tt.repository, tt.description, tt.avatar) 1286 - after := time.Now() 1287 - 1288 - if record.Type != RepoPageCollection { 1289 - t.Errorf("Type = %v, want %v", record.Type, RepoPageCollection) 1290 - } 1291 - 1292 - if record.Repository != tt.repository { 1293 - t.Errorf("Repository = %v, want %v", record.Repository, tt.repository) 1294 - } 1295 - 1296 - if record.Description != tt.description { 1297 - t.Errorf("Description = %v, want %v", record.Description, tt.description) 1298 - } 1299 - 1300 - if tt.avatar == nil && record.Avatar != nil { 1301 - t.Error("Avatar should be nil") 1302 - } 1303 - 1304 - if tt.avatar != nil { 1305 - if record.Avatar == nil { 1306 - t.Fatal("Avatar should not be nil") 1307 - } 1308 - if record.Avatar.Ref.Link != tt.avatar.Ref.Link { 1309 - t.Errorf("Avatar.Ref.Link = %v, want %v", record.Avatar.Ref.Link, tt.avatar.Ref.Link) 1310 - } 1311 - } 1312 - 1313 - if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 1314 - t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 1315 - } 1316 - 1317 - if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 1318 - t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 1319 - } 1320 - 1321 - // CreatedAt and UpdatedAt should be equal for new records 1322 - if !record.CreatedAt.Equal(record.UpdatedAt) { 1323 - t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt) 1324 - } 1325 - }) 1326 - } 1327 - } 1328 - 1329 - func TestRepoPageRecord_JSONSerialization(t *testing.T) { 1330 - record := NewRepoPageRecord( 1331 - "myapp", 1332 - "# My App\n\nA description with **markdown**.", 1333 - &ATProtoBlobRef{ 1334 - Type: "blob", 1335 - Ref: Link{Link: "bafyreiabc123"}, 1336 - MimeType: "image/png", 1337 - Size: 1024, 1338 - }, 1339 - ) 1340 - 1341 - // Serialize to JSON 1342 - jsonData, err := json.Marshal(record) 1343 - if err != nil { 1344 - t.Fatalf("json.Marshal() error = %v", err) 1345 - } 1346 - 1347 - // Deserialize from JSON 1348 - var decoded RepoPageRecord 1349 - if err := json.Unmarshal(jsonData, &decoded); err != nil { 1350 - t.Fatalf("json.Unmarshal() error = %v", err) 1351 - } 1352 - 1353 - // Verify fields 1354 - if decoded.Type != record.Type { 1355 - t.Errorf("Type = %v, want %v", decoded.Type, record.Type) 1356 - } 1357 - if decoded.Repository != record.Repository { 1358 - t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository) 1359 - } 1360 - if decoded.Description != record.Description { 1361 - t.Errorf("Description = %v, want %v", decoded.Description, record.Description) 1362 - } 1363 - if decoded.Avatar == nil { 1364 - t.Fatal("Avatar should not be nil") 1365 - } 1366 - if decoded.Avatar.Ref.Link != record.Avatar.Ref.Link { 1367 - t.Errorf("Avatar.Ref.Link = %v, want %v", decoded.Avatar.Ref.Link, record.Avatar.Ref.Link) 1368 - } 1369 - }
+103
pkg/atproto/manifest.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + // Lexicon schema: io.atcr.manifest 4 + 5 + package atproto 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + // A container image manifest following OCI specification, stored in ATProto 12 + type Manifest struct { 13 + LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.manifest"` 14 + // annotations: Optional metadata annotations 15 + Annotations *Manifest_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"` 16 + // config: Reference to image configuration blob 17 + Config *Manifest_BlobReference `json:"config,omitempty" cborgen:"config,omitempty"` 18 + // createdAt: Record creation timestamp 19 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 20 + // digest: Content digest (e.g., 'sha256:abc123...') 21 + Digest string `json:"digest" cborgen:"digest"` 22 + // holdDid: DID of the hold service where blobs are stored (e.g., 'did:web:hold01.atcr.io'). Primary reference for hold resolution. 23 + HoldDid *string `json:"holdDid,omitempty" cborgen:"holdDid,omitempty"` 24 + // holdEndpoint: Hold service endpoint URL where blobs are stored. DEPRECATED: Use holdDid instead. Kept for backward compatibility. 25 + HoldEndpoint *string `json:"holdEndpoint,omitempty" cborgen:"holdEndpoint,omitempty"` 26 + // layers: Filesystem layers (for image manifests) 27 + Layers []Manifest_BlobReference `json:"layers,omitempty" cborgen:"layers,omitempty"` 28 + // manifestBlob: The full OCI manifest stored as a blob in ATProto. 29 + ManifestBlob *lexutil.LexBlob `json:"manifestBlob,omitempty" cborgen:"manifestBlob,omitempty"` 30 + // manifests: Referenced manifests (for manifest lists/indexes) 31 + Manifests []Manifest_ManifestReference `json:"manifests,omitempty" cborgen:"manifests,omitempty"` 32 + // mediaType: OCI media type 33 + MediaType string `json:"mediaType" cborgen:"mediaType"` 34 + // repository: Repository name (e.g., 'myapp'). Scoped to user's DID. 35 + Repository string `json:"repository" cborgen:"repository"` 36 + // schemaVersion: OCI schema version (typically 2) 37 + SchemaVersion int64 `json:"schemaVersion" cborgen:"schemaVersion"` 38 + // subject: Optional reference to another manifest (for attestations, signatures) 39 + Subject *Manifest_BlobReference `json:"subject,omitempty" cborgen:"subject,omitempty"` 40 + } 41 + 42 + // Optional metadata annotations 43 + type Manifest_Annotations struct { 44 + } 45 + 46 + // Manifest_BlobReference is a "blobReference" in the io.atcr.manifest schema. 47 + // 48 + // Reference to a blob stored in S3 or external storage 49 + type Manifest_BlobReference struct { 50 + LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#blobReference,omitempty"` 51 + // annotations: Optional metadata 52 + Annotations *Manifest_BlobReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"` 53 + // digest: Content digest (e.g., 'sha256:...') 54 + Digest string `json:"digest" cborgen:"digest"` 55 + // mediaType: MIME type of the blob 56 + MediaType string `json:"mediaType" cborgen:"mediaType"` 57 + // size: Size in bytes 58 + Size int64 `json:"size" cborgen:"size"` 59 + // urls: Optional direct URLs to blob (for BYOS) 60 + Urls []string `json:"urls,omitempty" cborgen:"urls,omitempty"` 61 + } 62 + 63 + // Optional metadata 64 + type Manifest_BlobReference_Annotations struct { 65 + } 66 + 67 + // Manifest_ManifestReference is a "manifestReference" in the io.atcr.manifest schema. 68 + // 69 + // Reference to a manifest in a manifest list/index 70 + type Manifest_ManifestReference struct { 71 + LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#manifestReference,omitempty"` 72 + // annotations: Optional metadata 73 + Annotations *Manifest_ManifestReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"` 74 + // digest: Content digest (e.g., 'sha256:...') 75 + Digest string `json:"digest" cborgen:"digest"` 76 + // mediaType: Media type of the referenced manifest 77 + MediaType string `json:"mediaType" cborgen:"mediaType"` 78 + // platform: Platform information for this manifest 79 + Platform *Manifest_Platform `json:"platform,omitempty" cborgen:"platform,omitempty"` 80 + // size: Size in bytes 81 + Size int64 `json:"size" cborgen:"size"` 82 + } 83 + 84 + // Optional metadata 85 + type Manifest_ManifestReference_Annotations struct { 86 + } 87 + 88 + // Manifest_Platform is a "platform" in the io.atcr.manifest schema. 89 + // 90 + // Platform information describing OS and architecture 91 + type Manifest_Platform struct { 92 + LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#platform,omitempty"` 93 + // architecture: CPU architecture (e.g., 'amd64', 'arm64', 'arm') 94 + Architecture string `json:"architecture" cborgen:"architecture"` 95 + // os: Operating system (e.g., 'linux', 'windows', 'darwin') 96 + Os string `json:"os" cborgen:"os"` 97 + // osFeatures: Optional OS features 98 + OsFeatures []string `json:"osFeatures,omitempty" cborgen:"osFeatures,omitempty"` 99 + // osVersion: Optional OS version 100 + OsVersion *string `json:"osVersion,omitempty" cborgen:"osVersion,omitempty"` 101 + // variant: Optional CPU variant (e.g., 'v7' for ARM) 102 + Variant *string `json:"variant,omitempty" cborgen:"variant,omitempty"` 103 + }
+15
pkg/atproto/register.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + package atproto 4 + 5 + import lexutil "github.com/bluesky-social/indigo/lex/util" 6 + 7 + func init() { 8 + lexutil.RegisterType("io.atcr.hold.captain", &HoldCaptain{}) 9 + lexutil.RegisterType("io.atcr.hold.crew", &HoldCrew{}) 10 + lexutil.RegisterType("io.atcr.hold.layer", &HoldLayer{}) 11 + lexutil.RegisterType("io.atcr.manifest", &Manifest{}) 12 + lexutil.RegisterType("io.atcr.sailor.profile", &SailorProfile{}) 13 + lexutil.RegisterType("io.atcr.sailor.star", &SailorStar{}) 14 + lexutil.RegisterType("io.atcr.tag", &Tag{}) 15 + }
+16
pkg/atproto/sailorprofile.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + // Lexicon schema: io.atcr.sailor.profile 4 + 5 + package atproto 6 + 7 + // User profile for ATCR registry. Stores preferences like default hold for blob storage. 8 + type SailorProfile struct { 9 + LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.profile"` 10 + // createdAt: Profile creation timestamp 11 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 12 + // defaultHold: Default hold endpoint for blob storage. If null, user has opted out of defaults. 13 + DefaultHold *string `json:"defaultHold,omitempty" cborgen:"defaultHold,omitempty"` 14 + // updatedAt: Profile last updated timestamp 15 + UpdatedAt *string `json:"updatedAt,omitempty" cborgen:"updatedAt,omitempty"` 16 + }
+25
pkg/atproto/sailorstar.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + // Lexicon schema: io.atcr.sailor.star 4 + 5 + package atproto 6 + 7 + // A star (like) on a container image repository. Stored in the starrer's PDS, similar to Bluesky likes. 8 + type SailorStar struct { 9 + LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.star"` 10 + // createdAt: Star creation timestamp 11 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 12 + // subject: The repository being starred 13 + Subject SailorStar_Subject `json:"subject" cborgen:"subject"` 14 + } 15 + 16 + // SailorStar_Subject is a "subject" in the io.atcr.sailor.star schema. 17 + // 18 + // Reference to a repository owned by a user 19 + type SailorStar_Subject struct { 20 + LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.sailor.star#subject,omitempty"` 21 + // did: DID of the repository owner 22 + Did string `json:"did" cborgen:"did"` 23 + // repository: Repository name (e.g., 'myapp') 24 + Repository string `json:"repository" cborgen:"repository"` 25 + }
+20
pkg/atproto/tag.go
··· 1 + // Code generated by generate.go; DO NOT EDIT. 2 + 3 + // Lexicon schema: io.atcr.tag 4 + 5 + package atproto 6 + 7 + // A named tag pointing to a specific manifest digest 8 + type Tag struct { 9 + LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.tag"` 10 + // createdAt: Tag creation timestamp 11 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 12 + // manifest: AT-URI of the manifest this tag points to (e.g., 'at://did:plc:xyz/io.atcr.manifest/abc123'). Preferred over manifestDigest for new records. 13 + Manifest *string `json:"manifest,omitempty" cborgen:"manifest,omitempty"` 14 + // manifestDigest: DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead. 15 + ManifestDigest *string `json:"manifestDigest,omitempty" cborgen:"manifestDigest,omitempty"` 16 + // repository: Repository name (e.g., 'myapp'). Scoped to user's DID. 17 + Repository string `json:"repository" cborgen:"repository"` 18 + // tag: Tag name (e.g., 'latest', 'v1.0.0', '12-slim') 19 + Tag string `json:"tag" cborgen:"tag"` 20 + }
+3 -3
pkg/auth/hold_authorizer.go
··· 21 21 22 22 // GetCaptainRecord retrieves the captain record for a hold 23 23 // Used to check public flag and allowAllCrew settings 24 - GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) 24 + GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) 25 25 26 26 // IsCrewMember checks if userDID is a crew member of holdDID 27 27 IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) ··· 32 32 // Read access rules: 33 33 // - Public hold: allow anyone (even anonymous) 34 34 // - Private hold: require authentication (any authenticated user) 35 - func CheckReadAccessWithCaptain(captain *atproto.CaptainRecord, userDID string) bool { 35 + func CheckReadAccessWithCaptain(captain *atproto.HoldCaptain, userDID string) bool { 36 36 if captain.Public { 37 37 // Public hold - allow anyone (even anonymous) 38 38 return true ··· 55 55 // Write access rules: 56 56 // - Must be authenticated 57 57 // - Must be hold owner OR crew member 58 - func CheckWriteAccessWithCaptain(captain *atproto.CaptainRecord, userDID string, isCrew bool) bool { 58 + func CheckWriteAccessWithCaptain(captain *atproto.HoldCaptain, userDID string, isCrew bool) bool { 59 59 slog.Debug("Checking write access", "userDID", userDID, "owner", captain.Owner, "isCrew", isCrew) 60 60 61 61 if userDID == "" {
+5 -5
pkg/auth/hold_authorizer_test.go
··· 7 7 ) 8 8 9 9 func TestCheckReadAccessWithCaptain_PublicHold(t *testing.T) { 10 - captain := &atproto.CaptainRecord{ 10 + captain := &atproto.HoldCaptain{ 11 11 Public: true, 12 12 Owner: "did:plc:owner123", 13 13 } ··· 26 26 } 27 27 28 28 func TestCheckReadAccessWithCaptain_PrivateHold(t *testing.T) { 29 - captain := &atproto.CaptainRecord{ 29 + captain := &atproto.HoldCaptain{ 30 30 Public: false, 31 31 Owner: "did:plc:owner123", 32 32 } ··· 45 45 } 46 46 47 47 func TestCheckWriteAccessWithCaptain_Owner(t *testing.T) { 48 - captain := &atproto.CaptainRecord{ 48 + captain := &atproto.HoldCaptain{ 49 49 Public: false, 50 50 Owner: "did:plc:owner123", 51 51 } ··· 58 58 } 59 59 60 60 func TestCheckWriteAccessWithCaptain_Crew(t *testing.T) { 61 - captain := &atproto.CaptainRecord{ 61 + captain := &atproto.HoldCaptain{ 62 62 Public: false, 63 63 Owner: "did:plc:owner123", 64 64 } ··· 77 77 } 78 78 79 79 func TestCheckWriteAccessWithCaptain_Anonymous(t *testing.T) { 80 - captain := &atproto.CaptainRecord{ 80 + captain := &atproto.HoldCaptain{ 81 81 Public: false, 82 82 Owner: "did:plc:owner123", 83 83 }
+2 -2
pkg/auth/hold_local.go
··· 35 35 } 36 36 37 37 // GetCaptainRecord retrieves the captain record from the hold's PDS 38 - func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) { 38 + func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) { 39 39 // Verify that the requested holdDID matches this hold 40 40 if holdDID != a.pds.DID() { 41 41 return nil, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID()) ··· 47 47 return nil, fmt.Errorf("failed to get captain record: %w", err) 48 48 } 49 49 50 - // The PDS returns *atproto.CaptainRecord directly now (after we update pds to use atproto types) 50 + // The PDS returns *atproto.HoldCaptain directly 51 51 return pdsCaptain, nil 52 52 } 53 53
+34 -20
pkg/auth/hold_remote.go
··· 101 101 // 1. Check database cache 102 102 // 2. If cache miss or expired, query hold's XRPC endpoint 103 103 // 3. Update cache 104 - func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) { 104 + func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) { 105 105 // Try cache first 106 106 if a.db != nil { 107 107 cached, err := a.getCachedCaptainRecord(holdDID) 108 108 if err == nil && cached != nil { 109 109 // Cache hit - check if still valid 110 110 if time.Since(cached.UpdatedAt) < a.cacheTTL { 111 - return cached.CaptainRecord, nil 111 + return cached.HoldCaptain, nil 112 112 } 113 113 // Cache expired - continue to fetch fresh data 114 114 } ··· 133 133 134 134 // captainRecordWithMeta includes UpdatedAt for cache management 135 135 type captainRecordWithMeta struct { 136 - *atproto.CaptainRecord 136 + *atproto.HoldCaptain 137 137 UpdatedAt time.Time 138 138 } 139 139 ··· 145 145 WHERE hold_did = ? 146 146 ` 147 147 148 - var record atproto.CaptainRecord 148 + var record atproto.HoldCaptain 149 149 var deployedAt, region, provider sql.NullString 150 150 var updatedAt time.Time 151 151 ··· 172 172 record.DeployedAt = deployedAt.String 173 173 } 174 174 if region.Valid { 175 - record.Region = region.String 175 + record.Region = &region.String 176 176 } 177 177 if provider.Valid { 178 - record.Provider = provider.String 178 + record.Provider = &provider.String 179 179 } 180 180 181 181 return &captainRecordWithMeta{ 182 - CaptainRecord: &record, 183 - UpdatedAt: updatedAt, 182 + HoldCaptain: &record, 183 + UpdatedAt: updatedAt, 184 184 }, nil 185 185 } 186 186 187 187 // setCachedCaptainRecord stores a captain record in database cache 188 - func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.CaptainRecord) error { 188 + func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.HoldCaptain) error { 189 189 query := ` 190 190 INSERT INTO hold_captain_records ( 191 191 hold_did, owner_did, public, allow_all_crew, ··· 207 207 record.Public, 208 208 record.AllowAllCrew, 209 209 nullString(record.DeployedAt), 210 - nullString(record.Region), 211 - nullString(record.Provider), 210 + nullStringPtr(record.Region), 211 + nullStringPtr(record.Provider), 212 212 time.Now(), 213 213 ) 214 214 ··· 216 216 } 217 217 218 218 // fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record 219 - func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) { 219 + func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) { 220 220 // Resolve DID to URL 221 221 holdURL := atproto.ResolveHoldURL(holdDID) 222 222 ··· 261 261 } 262 262 263 263 // Convert to our type 264 - record := &atproto.CaptainRecord{ 265 - Type: atproto.CaptainCollection, 266 - Owner: xrpcResp.Value.Owner, 267 - Public: xrpcResp.Value.Public, 268 - AllowAllCrew: xrpcResp.Value.AllowAllCrew, 269 - DeployedAt: xrpcResp.Value.DeployedAt, 270 - Region: xrpcResp.Value.Region, 271 - Provider: xrpcResp.Value.Provider, 264 + record := &atproto.HoldCaptain{ 265 + LexiconTypeID: atproto.CaptainCollection, 266 + Owner: xrpcResp.Value.Owner, 267 + Public: xrpcResp.Value.Public, 268 + AllowAllCrew: xrpcResp.Value.AllowAllCrew, 269 + DeployedAt: xrpcResp.Value.DeployedAt, 270 + } 271 + 272 + // Handle optional pointer fields 273 + if xrpcResp.Value.Region != "" { 274 + record.Region = &xrpcResp.Value.Region 275 + } 276 + if xrpcResp.Value.Provider != "" { 277 + record.Provider = &xrpcResp.Value.Provider 272 278 } 273 279 274 280 return record, nil ··· 406 412 return sql.NullString{Valid: false} 407 413 } 408 414 return sql.NullString{String: s, Valid: true} 415 + } 416 + 417 + // nullStringPtr converts a *string to sql.NullString 418 + func nullStringPtr(s *string) sql.NullString { 419 + if s == nil || *s == "" { 420 + return sql.NullString{Valid: false} 421 + } 422 + return sql.NullString{String: *s, Valid: true} 409 423 } 410 424 411 425 // getCachedApproval checks if user has a cached crew approval
+13 -8
pkg/auth/hold_remote_test.go
··· 14 14 "atcr.io/pkg/atproto" 15 15 ) 16 16 17 + // ptrString returns a pointer to the given string 18 + func ptrString(s string) *string { 19 + return &s 20 + } 21 + 17 22 func TestNewRemoteHoldAuthorizer(t *testing.T) { 18 23 // Test with nil database (should still work) 19 24 authorizer := NewRemoteHoldAuthorizer(nil, false) ··· 133 138 holdDID := "did:web:hold01.atcr.io" 134 139 135 140 // Pre-populate cache with a captain record 136 - captainRecord := &atproto.CaptainRecord{ 137 - Type: atproto.CaptainCollection, 138 - Owner: "did:plc:owner123", 139 - Public: true, 140 - AllowAllCrew: false, 141 - DeployedAt: "2025-10-28T00:00:00Z", 142 - Region: "us-east-1", 143 - Provider: "fly.io", 141 + captainRecord := &atproto.HoldCaptain{ 142 + LexiconTypeID: atproto.CaptainCollection, 143 + Owner: "did:plc:owner123", 144 + Public: true, 145 + AllowAllCrew: false, 146 + DeployedAt: "2025-10-28T00:00:00Z", 147 + Region: ptrString("us-east-1"), 148 + Provider: ptrString("fly.io"), 144 149 } 145 150 146 151 err := remote.setCachedCaptainRecord(holdDID, captainRecord)
+2 -5
pkg/auth/oauth/client.go
··· 77 77 func GetDefaultScopes(did string) []string { 78 78 scopes := []string{ 79 79 "atproto", 80 - // Used for service token validation on holds 81 - "rpc:com.atproto.repo.getRecord?aud=*", 82 80 // Image manifest types (single-arch) 83 81 "blob:application/vnd.oci.image.manifest.v1+json", 84 82 "blob:application/vnd.docker.distribution.manifest.v2+json", ··· 87 85 "blob:application/vnd.docker.distribution.manifest.list.v2+json", 88 86 // OCI artifact manifests (for cosign signatures, SBOMs, attestations) 89 87 "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", 90 - // image avatars 91 - "blob:image/*", 88 + // Used for service token validation on holds 89 + "rpc:com.atproto.repo.getRecord?aud=*", 92 90 } 93 91 94 92 // Add repo scopes ··· 97 95 fmt.Sprintf("repo:%s", atproto.TagCollection), 98 96 fmt.Sprintf("repo:%s", atproto.StarCollection), 99 97 fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), 100 - fmt.Sprintf("repo:%s", atproto.RepoPageCollection), 101 98 ) 102 99 103 100 return scopes
+4 -4
pkg/hold/pds/captain.go
··· 18 18 // CreateCaptainRecord creates the captain record for the hold (first-time only). 19 19 // This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify. 20 20 func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) { 21 - captainRecord := &atproto.CaptainRecord{ 22 - Type: atproto.CaptainCollection, 21 + captainRecord := &atproto.HoldCaptain{ 22 + LexiconTypeID: atproto.CaptainCollection, 23 23 Owner: ownerDID, 24 24 Public: public, 25 25 AllowAllCrew: allowAllCrew, ··· 40 40 } 41 41 42 42 // GetCaptainRecord retrieves the captain record 43 - func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.CaptainRecord, error) { 43 + func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.HoldCaptain, error) { 44 44 // Use repomgr.GetRecord - our types are registered in init() 45 45 // so it will automatically unmarshal to the concrete type 46 46 recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CaptainCollection, CaptainRkey, cid.Undef) ··· 49 49 } 50 50 51 51 // Type assert to our concrete type 52 - captainRecord, ok := val.(*atproto.CaptainRecord) 52 + captainRecord, ok := val.(*atproto.HoldCaptain) 53 53 if !ok { 54 54 return cid.Undef, nil, fmt.Errorf("unexpected type for captain record: %T", val) 55 55 }
+43 -32
pkg/hold/pds/captain_test.go
··· 12 12 "atcr.io/pkg/atproto" 13 13 ) 14 14 15 + // ptrString returns a pointer to the given string 16 + func ptrString(s string) *string { 17 + return &s 18 + } 19 + 15 20 // setupTestPDS creates a test PDS instance in a temporary directory 16 21 // It initializes the repo but does NOT create captain/crew records 17 22 // Tests should call Bootstrap or create records as needed ··· 146 151 if captain.EnableBlueskyPosts != tt.enableBlueskyPosts { 147 152 t.Errorf("Expected enableBlueskyPosts=%v, got %v", tt.enableBlueskyPosts, captain.EnableBlueskyPosts) 148 153 } 149 - if captain.Type != atproto.CaptainCollection { 150 - t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type) 154 + if captain.LexiconTypeID != atproto.CaptainCollection { 155 + t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID) 151 156 } 152 157 if captain.DeployedAt == "" { 153 158 t.Error("Expected deployedAt to be set") ··· 322 327 func TestCaptainRecord_CBORRoundtrip(t *testing.T) { 323 328 tests := []struct { 324 329 name string 325 - record *atproto.CaptainRecord 330 + record *atproto.HoldCaptain 326 331 }{ 327 332 { 328 333 name: "Basic captain", 329 - record: &atproto.CaptainRecord{ 330 - Type: atproto.CaptainCollection, 331 - Owner: "did:plc:alice123", 332 - Public: true, 333 - AllowAllCrew: false, 334 - DeployedAt: "2025-10-16T12:00:00Z", 334 + record: &atproto.HoldCaptain{ 335 + LexiconTypeID: atproto.CaptainCollection, 336 + Owner: "did:plc:alice123", 337 + Public: true, 338 + AllowAllCrew: false, 339 + DeployedAt: "2025-10-16T12:00:00Z", 335 340 }, 336 341 }, 337 342 { 338 343 name: "Captain with optional fields", 339 - record: &atproto.CaptainRecord{ 340 - Type: atproto.CaptainCollection, 341 - Owner: "did:plc:bob456", 342 - Public: false, 343 - AllowAllCrew: true, 344 - DeployedAt: "2025-10-16T12:00:00Z", 345 - Region: "us-west-2", 346 - Provider: "fly.io", 344 + record: &atproto.HoldCaptain{ 345 + LexiconTypeID: atproto.CaptainCollection, 346 + Owner: "did:plc:bob456", 347 + Public: false, 348 + AllowAllCrew: true, 349 + DeployedAt: "2025-10-16T12:00:00Z", 350 + Region: ptrString("us-west-2"), 351 + Provider: ptrString("fly.io"), 347 352 }, 348 353 }, 349 354 { 350 355 name: "Captain with empty optional fields", 351 - record: &atproto.CaptainRecord{ 352 - Type: atproto.CaptainCollection, 353 - Owner: "did:plc:charlie789", 354 - Public: true, 355 - AllowAllCrew: true, 356 - DeployedAt: "2025-10-16T12:00:00Z", 357 - Region: "", 358 - Provider: "", 356 + record: &atproto.HoldCaptain{ 357 + LexiconTypeID: atproto.CaptainCollection, 358 + Owner: "did:plc:charlie789", 359 + Public: true, 360 + AllowAllCrew: true, 361 + DeployedAt: "2025-10-16T12:00:00Z", 362 + Region: ptrString(""), 363 + Provider: ptrString(""), 359 364 }, 360 365 }, 361 366 } ··· 375 380 } 376 381 377 382 // Unmarshal from CBOR 378 - var decoded atproto.CaptainRecord 383 + var decoded atproto.HoldCaptain 379 384 err = decoded.UnmarshalCBOR(bytes.NewReader(cborBytes)) 380 385 if err != nil { 381 386 t.Fatalf("UnmarshalCBOR failed: %v", err) 382 387 } 383 388 384 389 // Verify all fields match 385 - if decoded.Type != tt.record.Type { 386 - t.Errorf("Type mismatch: expected %s, got %s", tt.record.Type, decoded.Type) 390 + if decoded.LexiconTypeID != tt.record.LexiconTypeID { 391 + t.Errorf("LexiconTypeID mismatch: expected %s, got %s", tt.record.LexiconTypeID, decoded.LexiconTypeID) 387 392 } 388 393 if decoded.Owner != tt.record.Owner { 389 394 t.Errorf("Owner mismatch: expected %s, got %s", tt.record.Owner, decoded.Owner) ··· 397 402 if decoded.DeployedAt != tt.record.DeployedAt { 398 403 t.Errorf("DeployedAt mismatch: expected %s, got %s", tt.record.DeployedAt, decoded.DeployedAt) 399 404 } 400 - if decoded.Region != tt.record.Region { 401 - t.Errorf("Region mismatch: expected %s, got %s", tt.record.Region, decoded.Region) 405 + // Compare Region pointers (may be nil) 406 + if (decoded.Region == nil) != (tt.record.Region == nil) { 407 + t.Errorf("Region nil mismatch: expected %v, got %v", tt.record.Region, decoded.Region) 408 + } else if decoded.Region != nil && *decoded.Region != *tt.record.Region { 409 + t.Errorf("Region mismatch: expected %q, got %q", *tt.record.Region, *decoded.Region) 402 410 } 403 - if decoded.Provider != tt.record.Provider { 404 - t.Errorf("Provider mismatch: expected %s, got %s", tt.record.Provider, decoded.Provider) 411 + // Compare Provider pointers (may be nil) 412 + if (decoded.Provider == nil) != (tt.record.Provider == nil) { 413 + t.Errorf("Provider nil mismatch: expected %v, got %v", tt.record.Provider, decoded.Provider) 414 + } else if decoded.Provider != nil && *decoded.Provider != *tt.record.Provider { 415 + t.Errorf("Provider mismatch: expected %q, got %q", *tt.record.Provider, *decoded.Provider) 405 416 } 406 417 }) 407 418 }
+10 -10
pkg/hold/pds/crew.go
··· 15 15 16 16 // AddCrewMember adds a new crew member to the hold and commits to carstore 17 17 func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) { 18 - crewRecord := &atproto.CrewRecord{ 19 - Type: atproto.CrewCollection, 20 - Member: memberDID, 21 - Role: role, 22 - Permissions: permissions, 23 - AddedAt: time.Now().Format(time.RFC3339), 18 + crewRecord := &atproto.HoldCrew{ 19 + LexiconTypeID: atproto.CrewCollection, 20 + Member: memberDID, 21 + Role: role, 22 + Permissions: permissions, 23 + AddedAt: time.Now().Format(time.RFC3339), 24 24 } 25 25 26 26 // Use repomgr for crew operations - auto-generated rkey is fine ··· 33 33 } 34 34 35 35 // GetCrewMember retrieves a crew member by their record key 36 - func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.CrewRecord, error) { 36 + func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.HoldCrew, error) { 37 37 // Use repomgr.GetRecord - our types are registered in init() 38 38 recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CrewCollection, rkey, cid.Undef) 39 39 if err != nil { ··· 41 41 } 42 42 43 43 // Type assert to our concrete type 44 - crewRecord, ok := val.(*atproto.CrewRecord) 44 + crewRecord, ok := val.(*atproto.HoldCrew) 45 45 if !ok { 46 46 return cid.Undef, nil, fmt.Errorf("unexpected type for crew record: %T", val) 47 47 } ··· 53 53 type CrewMemberWithKey struct { 54 54 Rkey string 55 55 Cid cid.Cid 56 - Record *atproto.CrewRecord 56 + Record *atproto.HoldCrew 57 57 } 58 58 59 59 // ListCrewMembers returns all crew members with their rkeys ··· 108 108 } 109 109 110 110 // Unmarshal the CBOR bytes into our concrete type 111 - var crewRecord atproto.CrewRecord 111 + var crewRecord atproto.HoldCrew 112 112 if err := crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil { 113 113 return fmt.Errorf("failed to decode crew record: %w", err) 114 114 }
+30 -30
pkg/hold/pds/crew_test.go
··· 53 53 t.Errorf("Expected permission[%d]=%s, got %s", i, perm, crew.Record.Permissions[i]) 54 54 } 55 55 } 56 - if crew.Record.Type != atproto.CrewCollection { 57 - t.Errorf("Expected type %s, got %s", atproto.CrewCollection, crew.Record.Type) 56 + if crew.Record.LexiconTypeID != atproto.CrewCollection { 57 + t.Errorf("Expected type %s, got %s", atproto.CrewCollection, crew.Record.LexiconTypeID) 58 58 } 59 59 if crew.Record.AddedAt == "" { 60 60 t.Error("Expected addedAt to be set") ··· 348 348 func TestCrewRecord_CBORRoundtrip(t *testing.T) { 349 349 tests := []struct { 350 350 name string 351 - record *atproto.CrewRecord 351 + record *atproto.HoldCrew 352 352 }{ 353 353 { 354 354 name: "Basic crew member", 355 - record: &atproto.CrewRecord{ 356 - Type: atproto.CrewCollection, 357 - Member: "did:plc:alice123", 358 - Role: "writer", 359 - Permissions: []string{"blob:read", "blob:write"}, 360 - AddedAt: "2025-10-16T12:00:00Z", 355 + record: &atproto.HoldCrew{ 356 + LexiconTypeID: atproto.CrewCollection, 357 + Member: "did:plc:alice123", 358 + Role: "writer", 359 + Permissions: []string{"blob:read", "blob:write"}, 360 + AddedAt: "2025-10-16T12:00:00Z", 361 361 }, 362 362 }, 363 363 { 364 364 name: "Admin crew member", 365 - record: &atproto.CrewRecord{ 366 - Type: atproto.CrewCollection, 367 - Member: "did:plc:bob456", 368 - Role: "admin", 369 - Permissions: []string{"blob:read", "blob:write", "crew:admin"}, 370 - AddedAt: "2025-10-16T13:00:00Z", 365 + record: &atproto.HoldCrew{ 366 + LexiconTypeID: atproto.CrewCollection, 367 + Member: "did:plc:bob456", 368 + Role: "admin", 369 + Permissions: []string{"blob:read", "blob:write", "crew:admin"}, 370 + AddedAt: "2025-10-16T13:00:00Z", 371 371 }, 372 372 }, 373 373 { 374 374 name: "Reader crew member", 375 - record: &atproto.CrewRecord{ 376 - Type: atproto.CrewCollection, 377 - Member: "did:plc:charlie789", 378 - Role: "reader", 379 - Permissions: []string{"blob:read"}, 380 - AddedAt: "2025-10-16T14:00:00Z", 375 + record: &atproto.HoldCrew{ 376 + LexiconTypeID: atproto.CrewCollection, 377 + Member: "did:plc:charlie789", 378 + Role: "reader", 379 + Permissions: []string{"blob:read"}, 380 + AddedAt: "2025-10-16T14:00:00Z", 381 381 }, 382 382 }, 383 383 { 384 384 name: "Crew member with empty permissions", 385 - record: &atproto.CrewRecord{ 386 - Type: atproto.CrewCollection, 387 - Member: "did:plc:dave012", 388 - Role: "none", 389 - Permissions: []string{}, 390 - AddedAt: "2025-10-16T15:00:00Z", 385 + record: &atproto.HoldCrew{ 386 + LexiconTypeID: atproto.CrewCollection, 387 + Member: "did:plc:dave012", 388 + Role: "none", 389 + Permissions: []string{}, 390 + AddedAt: "2025-10-16T15:00:00Z", 391 391 }, 392 392 }, 393 393 } ··· 407 407 } 408 408 409 409 // Unmarshal from CBOR 410 - var decoded atproto.CrewRecord 410 + var decoded atproto.HoldCrew 411 411 err = decoded.UnmarshalCBOR(bytes.NewReader(cborBytes)) 412 412 if err != nil { 413 413 t.Fatalf("UnmarshalCBOR failed: %v", err) 414 414 } 415 415 416 416 // Verify all fields match 417 - if decoded.Type != tt.record.Type { 418 - t.Errorf("Type mismatch: expected %s, got %s", tt.record.Type, decoded.Type) 417 + if decoded.LexiconTypeID != tt.record.LexiconTypeID { 418 + t.Errorf("LexiconTypeID mismatch: expected %s, got %s", tt.record.LexiconTypeID, decoded.LexiconTypeID) 419 419 } 420 420 if decoded.Member != tt.record.Member { 421 421 t.Errorf("Member mismatch: expected %s, got %s", tt.record.Member, decoded.Member)
+5 -5
pkg/hold/pds/layer.go
··· 9 9 10 10 // CreateLayerRecord creates a new layer record in the hold's PDS 11 11 // Returns the rkey and CID of the created record 12 - func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) { 12 + func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.HoldLayer) (string, string, error) { 13 13 // Validate record 14 - if record.Type != atproto.LayerCollection { 15 - return "", "", fmt.Errorf("invalid record type: %s", record.Type) 14 + if record.LexiconTypeID != atproto.LayerCollection { 15 + return "", "", fmt.Errorf("invalid record type: %s", record.LexiconTypeID) 16 16 } 17 17 18 18 if record.Digest == "" { ··· 40 40 41 41 // GetLayerRecord retrieves a specific layer record by rkey 42 42 // Note: This is a simplified implementation. For production, you may need to pass the CID 43 - func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) { 43 + func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.HoldLayer, error) { 44 44 // For now, we don't implement this as it's not needed for the manifest post feature 45 45 // Full implementation would require querying the carstore with a specific CID 46 46 return nil, fmt.Errorf("GetLayerRecord not yet implemented - use via XRPC listRecords instead") ··· 50 50 // Returns records, next cursor (empty if no more), and error 51 51 // Note: This is a simplified implementation. For production, consider adding filters 52 52 // (by repository, user, digest, etc.) and proper pagination 53 - func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) { 53 + func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.HoldLayer, string, error) { 54 54 // For now, return empty list - full implementation would query the carstore 55 55 // This would require iterating over records in the collection and filtering 56 56 // In practice, layer records are mainly for analytics and Bluesky posts,
+19 -19
pkg/hold/pds/layer_test.go
··· 12 12 13 13 tests := []struct { 14 14 name string 15 - record *atproto.LayerRecord 15 + record *atproto.HoldLayer 16 16 wantErr bool 17 17 errSubstr string 18 18 }{ ··· 42 42 }, 43 43 { 44 44 name: "invalid record type", 45 - record: &atproto.LayerRecord{ 46 - Type: "wrong.type", 45 + record: &atproto.HoldLayer{ 46 + LexiconTypeID: "wrong.type", 47 47 Digest: "sha256:abc123", 48 48 Size: 1024, 49 49 MediaType: "application/vnd.oci.image.layer.v1.tar", 50 50 Repository: "test", 51 - UserDID: "did:plc:test", 51 + UserDid: "did:plc:test", 52 52 UserHandle: "test.example.com", 53 53 }, 54 54 wantErr: true, ··· 56 56 }, 57 57 { 58 58 name: "missing digest", 59 - record: &atproto.LayerRecord{ 60 - Type: atproto.LayerCollection, 59 + record: &atproto.HoldLayer{ 60 + LexiconTypeID: atproto.LayerCollection, 61 61 Digest: "", 62 62 Size: 1024, 63 63 MediaType: "application/vnd.oci.image.layer.v1.tar", 64 64 Repository: "test", 65 - UserDID: "did:plc:test", 65 + UserDid: "did:plc:test", 66 66 UserHandle: "test.example.com", 67 67 }, 68 68 wantErr: true, ··· 70 70 }, 71 71 { 72 72 name: "zero size", 73 - record: &atproto.LayerRecord{ 74 - Type: atproto.LayerCollection, 73 + record: &atproto.HoldLayer{ 74 + LexiconTypeID: atproto.LayerCollection, 75 75 Digest: "sha256:abc123", 76 76 Size: 0, 77 77 MediaType: "application/vnd.oci.image.layer.v1.tar", 78 78 Repository: "test", 79 - UserDID: "did:plc:test", 79 + UserDid: "did:plc:test", 80 80 UserHandle: "test.example.com", 81 81 }, 82 82 wantErr: true, ··· 84 84 }, 85 85 { 86 86 name: "negative size", 87 - record: &atproto.LayerRecord{ 88 - Type: atproto.LayerCollection, 87 + record: &atproto.HoldLayer{ 88 + LexiconTypeID: atproto.LayerCollection, 89 89 Digest: "sha256:abc123", 90 90 Size: -1, 91 91 MediaType: "application/vnd.oci.image.layer.v1.tar", 92 92 Repository: "test", 93 - UserDID: "did:plc:test", 93 + UserDid: "did:plc:test", 94 94 UserHandle: "test.example.com", 95 95 }, 96 96 wantErr: true, ··· 191 191 } 192 192 193 193 // Verify all fields are set correctly 194 - if record.Type != atproto.LayerCollection { 195 - t.Errorf("Type = %q, want %q", record.Type, atproto.LayerCollection) 194 + if record.LexiconTypeID != atproto.LayerCollection { 195 + t.Errorf("LexiconTypeID = %q, want %q", record.LexiconTypeID, atproto.LayerCollection) 196 196 } 197 197 198 198 if record.Digest != digest { ··· 211 211 t.Errorf("Repository = %q, want %q", record.Repository, repository) 212 212 } 213 213 214 - if record.UserDID != userDID { 215 - t.Errorf("UserDID = %q, want %q", record.UserDID, userDID) 214 + if record.UserDid != userDID { 215 + t.Errorf("UserDid = %q, want %q", record.UserDid, userDID) 216 216 } 217 217 218 218 if record.UserHandle != userHandle { ··· 282 282 } 283 283 284 284 // Verify the record can be created 285 - if record.Type != atproto.LayerCollection { 286 - t.Errorf("Type = %q, want %q", record.Type, atproto.LayerCollection) 285 + if record.LexiconTypeID != atproto.LayerCollection { 286 + t.Errorf("Type = %q, want %q", record.LexiconTypeID, atproto.LayerCollection) 287 287 } 288 288 289 289 if record.Digest != tt.digest {
+3 -7
pkg/hold/pds/server.go
··· 19 19 "github.com/ipfs/go-cid" 20 20 ) 21 21 22 - // init registers our custom ATProto types with indigo's lexutil type registry 23 - // This allows repomgr.GetRecord to automatically unmarshal our types 22 + // init registers the TangledProfileRecord type with indigo's lexutil type registry. 23 + // Note: HoldCaptain, HoldCrew, and HoldLayer are registered in pkg/atproto/register.go (generated). 24 + // TangledProfileRecord is external (sh.tangled.actor.profile) so we register it here. 24 25 func init() { 25 - // Register captain, crew, tangled profile, and layer record types 26 - // These must match the $type field in the records 27 - lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{}) 28 - lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{}) 29 - lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{}) 30 26 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 31 27 } 32 28
+6 -6
pkg/hold/pds/server_test.go
··· 150 150 if captain.AllowAllCrew != allowAllCrew { 151 151 t.Errorf("Expected allowAllCrew=%v, got %v", allowAllCrew, captain.AllowAllCrew) 152 152 } 153 - if captain.Type != atproto.CaptainCollection { 154 - t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type) 153 + if captain.LexiconTypeID != atproto.CaptainCollection { 154 + t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID) 155 155 } 156 156 if captain.DeployedAt == "" { 157 157 t.Error("Expected deployedAt to be set") ··· 317 317 if captain == nil { 318 318 t.Fatal("Expected non-nil captain record") 319 319 } 320 - if captain.Type != atproto.CaptainCollection { 321 - t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.Type) 320 + if captain.LexiconTypeID != atproto.CaptainCollection { 321 + t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID) 322 322 } 323 323 324 324 // Do the same for crew record ··· 331 331 } 332 332 333 333 crew := crewMembers[0].Record 334 - if crew.Type != atproto.CrewCollection { 335 - t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.Type) 334 + if crew.LexiconTypeID != atproto.CrewCollection { 335 + t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.LexiconTypeID) 336 336 } 337 337 } 338 338