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

Configure Feed

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

overhaul repo pages, add tab for 'artifacts' (tags, manifests, helm charts). implement digest page with layer commands and vuln reports

+1644 -592
+1
CLAUDE.md
··· 109 109 | `io.atcr.hold.layer` | Per-layer | Layer metadata (digest, size, media type) | 110 110 | `io.atcr.hold.stats` | Per-repo | Push/pull counts per owner+repository | 111 111 | `io.atcr.hold.scan` | Per-scan | Vulnerability scan results | 112 + | `io.atcr.hold.image.config` | Per-manifest | OCI image config (history, env, entrypoint, labels) | 112 113 | `app.bsky.feed.post` | Status posts | Online/offline status, push notifications | 113 114 | `sh.tangled.actor.profile` | Singleton | Hold profile (name, description, avatar) | 114 115
+2
docs/HOLD_XRPC_ENDPOINTS.md
··· 80 80 | `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership | 81 81 | `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export | 82 82 | `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info | 83 + | `/xrpc/io.atcr.hold.getLayersForManifest` | GET | none | Get layer records for a manifest AT-URI | 84 + | `/xrpc/io.atcr.hold.image.getConfig` | GET | none | Get OCI image config record for a manifest digest | 83 85 | `/xrpc/io.atcr.hold.listTiers` | GET | none | List hold's available tiers with quotas and features (scanOnPush) | 84 86 | `/xrpc/io.atcr.hold.updateCrewTier` | POST | appview token | Update crew member's tier | 85 87
+1 -1
pkg/appview/db/delete.go
··· 36 36 } 37 37 38 38 // 3. Delete user (cascades to manifests, tags, stars, annotations, etc.) 39 - if err := DeleteUserData(db, did); err != nil { 39 + if _, err := DeleteUserData(db, did); err != nil { 40 40 slog.Error("Failed to delete user data", "did", did, "error", err) 41 41 return fmt.Errorf("failed to delete user data: %w", err) 42 42 }
+23 -6
pkg/appview/db/models.go
··· 124 124 125 125 // PlatformInfo represents platform information (OS/Architecture) 126 126 type PlatformInfo struct { 127 - OS string 128 - Architecture string 129 - Variant string 130 - OSVersion string 131 - Digest string // child platform manifest digest (for manifest lists) 132 - HoldEndpoint string // hold endpoint for this platform manifest 127 + OS string 128 + Architecture string 129 + Variant string 130 + OSVersion string 131 + Digest string // child platform manifest digest (for manifest lists) 132 + HoldEndpoint string // hold endpoint for this platform manifest 133 + CompressedSize int64 // sum of layer sizes (compressed) 133 134 } 134 135 135 136 // TagWithPlatforms extends Tag with platform information ··· 140 141 IsMultiArch bool 141 142 HasAttestations bool // true if manifest list contains attestation references 142 143 ArtifactType string // container-image, helm-chart, unknown 144 + CompressedSize int64 // sum of layer sizes for single-arch tags 143 145 } 144 146 145 147 // ManifestWithMetadata extends Manifest with tags and platform information ··· 153 155 Reachable bool // Whether the hold endpoint is reachable 154 156 Pending bool // Whether health check is still in progress 155 157 // Note: ArtifactType is available via embedded Manifest struct 158 + } 159 + 160 + // ManifestEntry is a unified view model for the tags tab. 161 + // Every entry is a manifest — labeled by tag name or digest. 162 + type ManifestEntry struct { 163 + Label string // tag name, or digest if untagged 164 + Digest string // manifest digest 165 + IsTagged bool 166 + CreatedAt time.Time 167 + HoldEndpoint string 168 + Platforms []PlatformInfo 169 + IsMultiArch bool 170 + HasAttestations bool 171 + ArtifactType string 172 + CompressedSize int64 // for single-arch 156 173 } 157 174 158 175 // AttestationDetail represents an attestation manifest and its layers
+95 -71
pkg/appview/db/queries.go
··· 692 692 return err 693 693 } 694 694 695 - // GetTagsWithPlatforms returns all tags for a repository with platform information 695 + // LatestTagInfo holds the most recent tag name and its artifact type. 696 + type LatestTagInfo struct { 697 + Tag string 698 + ArtifactType string 699 + } 700 + 701 + // RepositoryExists checks if any manifests exist for a given repository. 702 + func RepositoryExists(db DBTX, did, repository string) (bool, error) { 703 + var count int 704 + err := db.QueryRow(`SELECT COUNT(*) FROM manifests WHERE did = ? AND repository = ? LIMIT 1`, did, repository).Scan(&count) 705 + if err != nil { 706 + return false, err 707 + } 708 + return count > 0, nil 709 + } 710 + 711 + // GetLatestTag returns the most recently created tag and its artifact type for a repository. 712 + // Returns nil if no tags exist. 713 + func GetLatestTag(db DBTX, did, repository string) (*LatestTagInfo, error) { 714 + var info LatestTagInfo 715 + err := db.QueryRow(` 716 + SELECT t.tag, COALESCE(m.artifact_type, 'container-image') 717 + FROM tags t 718 + JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository 719 + WHERE t.did = ? AND t.repository = ? 720 + ORDER BY t.created_at DESC LIMIT 1 721 + `, did, repository).Scan(&info.Tag, &info.ArtifactType) 722 + if err != nil { 723 + return nil, nil // no tags is not an error 724 + } 725 + return &info, nil 726 + } 727 + 728 + // CountTags returns the total number of tags for a repository. 729 + func CountTags(db DBTX, did, repository string) (int, error) { 730 + var count int 731 + err := db.QueryRow(`SELECT COUNT(*) FROM tags WHERE did = ? AND repository = ?`, did, repository).Scan(&count) 732 + return count, err 733 + } 734 + 735 + // GetTagsWithPlatforms returns tags for a repository with platform information 696 736 // Only multi-arch tags (manifest lists) have platform info in manifest_references 697 737 // Single-arch tags will have empty Platforms slice (platform is obvious for single-arch) 698 738 // Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations 699 - func GetTagsWithPlatforms(db DBTX, did, repository string) ([]TagWithPlatforms, error) { 739 + func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) { 700 740 rows, err := db.Query(` 741 + WITH paged_tags AS ( 742 + SELECT id, did, repository, tag, digest, created_at 743 + FROM tags 744 + WHERE did = ? AND repository = ? 745 + ORDER BY created_at DESC 746 + LIMIT ? OFFSET ? 747 + ) 701 748 SELECT 702 749 t.id, 703 750 t.did, ··· 714 761 COALESCE(mr.platform_os_version, '') as platform_os_version, 715 762 COALESCE(mr.is_attestation, 0) as is_attestation, 716 763 COALESCE(mr.digest, '') as child_digest, 717 - COALESCE(child_m.hold_endpoint, m.hold_endpoint, '') as child_hold_endpoint 718 - FROM tags t 764 + COALESCE(child_m.hold_endpoint, m.hold_endpoint, '') as child_hold_endpoint, 765 + COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = COALESCE(child_m.id, m.id)), 0) as compressed_size 766 + FROM paged_tags t 719 767 JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository 720 768 LEFT JOIN manifest_references mr ON m.id = mr.manifest_id 721 769 LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository 722 - WHERE t.did = ? AND t.repository = ? 723 770 ORDER BY t.created_at DESC, mr.reference_index 724 - `, did, repository) 771 + `, did, repository, limit, offset) 725 772 726 773 if err != nil { 727 774 return nil, err ··· 738 785 var platformOS, platformArch, platformVariant, platformOSVersion string 739 786 var isAttestation bool 740 787 var childDigest, childHoldEndpoint string 788 + var compressedSize int64 741 789 742 790 if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt, 743 791 &mediaType, &artifactType, &holdEndpoint, 744 792 &platformOS, &platformArch, &platformVariant, &platformOSVersion, 745 - &isAttestation, &childDigest, &childHoldEndpoint); err != nil { 793 + &isAttestation, &childDigest, &childHoldEndpoint, &compressedSize); err != nil { 746 794 return nil, err 747 795 } 748 796 ··· 750 798 tagKey := t.Tag 751 799 if _, exists := tagMap[tagKey]; !exists { 752 800 tagMap[tagKey] = &TagWithPlatforms{ 753 - Tag: t, 754 - HoldEndpoint: holdEndpoint, 755 - Platforms: []PlatformInfo{}, 756 - ArtifactType: artifactType, 801 + Tag: t, 802 + HoldEndpoint: holdEndpoint, 803 + Platforms: []PlatformInfo{}, 804 + ArtifactType: artifactType, 805 + CompressedSize: compressedSize, // for single-arch (no manifest_references row) 757 806 } 758 807 tagOrder = append(tagOrder, tagKey) 759 808 } ··· 768 817 // Add platform info if present (only for multi-arch manifest lists) 769 818 if platformOS != "" || platformArch != "" { 770 819 tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{ 771 - OS: platformOS, 772 - Architecture: platformArch, 773 - Variant: platformVariant, 774 - OSVersion: platformOSVersion, 775 - Digest: childDigest, 776 - HoldEndpoint: childHoldEndpoint, 820 + OS: platformOS, 821 + Architecture: platformArch, 822 + Variant: platformVariant, 823 + OSVersion: platformOSVersion, 824 + Digest: childDigest, 825 + HoldEndpoint: childHoldEndpoint, 826 + CompressedSize: compressedSize, 777 827 }) 778 828 } 779 829 } ··· 809 859 // 810 860 // Due to ON DELETE CASCADE in the schema, deleting from users will automatically 811 861 // cascade to: manifests, tags, layers, references, annotations, stars, repo_pages, etc. 812 - func DeleteUserData(db DBTX, did string) error { 862 + func DeleteUserData(db DBTX, did string) (bool, error) { 813 863 result, err := db.Exec(`DELETE FROM users WHERE did = ?`, did) 814 864 if err != nil { 815 - return fmt.Errorf("failed to delete user: %w", err) 865 + return false, fmt.Errorf("failed to delete user: %w", err) 816 866 } 817 867 818 868 rowsAffected, _ := result.RowsAffected() 819 - if rowsAffected == 0 { 820 - // User didn't exist, nothing to delete 821 - return nil 822 - } 823 - 824 - return nil 869 + return rowsAffected > 0, nil 825 870 } 826 871 827 872 // GetManifest fetches a single manifest by digest ··· 1086 1131 if manifests[i].IsManifestList { 1087 1132 platformRows, err := db.Query(` 1088 1133 SELECT 1089 - mr.platform_os, 1090 - mr.platform_architecture, 1091 - mr.platform_variant, 1092 - mr.platform_os_version, 1093 - COALESCE(mr.is_attestation, 0) as is_attestation 1134 + COALESCE(mr.platform_os, '') as platform_os, 1135 + COALESCE(mr.platform_architecture, '') as platform_architecture, 1136 + COALESCE(mr.platform_variant, '') as platform_variant, 1137 + COALESCE(mr.platform_os_version, '') as platform_os_version, 1138 + COALESCE(mr.is_attestation, 0) as is_attestation, 1139 + COALESCE(mr.digest, '') as child_digest, 1140 + COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint, 1141 + COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = child_m.id), 0) as compressed_size 1094 1142 FROM manifest_references mr 1143 + LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ? 1095 1144 WHERE mr.manifest_id = ? 1096 1145 ORDER BY mr.reference_index 1097 - `, manifests[i].ID) 1146 + `, manifests[i].DID, manifests[i].Repository, manifests[i].ID) 1098 1147 1099 1148 if err != nil { 1100 1149 return nil, err ··· 1103 1152 manifests[i].Platforms = []PlatformInfo{} 1104 1153 for platformRows.Next() { 1105 1154 var p PlatformInfo 1106 - var os, arch, variant, osVersion sql.NullString 1107 1155 var isAttestation bool 1108 1156 1109 - if err := platformRows.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil { 1157 + if err := platformRows.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion, 1158 + &isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil { 1110 1159 platformRows.Close() 1111 1160 return nil, err 1112 1161 } ··· 1114 1163 // Track if manifest list has attestations 1115 1164 if isAttestation { 1116 1165 manifests[i].HasAttestations = true 1117 - // Skip attestation references in platform display 1118 1166 continue 1119 - } 1120 - 1121 - if os.Valid { 1122 - p.OS = os.String 1123 - } 1124 - if arch.Valid { 1125 - p.Architecture = arch.String 1126 - } 1127 - if variant.Valid { 1128 - p.Variant = variant.String 1129 - } 1130 - if osVersion.Valid { 1131 - p.OSVersion = osVersion.String 1132 1167 } 1133 1168 1134 1169 manifests[i].Platforms = append(manifests[i].Platforms, p) ··· 1190 1225 // Determine if manifest list 1191 1226 m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list") 1192 1227 1193 - // If this is a manifest list, get platform details 1228 + // If this is a manifest list, get platform details with child digests and sizes 1194 1229 if m.IsManifestList { 1195 1230 platforms, err := db.Query(` 1196 1231 SELECT 1197 - mr.platform_os, 1198 - mr.platform_architecture, 1199 - mr.platform_variant, 1200 - mr.platform_os_version, 1201 - COALESCE(mr.is_attestation, 0) as is_attestation 1232 + COALESCE(mr.platform_os, '') as platform_os, 1233 + COALESCE(mr.platform_architecture, '') as platform_architecture, 1234 + COALESCE(mr.platform_variant, '') as platform_variant, 1235 + COALESCE(mr.platform_os_version, '') as platform_os_version, 1236 + COALESCE(mr.is_attestation, 0) as is_attestation, 1237 + COALESCE(mr.digest, '') as child_digest, 1238 + COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint, 1239 + COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = child_m.id), 0) as compressed_size 1202 1240 FROM manifest_references mr 1241 + LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ? 1203 1242 WHERE mr.manifest_id = ? 1204 1243 ORDER BY mr.reference_index 1205 - `, m.ID) 1244 + `, m.DID, m.Repository, m.ID) 1206 1245 1207 1246 if err != nil { 1208 1247 return nil, err ··· 1212 1251 m.Platforms = []PlatformInfo{} 1213 1252 for platforms.Next() { 1214 1253 var p PlatformInfo 1215 - var os, arch, variant, osVersion sql.NullString 1216 1254 var isAttestation bool 1217 1255 1218 - if err := platforms.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil { 1256 + if err := platforms.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion, 1257 + &isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil { 1219 1258 return nil, err 1220 1259 } 1221 1260 1222 - // Track if manifest list has attestations 1223 1261 if isAttestation { 1224 1262 m.HasAttestations = true 1225 - // Skip attestation references in platform display 1226 1263 continue 1227 - } 1228 - 1229 - if os.Valid { 1230 - p.OS = os.String 1231 - } 1232 - if arch.Valid { 1233 - p.Architecture = arch.String 1234 - } 1235 - if variant.Valid { 1236 - p.Variant = variant.String 1237 - } 1238 - if osVersion.Valid { 1239 - p.OSVersion = osVersion.String 1240 1264 } 1241 1265 1242 1266 m.Platforms = append(m.Platforms, p)
+6 -4
pkg/appview/db/queries_test.go
··· 882 882 t.Fatalf("Failed to insert single-arch tag: %v", err) 883 883 } 884 884 885 - tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp") 885 + tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0) 886 886 if err != nil { 887 887 t.Fatalf("Failed to get tags with platforms: %v", err) 888 888 } ··· 951 951 t.Fatalf("Failed to insert multi-arch tag: %v", err) 952 952 } 953 953 954 - multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp") 954 + multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0) 955 955 if err != nil { 956 956 t.Fatalf("Failed to get multi-arch tags with platforms: %v", err) 957 957 } ··· 1280 1280 } 1281 1281 1282 1282 // Delete user data 1283 - if err := DeleteUserData(db, testUser.DID); err != nil { 1283 + if _, err := DeleteUserData(db, testUser.DID); err != nil { 1284 1284 t.Fatalf("Failed to delete user data: %v", err) 1285 1285 } 1286 1286 ··· 1303 1303 } 1304 1304 1305 1305 // Test idempotency - deleting non-existent user should not error 1306 - if err := DeleteUserData(db, testUser.DID); err != nil { 1306 + if deleted, err := DeleteUserData(db, testUser.DID); err != nil { 1307 1307 t.Errorf("Deleting non-existent user should not error, got: %v", err) 1308 + } else if deleted { 1309 + t.Errorf("Deleting non-existent user should return false, got true") 1308 1310 } 1309 1311 } 1310 1312
+265 -142
pkg/appview/handlers/repository.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "net/url" 9 + "strconv" 9 10 "strings" 10 11 "sync" 11 12 "time" ··· 50 51 owner.Handle = resolvedHandle 51 52 } 52 53 53 - // Fetch tags with platform information 54 - tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository) 54 + // Check if repository exists 55 + exists, err := db.RepositoryExists(h.ReadOnlyDB, owner.DID, repository) 55 56 if err != nil { 56 57 http.Error(w, err.Error(), http.StatusInternalServerError) 57 58 return 58 59 } 60 + if !exists { 61 + RenderNotFound(w, r, &h.BaseUIHandler) 62 + return 63 + } 59 64 60 - // Fetch top-level manifests (filters out platform-specific manifests) 61 - manifests, err := db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0) 65 + // Fetch latest tag for pull command 66 + latestTag, err := db.GetLatestTag(h.ReadOnlyDB, owner.DID, repository) 62 67 if err != nil { 63 68 http.Error(w, err.Error(), http.StatusInternalServerError) 64 69 return 65 70 } 66 71 67 - // Check health status for each manifest's hold endpoint (concurrent with 1s timeout) 68 - if h.HealthChecker != nil { 69 - // Create context with 1 second deadline for fast-fail 70 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 71 - defer cancel() 72 - 73 - var wg sync.WaitGroup 74 - var mu sync.Mutex 75 - 76 - for i := range manifests { 77 - if manifests[i].HoldEndpoint == "" { 78 - // No hold endpoint, mark as unreachable 79 - manifests[i].Reachable = false 80 - manifests[i].Pending = false 81 - continue 82 - } 83 - 84 - wg.Go(func() { 85 - endpoint := manifests[i].HoldEndpoint 86 - 87 - // Try to get cached status first (instant) 88 - if cached := h.HealthChecker.GetCachedStatus(endpoint); cached != nil { 89 - mu.Lock() 90 - manifests[i].Reachable = cached.Reachable 91 - manifests[i].Pending = false 92 - mu.Unlock() 93 - return 94 - } 95 - 96 - // Perform health check with timeout context 97 - reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint) 98 - 99 - mu.Lock() 100 - if ctx.Err() == context.DeadlineExceeded { 101 - // Timeout - mark as pending for HTMX polling 102 - manifests[i].Reachable = false 103 - manifests[i].Pending = true 104 - } else if err != nil { 105 - // Error - mark as unreachable 106 - manifests[i].Reachable = false 107 - manifests[i].Pending = false 108 - } else { 109 - // Success 110 - manifests[i].Reachable = reachable 111 - manifests[i].Pending = false 112 - } 113 - mu.Unlock() 114 - }) 115 - } 116 - 117 - // Wait for all checks to complete or timeout 118 - wg.Wait() 119 - } else { 120 - // If no health checker, assume all are reachable (backward compatibility) 121 - for i := range manifests { 122 - manifests[i].Reachable = true 123 - manifests[i].Pending = false 124 - } 125 - } 126 - 127 - if len(tagsWithPlatforms) == 0 && len(manifests) == 0 { 128 - RenderNotFound(w, r, &h.BaseUIHandler) 129 - return 72 + // Determine artifact type from latest tag 73 + artifactType := "container-image" 74 + latestTagName := "" 75 + if latestTag != nil { 76 + latestTagName = latestTag.Tag 77 + artifactType = latestTag.ArtifactType 130 78 } 131 79 132 80 // Create repository summary 133 81 repo := &db.Repository{ 134 - Name: repository, 135 - TagCount: len(tagsWithPlatforms), 136 - ManifestCount: len(manifests), 82 + Name: repository, 137 83 } 138 84 139 85 // Fetch repository metadata from annotations table 140 86 metadata, err := db.GetRepositoryMetadata(h.ReadOnlyDB, owner.DID, repository) 141 87 if err != nil { 142 88 slog.Warn("Failed to fetch repository metadata", "error", err) 143 - // Continue without metadata on error 144 89 } else { 145 90 repo.Title = metadata["org.opencontainers.image.title"] 146 91 repo.Description = metadata["org.opencontainers.image.description"] ··· 156 101 stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository) 157 102 if err != nil { 158 103 slog.Warn("Failed to fetch repository stats", "error", err) 159 - // Continue with zero stats on error 160 104 stats = &db.RepositoryStats{StarCount: 0} 161 105 } 162 106 ··· 164 108 isStarred := false 165 109 user := middleware.GetUser(r) 166 110 if user != nil && h.Refresher != nil && h.Directory != nil { 167 - // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety) 168 111 pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 169 - 170 - // Check if star record exists 171 112 rkey := atproto.StarRecordKey(owner.DID, repository) 172 113 _, err := pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey) 173 114 isStarred = (err == nil) ··· 182 123 // Fetch README content from repo page record or annotations 183 124 var readmeHTML template.HTML 184 125 185 - // Try repo page record from database (synced from PDS via Jetstream) 186 126 repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository) 187 127 if err == nil && repoPage != nil { 188 - // Use repo page avatar if present 189 128 if repoPage.AvatarCID != "" { 190 129 repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) 191 130 } 192 - // Render description as markdown if present 193 131 if repoPage.Description != "" && h.ReadmeFetcher != nil { 194 132 html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description)) 195 133 if err != nil { ··· 199 137 } 200 138 } 201 139 } 202 - // Fall back to fetching README from URL annotations if no description in repo page 203 140 if readmeHTML == "" && h.ReadmeFetcher != nil { 204 - // Fall back to fetching from URL annotations 205 141 readmeURL := repo.ReadmeURL 206 142 if readmeURL == "" && repo.SourceURL != "" { 207 - // Try to derive README URL from source URL 208 143 readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "main") 209 144 if readmeURL == "" { 210 145 readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "master") ··· 220 155 } 221 156 } 222 157 223 - // Determine artifact type for header section from first tag 224 - // This is used for the "Pull this image/chart" header command 225 - artifactType := "container-image" 226 - if len(tagsWithPlatforms) > 0 { 227 - artifactType = tagsWithPlatforms[0].ArtifactType 228 - } else if len(manifests) > 0 { 229 - // Fallback to manifests if no tags 230 - artifactType = manifests[0].ArtifactType 231 - } 232 - 233 - // Collect digests for batch scan-result requests, grouped by hold endpoint 234 - holdDigests := make(map[string][]string) // holdEndpoint → []hexDigest 235 - seen := make(map[string]bool) // dedup digests 236 - for _, t := range tagsWithPlatforms { 237 - if len(t.Platforms) > 0 { 238 - // Multi-arch: collect each platform's child digest 239 - for _, p := range t.Platforms { 240 - if p.Digest != "" && p.HoldEndpoint != "" && !seen[p.Digest] { 241 - seen[p.Digest] = true 242 - hex := strings.TrimPrefix(p.Digest, "sha256:") 243 - holdDigests[p.HoldEndpoint] = append(holdDigests[p.HoldEndpoint], hex) 244 - } 245 - } 246 - } else if t.HoldEndpoint != "" { 247 - // Single-arch: use tag's own digest 248 - if !seen[t.Digest] { 249 - seen[t.Digest] = true 250 - hex := strings.TrimPrefix(t.Digest, "sha256:") 251 - holdDigests[t.HoldEndpoint] = append(holdDigests[t.HoldEndpoint], hex) 252 - } 253 - } 254 - } 255 - var scanBatchParams []template.HTML 256 - for hold, digests := range holdDigests { 257 - scanBatchParams = append(scanBatchParams, template.HTML( 258 - "holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests, ","))) 259 - } 260 - 261 158 // Build page meta 262 159 title := owner.Handle + "/" + repository + " - " + h.ClientShortName 263 160 if repo.Title != "" { ··· 284 181 285 182 data := struct { 286 183 PageData 287 - Meta *PageMeta 288 - Owner *db.User // Repository owner 289 - Repository *db.Repository // Repository summary 290 - Tags []db.TagWithPlatforms // Tags with platform info 291 - Manifests []db.ManifestWithMetadata // Top-level manifests only 292 - StarCount int 293 - PullCount int 294 - IsStarred bool 295 - IsOwner bool // Whether current user owns this repository 296 - ReadmeHTML template.HTML 297 - ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown 298 - ScanBatchParams []template.HTML // Pre-encoded query strings for batch scan-result endpoint (one per hold) 184 + Meta *PageMeta 185 + Owner *db.User 186 + Repository *db.Repository 187 + LatestTag string 188 + StarCount int 189 + PullCount int 190 + IsStarred bool 191 + IsOwner bool 192 + ReadmeHTML template.HTML 193 + ArtifactType string 194 + }{ 195 + PageData: NewPageData(r, &h.BaseUIHandler), 196 + Meta: meta, 197 + Owner: owner, 198 + Repository: repo, 199 + LatestTag: latestTagName, 200 + StarCount: stats.StarCount, 201 + PullCount: stats.PullCount, 202 + IsStarred: isStarred, 203 + IsOwner: isOwner, 204 + ReadmeHTML: readmeHTML, 205 + ArtifactType: artifactType, 206 + } 207 + 208 + if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { 209 + http.Error(w, err.Error(), http.StatusInternalServerError) 210 + return 211 + } 212 + } 213 + 214 + // RepositoryTagsHandler returns the tags+manifests HTMX partial for a repository 215 + type RepositoryTagsHandler struct { 216 + BaseUIHandler 217 + } 218 + 219 + func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 220 + identifier := chi.URLParam(r, "handle") 221 + repository := strings.TrimPrefix(chi.URLParam(r, "*"), "/") 222 + 223 + did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier) 224 + if err != nil { 225 + http.Error(w, "Not found", http.StatusNotFound) 226 + return 227 + } 228 + 229 + owner, err := db.GetUserByDID(h.ReadOnlyDB, did) 230 + if err != nil || owner == nil { 231 + http.Error(w, "Not found", http.StatusNotFound) 232 + return 233 + } 234 + 235 + // Parse pagination 236 + const pageSize = 50 237 + offset := 0 238 + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { 239 + if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed > 0 { 240 + offset = parsed 241 + } 242 + } 243 + 244 + // Count total tags for pagination 245 + totalTags, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository) 246 + if err != nil { 247 + http.Error(w, err.Error(), http.StatusInternalServerError) 248 + return 249 + } 250 + 251 + // Fetch tags with platform information and compressed sizes 252 + tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset) 253 + if err != nil { 254 + http.Error(w, err.Error(), http.StatusInternalServerError) 255 + return 256 + } 257 + 258 + // Fetch untagged manifests only on first page 259 + var manifests []db.ManifestWithMetadata 260 + if offset == 0 { 261 + manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0) 262 + if err != nil { 263 + http.Error(w, err.Error(), http.StatusInternalServerError) 264 + return 265 + } 266 + } 267 + 268 + // Check health status for each manifest's hold endpoint 269 + if h.HealthChecker != nil { 270 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 271 + defer cancel() 272 + 273 + var wg sync.WaitGroup 274 + var mu sync.Mutex 275 + 276 + for i := range manifests { 277 + if manifests[i].HoldEndpoint == "" { 278 + manifests[i].Reachable = false 279 + manifests[i].Pending = false 280 + continue 281 + } 282 + 283 + wg.Go(func() { 284 + endpoint := manifests[i].HoldEndpoint 285 + 286 + if cached := h.HealthChecker.GetCachedStatus(endpoint); cached != nil { 287 + mu.Lock() 288 + manifests[i].Reachable = cached.Reachable 289 + manifests[i].Pending = false 290 + mu.Unlock() 291 + return 292 + } 293 + 294 + reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint) 295 + 296 + mu.Lock() 297 + if ctx.Err() == context.DeadlineExceeded { 298 + manifests[i].Reachable = false 299 + manifests[i].Pending = true 300 + } else if err != nil { 301 + manifests[i].Reachable = false 302 + manifests[i].Pending = false 303 + } else { 304 + manifests[i].Reachable = reachable 305 + manifests[i].Pending = false 306 + } 307 + mu.Unlock() 308 + }) 309 + } 310 + 311 + wg.Wait() 312 + } else { 313 + for i := range manifests { 314 + manifests[i].Reachable = true 315 + manifests[i].Pending = false 316 + } 317 + } 318 + 319 + // Check if current user is the repository owner 320 + isOwner := false 321 + user := middleware.GetUser(r) 322 + if user != nil { 323 + isOwner = (user.DID == owner.DID) 324 + } 325 + 326 + // Build unified entries list: tagged first, then untagged. 327 + // Single-arch entries get a one-element Platforms slice so the template 328 + // can always just range over .Platforms without branching. 329 + var entries []db.ManifestEntry 330 + for _, t := range tagsWithPlatforms { 331 + platforms := t.Platforms 332 + if len(platforms) == 0 { 333 + platforms = []db.PlatformInfo{{ 334 + Digest: t.Digest, 335 + HoldEndpoint: t.HoldEndpoint, 336 + CompressedSize: t.CompressedSize, 337 + }} 338 + } 339 + entries = append(entries, db.ManifestEntry{ 340 + Label: t.Tag.Tag, 341 + Digest: t.Digest, 342 + IsTagged: true, 343 + CreatedAt: t.CreatedAt, 344 + HoldEndpoint: t.HoldEndpoint, 345 + Platforms: platforms, 346 + IsMultiArch: t.IsMultiArch, 347 + HasAttestations: t.HasAttestations, 348 + ArtifactType: t.ArtifactType, 349 + }) 350 + } 351 + for _, m := range manifests { 352 + if len(m.Tags) > 0 { 353 + continue 354 + } 355 + platforms := m.Platforms 356 + if len(platforms) == 0 { 357 + platforms = []db.PlatformInfo{{ 358 + Digest: m.Digest, 359 + HoldEndpoint: m.HoldEndpoint, 360 + }} 361 + } 362 + entries = append(entries, db.ManifestEntry{ 363 + Label: m.Digest, 364 + Digest: m.Digest, 365 + IsTagged: false, 366 + CreatedAt: m.CreatedAt, 367 + HoldEndpoint: m.HoldEndpoint, 368 + Platforms: platforms, 369 + IsMultiArch: m.IsManifestList, 370 + HasAttestations: m.HasAttestations, 371 + ArtifactType: m.ArtifactType, 372 + }) 373 + } 374 + 375 + // Collect digests for batch scan-result requests 376 + holdDigests := make(map[string][]string) 377 + seen := make(map[string]bool) 378 + for _, e := range entries { 379 + if len(e.Platforms) > 0 { 380 + for _, p := range e.Platforms { 381 + if p.Digest != "" && p.HoldEndpoint != "" && !seen[p.Digest] { 382 + seen[p.Digest] = true 383 + hex := strings.TrimPrefix(p.Digest, "sha256:") 384 + holdDigests[p.HoldEndpoint] = append(holdDigests[p.HoldEndpoint], hex) 385 + } 386 + } 387 + } else if e.HoldEndpoint != "" { 388 + if !seen[e.Digest] { 389 + seen[e.Digest] = true 390 + hex := strings.TrimPrefix(e.Digest, "sha256:") 391 + holdDigests[e.HoldEndpoint] = append(holdDigests[e.HoldEndpoint], hex) 392 + } 393 + } 394 + } 395 + var scanBatchParams []template.HTML 396 + for hold, digests := range holdDigests { 397 + // Chunk into batches of 50 to match the batch handler's limit 398 + for i := 0; i < len(digests); i += 50 { 399 + end := i + 50 400 + if end > len(digests) { 401 + end = len(digests) 402 + } 403 + scanBatchParams = append(scanBatchParams, template.HTML( 404 + "holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests[i:end], ","))) 405 + } 406 + } 407 + 408 + hasMore := offset+pageSize < totalTags 409 + isFirstPage := offset == 0 410 + 411 + data := struct { 412 + Owner *db.User 413 + Repository *db.Repository 414 + Entries []db.ManifestEntry 415 + IsOwner bool 416 + ScanBatchParams []template.HTML 417 + RegistryURL string 418 + HasMore bool 419 + NextOffset int 420 + IsFirstPage bool 299 421 }{ 300 - PageData: NewPageData(r, &h.BaseUIHandler), 301 - Meta: meta, 302 422 Owner: owner, 303 - Repository: repo, 304 - Tags: tagsWithPlatforms, 305 - Manifests: manifests, 306 - StarCount: stats.StarCount, 307 - PullCount: stats.PullCount, 308 - IsStarred: isStarred, 423 + Repository: &db.Repository{Name: repository}, 424 + Entries: entries, 309 425 IsOwner: isOwner, 310 - ReadmeHTML: readmeHTML, 311 - ArtifactType: artifactType, 312 426 ScanBatchParams: scanBatchParams, 427 + RegistryURL: h.RegistryURL, 428 + HasMore: hasMore, 429 + NextOffset: offset + pageSize, 430 + IsFirstPage: isFirstPage, 313 431 } 314 432 315 - if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { 433 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 434 + templateName := "repo-tags" 435 + if !isFirstPage { 436 + templateName = "repo-tags-page" 437 + } 438 + if err := h.Templates.ExecuteTemplate(w, templateName, data); err != nil { 316 439 http.Error(w, err.Error(), http.StatusInternalServerError) 317 440 return 318 441 }
+37 -57
pkg/appview/handlers/scan_result.go
··· 49 49 return 50 50 } 51 51 52 - // Resolve hold identity: holdEndpoint may be a DID or URL 53 - holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) 54 - if err != nil { 55 - slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err) 56 - h.renderBadge(w, vulnBadgeData{Error: true}) 57 - return 58 - } 59 - 60 - // Check if this hold has a successor — scan records may live there instead 61 - resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID) 62 - 63 - // Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID; 64 - // otherwise use the original holdEndpoint (which may already be a URL). 65 - holdURLTarget := holdEndpoint 66 - if resolvedHoldDID != holdDID { 67 - holdDID = resolvedHoldDID 68 - holdURLTarget = resolvedHoldDID 69 - } 70 - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget) 52 + hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint) 71 53 if err != nil { 72 - slog.Debug("Failed to resolve hold URL", "holdEndpoint", holdEndpoint, "error", err) 54 + slog.Debug("Failed to resolve hold", "holdEndpoint", holdEndpoint, "error", err) 73 55 h.renderBadge(w, vulnBadgeData{Error: true}) 74 56 return 75 57 } 58 + holdDID := hold.DID 59 + holdURL := hold.URL 76 60 77 61 // Compute rkey from digest (strip sha256: prefix) 78 62 rkey := strings.TrimPrefix(digest, "sha256:") ··· 226 210 digests = digests[:50] 227 211 } 228 212 229 - holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) 213 + hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint) 230 214 if err != nil { 231 - // Can't resolve hold — render empty OOB spans 232 - slog.Debug("Failed to resolve hold DID for batch scan", "holdEndpoint", holdEndpoint, "error", err) 215 + slog.Debug("Failed to resolve hold for batch scan", "holdEndpoint", holdEndpoint, "error", err) 233 216 w.Header().Set("Content-Type", "text/html") 234 217 for _, d := range digests { 235 218 fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d)) 236 219 } 237 220 return 238 221 } 239 - 240 - // Check if this hold has a successor — scan records may live there instead 241 - resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID) 242 - 243 - // Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID; 244 - // otherwise use the original holdEndpoint (which may already be a URL). 245 - holdURLTarget := holdEndpoint 246 - if resolvedHoldDID != holdDID { 247 - holdDID = resolvedHoldDID 248 - holdURLTarget = resolvedHoldDID 249 - } 250 - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget) 251 - if err != nil { 252 - slog.Debug("Failed to resolve hold URL for batch scan", "holdEndpoint", holdEndpoint, "error", err) 253 - w.Header().Set("Content-Type", "text/html") 254 - for _, d := range digests { 255 - fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d)) 256 - } 257 - return 258 - } 222 + holdDID := hold.DID 223 + holdURL := hold.URL 259 224 260 225 // Fetch scan records concurrently with a semaphore to limit parallelism 261 226 type result struct { ··· 294 259 } 295 260 } 296 261 297 - // resolveHoldSuccessor checks if a hold has a successor in the cached captain records. 298 - // Returns the successor DID if set, otherwise returns the original holdDID. 299 - // Single-hop only — does not follow chains. 300 - func resolveHoldSuccessor(database *sql.DB, holdDID string) string { 301 - if database == nil { 302 - return holdDID 262 + // ResolvedHold contains the resolved DID and URL for a hold endpoint, 263 + // after following any successor chain. 264 + type ResolvedHold struct { 265 + DID string 266 + URL string 267 + } 268 + 269 + // ResolveHold resolves a hold endpoint (DID, URL, or hostname) to its final 270 + // DID and URL, following a single successor hop if one exists in the captain records. 271 + func ResolveHold(ctx context.Context, database *sql.DB, holdEndpoint string) (*ResolvedHold, error) { 272 + holdDID, err := atproto.ResolveHoldDID(ctx, holdEndpoint) 273 + if err != nil { 274 + return nil, fmt.Errorf("resolve hold DID: %w", err) 303 275 } 304 - captain, err := db.GetCaptainRecord(database, holdDID) 305 - if err != nil || captain == nil { 306 - return holdDID 276 + 277 + // Check for successor 278 + resolveTarget := holdEndpoint 279 + if database != nil { 280 + captain, err := db.GetCaptainRecord(database, holdDID) 281 + if err == nil && captain != nil && captain.Successor != "" { 282 + slog.Debug("Following hold successor", "from", holdDID, "to", captain.Successor) 283 + holdDID = captain.Successor 284 + resolveTarget = captain.Successor 285 + } 307 286 } 308 - if captain.Successor != "" { 309 - slog.Debug("Scan result: following hold successor", 310 - "from", holdDID, "to", captain.Successor) 311 - return captain.Successor 287 + 288 + holdURL, err := atproto.ResolveHoldURL(ctx, resolveTarget) 289 + if err != nil { 290 + return nil, fmt.Errorf("resolve hold URL: %w", err) 312 291 } 313 - return holdDID 292 + 293 + return &ResolvedHold{DID: holdDID, URL: holdURL}, nil 314 294 }
+65 -6
pkg/appview/handlers/scan_result_test.go
··· 110 110 if !strings.Contains(body, `data-tip="Low">3<`) { 111 111 t.Error("Expected low count of 3") 112 112 } 113 - // Should be clickable (has openVulnDetails) 114 - if !strings.Contains(body, "openVulnDetails") { 115 - t.Error("Expected body to contain openVulnDetails click handler") 113 + // Should show vulnerability strip with tooltip 114 + if !strings.Contains(body, "vuln-strip") { 115 + t.Error("Expected body to contain vuln-strip class") 116 116 } 117 117 } 118 118 ··· 141 141 if !strings.Contains(body, "badge-success") { 142 142 t.Error("Expected body to contain badge-success for clean scan") 143 143 } 144 - // Should NOT be clickable 145 - if strings.Contains(body, "openVulnDetails") { 146 - t.Error("Clean badge should not have openVulnDetails click handler") 144 + // Clean badge should not have vuln-strip 145 + if strings.Contains(body, "vuln-strip") { 146 + t.Error("Clean badge should not have vuln-strip") 147 147 } 148 148 } 149 149 ··· 412 412 // Should NOT contain vulnerability badges 413 413 if strings.Contains(body, "badge-error") || strings.Contains(body, "Clean") { 414 414 t.Error("Unreachable hold should not render badge content") 415 + } 416 + } 417 + 418 + // --- ResolveHold tests --- 419 + 420 + func TestResolveHold_DirectURL(t *testing.T) { 421 + // Mock hold that serves DID resolution 422 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 423 + if r.URL.Path == "/.well-known/atproto-did" { 424 + w.Write([]byte("did:web:hold.example.com")) 425 + return 426 + } 427 + http.Error(w, "not found", http.StatusNotFound) 428 + })) 429 + defer hold.Close() 430 + 431 + resolved, err := handlers.ResolveHold(t.Context(), nil, hold.URL) 432 + if err != nil { 433 + t.Fatalf("ResolveHold failed: %v", err) 434 + } 435 + if resolved.DID != "did:web:hold.example.com" { 436 + t.Errorf("DID = %q, want %q", resolved.DID, "did:web:hold.example.com") 437 + } 438 + if resolved.URL != hold.URL { 439 + t.Errorf("URL = %q, want %q", resolved.URL, hold.URL) 440 + } 441 + } 442 + 443 + func TestResolveHold_NilDB_NoSuccessor(t *testing.T) { 444 + // With nil DB, successor check is skipped 445 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 446 + if r.URL.Path == "/.well-known/atproto-did" { 447 + w.Write([]byte("did:web:hold.example.com")) 448 + return 449 + } 450 + http.Error(w, "not found", http.StatusNotFound) 451 + })) 452 + defer hold.Close() 453 + 454 + resolved, err := handlers.ResolveHold(t.Context(), nil, hold.URL) 455 + if err != nil { 456 + t.Fatalf("ResolveHold failed: %v", err) 457 + } 458 + // Should resolve to the original hold since no DB to check successor 459 + if resolved.DID != "did:web:hold.example.com" { 460 + t.Errorf("DID = %q, want %q", resolved.DID, "did:web:hold.example.com") 461 + } 462 + if resolved.URL != hold.URL { 463 + t.Errorf("URL = %q, want %q", resolved.URL, hold.URL) 464 + } 465 + } 466 + 467 + func TestResolveHold_Unreachable(t *testing.T) { 468 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 469 + hold.Close() 470 + 471 + _, err := handlers.ResolveHold(t.Context(), nil, hold.URL) 472 + if err == nil { 473 + t.Error("Expected error for unreachable hold") 415 474 } 416 475 } 417 476
+143 -9
pkg/appview/handlers/vuln_details.go
··· 97 97 return 98 98 } 99 99 100 - holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) 100 + hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint) 101 101 if err != nil { 102 - slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err) 103 - h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold identity"}) 104 - return 105 - } 106 - 107 - // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) 108 - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) 109 - if err != nil { 102 + slog.Debug("Failed to resolve hold", "holdEndpoint", holdEndpoint, "error", err) 110 103 h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold endpoint"}) 111 104 return 112 105 } 106 + holdDID := hold.DID 107 + holdURL := hold.URL 113 108 114 109 rkey := strings.TrimPrefix(digest, "sha256:") 115 110 ··· 255 250 slog.Warn("Failed to render vuln details", "error", err) 256 251 } 257 252 } 253 + 254 + // FetchVulnDetails fetches vulnerability scan details for a digest from a hold. 255 + // This is the shared logic used by both VulnDetailsHandler and DigestDetailHandler. 256 + // holdEndpoint should already be resolved (successor-aware) before calling this. 257 + func FetchVulnDetails(ctx context.Context, holdEndpoint, digest string) vulnDetailsData { 258 + holdDID, err := atproto.ResolveHoldDID(ctx, holdEndpoint) 259 + if err != nil { 260 + return vulnDetailsData{Error: "Could not resolve hold identity"} 261 + } 262 + 263 + holdURL, err := atproto.ResolveHoldURL(ctx, holdEndpoint) 264 + if err != nil { 265 + return vulnDetailsData{Error: "Could not resolve hold endpoint"} 266 + } 267 + 268 + rkey := strings.TrimPrefix(digest, "sha256:") 269 + 270 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 271 + defer cancel() 272 + 273 + // Fetch the scan record 274 + scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 275 + holdURL, 276 + url.QueryEscape(holdDID), 277 + url.QueryEscape(atproto.ScanCollection), 278 + url.QueryEscape(rkey), 279 + ) 280 + 281 + req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil) 282 + if err != nil { 283 + return vulnDetailsData{Error: "Failed to build request"} 284 + } 285 + 286 + resp, err := http.DefaultClient.Do(req) 287 + if err != nil { 288 + return vulnDetailsData{Error: "Hold service unreachable"} 289 + } 290 + defer resp.Body.Close() 291 + 292 + if resp.StatusCode != http.StatusOK { 293 + return vulnDetailsData{Error: "No scan record found"} 294 + } 295 + 296 + var envelope struct { 297 + Value json.RawMessage `json:"value"` 298 + } 299 + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 300 + return vulnDetailsData{Error: "Failed to parse scan record"} 301 + } 302 + 303 + var scanRecord atproto.ScanRecord 304 + if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil { 305 + return vulnDetailsData{Error: "Failed to parse scan record"} 306 + } 307 + 308 + summary := vulnSummary{ 309 + Critical: scanRecord.Critical, 310 + High: scanRecord.High, 311 + Medium: scanRecord.Medium, 312 + Low: scanRecord.Low, 313 + Total: scanRecord.Total, 314 + } 315 + 316 + // Fetch the vulnerability report blob 317 + if scanRecord.VulnReportBlob == nil || scanRecord.VulnReportBlob.Ref.String() == "" { 318 + return vulnDetailsData{ 319 + Summary: summary, 320 + ScannedAt: scanRecord.ScannedAt, 321 + Error: "No detailed vulnerability report available. Only summary counts were recorded.", 322 + } 323 + } 324 + 325 + blobCID := scanRecord.VulnReportBlob.Ref.String() 326 + blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 327 + holdURL, 328 + url.QueryEscape(holdDID), 329 + url.QueryEscape(blobCID), 330 + ) 331 + 332 + blobReq, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil) 333 + if err != nil { 334 + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to build blob request"} 335 + } 336 + 337 + blobResp, err := http.DefaultClient.Do(blobReq) 338 + if err != nil { 339 + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to fetch vulnerability report"} 340 + } 341 + defer blobResp.Body.Close() 342 + 343 + if blobResp.StatusCode != http.StatusOK { 344 + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Vulnerability report not accessible"} 345 + } 346 + 347 + var report grypeReport 348 + if err := json.NewDecoder(blobResp.Body).Decode(&report); err != nil { 349 + return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to parse vulnerability report"} 350 + } 351 + 352 + matches := make([]vulnMatch, 0, len(report.Matches)) 353 + for _, m := range report.Matches { 354 + fixedIn := "" 355 + if len(m.Vulnerability.Fix.Versions) > 0 { 356 + fixedIn = strings.Join(m.Vulnerability.Fix.Versions, ", ") 357 + } 358 + 359 + cveURL := "" 360 + if strings.HasPrefix(m.Vulnerability.ID, "CVE-") { 361 + cveURL = "https://nvd.nist.gov/vuln/detail/" + m.Vulnerability.ID 362 + } else if strings.HasPrefix(m.Vulnerability.ID, "GHSA-") { 363 + cveURL = "https://github.com/advisories/" + m.Vulnerability.ID 364 + } 365 + 366 + matches = append(matches, vulnMatch{ 367 + CVEID: m.Vulnerability.ID, 368 + CVEURL: cveURL, 369 + Severity: m.Vulnerability.Metadata.Severity, 370 + Package: m.Package.Name, 371 + Version: m.Package.Version, 372 + FixedIn: fixedIn, 373 + Type: m.Package.Type, 374 + }) 375 + } 376 + 377 + sort.Slice(matches, func(i, j int) bool { 378 + oi := severityOrder[matches[i].Severity] 379 + oj := severityOrder[matches[j].Severity] 380 + if oi != oj { 381 + return oi < oj 382 + } 383 + return matches[i].CVEID < matches[j].CVEID 384 + }) 385 + 386 + return vulnDetailsData{ 387 + Matches: matches, 388 + Summary: summary, 389 + ScannedAt: scanRecord.ScannedAt, 390 + } 391 + }
+3 -3
pkg/appview/handlers/vuln_details_test.go
··· 222 222 t.Error("Expected body to contain fix version '1.2.4'") 223 223 } 224 224 225 - // Should contain "No fix" for unfixed vuln 226 - if !strings.Contains(body, "No fix") { 227 - t.Error("Expected body to contain 'No fix' for unfixed vulnerability") 225 + // Should contain "-" placeholder for unfixed vuln 226 + if !strings.Contains(body, `opacity-40`) { 227 + t.Error("Expected body to contain opacity-40 placeholder for unfixed vulnerability") 228 228 } 229 229 230 230 // Should contain a table
+107 -30
pkg/appview/jetstream/backfill.go
··· 151 151 strings.Contains(errStr, "Could not find repo") || 152 152 strings.Contains(errStr, "status 400") || 153 153 strings.Contains(errStr, "status 404") { 154 - if delErr := db.DeleteUserData(b.db, repo.DID); delErr != nil { 154 + deleted, delErr := db.DeleteUserData(b.db, repo.DID) 155 + if delErr != nil { 155 156 slog.Warn("Backfill failed to delete data for removed repo", "did", repo.DID, "error", delErr) 156 - } else { 157 + } else if deleted { 157 158 slog.Info("Backfill cleaned up data for deleted/deactivated repo", "did", repo.DID) 158 159 } 159 160 } else { ··· 183 184 } 184 185 185 186 // backfillRepo backfills all records for a single repo/DID. 186 - // Per-record processing is wrapped in a single SQL transaction to batch writes 187 - // (one commit per repo instead of per-statement). 187 + // Records are fetched from PDS first, then network-dependent caches are warmed, 188 + // and finally DB writes happen in chunked transactions to batch writes while 189 + // staying under the remote SQLite transaction timeout (~5s on Bunny Database). 188 190 func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection string) (int, error) { 189 191 // Resolve DID to get user's PDS endpoint 190 192 pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, did) ··· 193 195 } 194 196 195 197 // Create a client for this user's PDS with the user's DID 196 - // This allows GetRecord to work properly with the repo parameter 197 198 pdsClient := atproto.NewClient(pdsEndpoint, did, "") 198 199 199 - // Begin transaction for per-record processing (batches all writes into one commit) 200 - tx, err := b.db.Begin() 201 - if err != nil { 202 - return 0, fmt.Errorf("failed to begin transaction: %w", err) 203 - } 204 - defer tx.Rollback() 205 - 206 - // Create a transactional processor — all DB writes go through this tx 207 - txProcessor := NewProcessor(tx, false, b.processor.statsCache) 208 - 209 200 var recordCursor string 210 - recordCount := 0 211 201 212 202 // Track which records exist on the PDS for reconciliation 213 203 var foundManifestDigests []string 214 204 var foundTags []struct{ Repository, Tag string } 215 205 foundStars := make(map[string]time.Time) // key: "ownerDID/repository", value: createdAt 216 206 217 - // Paginate through all records for this repo 207 + // Phase 1: Collect all records from PDS (network I/O, no transaction) 208 + var allRecords []atproto.Record 218 209 for { 219 210 records, cursor, err := pdsClient.ListRecordsForRepo(ctx, did, collection, 100, recordCursor) 220 211 if err != nil { 221 - return recordCount, fmt.Errorf("failed to list records: %w", err) 212 + return 0, fmt.Errorf("failed to list records: %w", err) 222 213 } 223 214 224 - // Process each record 225 215 for _, record := range records { 226 - // Track what we found for deletion reconciliation 227 216 switch collection { 228 217 case atproto.ManifestCollection: 229 218 var manifestRecord atproto.ManifestRecord ··· 248 237 } 249 238 } 250 239 251 - if err := b.processRecordWith(ctx, txProcessor, did, collection, &record); err != nil { 252 - slog.Warn("Backfill failed to process record", "uri", record.URI, "error", err) 253 - continue 254 - } 255 - recordCount++ 240 + allRecords = append(allRecords, record) 256 241 } 257 242 258 - // Check if there are more pages 259 243 if cursor == "" { 260 244 break 261 245 } 246 + recordCursor = cursor 247 + } 262 248 263 - recordCursor = cursor 249 + // Phase 2: Pre-warm caches outside any transaction so that ProcessRecord 250 + // inside transactions hits only DB (no network I/O that could cause timeouts). 251 + 252 + // Ensure user exists in DB (resolves DID → handle/PDS, fetches profile) 253 + switch collection { 254 + case atproto.SailorProfileCollection: 255 + if err := b.processor.EnsureUser(ctx, did); err != nil { 256 + slog.Warn("Backfill failed to pre-ensure user", "did", did, "error", err) 257 + } 258 + case atproto.ManifestCollection, atproto.TagCollection, atproto.StarCollection, atproto.RepoPageCollection: 259 + if err := b.processor.EnsureUserExists(ctx, did); err != nil { 260 + slog.Warn("Backfill failed to pre-ensure user", "did", did, "error", err) 261 + } 264 262 } 265 263 266 - // Commit all per-record writes in one batch 267 - if err := tx.Commit(); err != nil { 268 - return 0, fmt.Errorf("failed to commit transaction: %w", err) 264 + // Pre-cache hold DIDs and captain records referenced in records. 265 + // ProcessSailorProfile calls ResolveHoldDID + queryCaptainFn, 266 + // ProcessManifest calls ResolveHoldDID for legacy manifests. 267 + b.prewarmHoldCaches(ctx, collection, allRecords) 268 + 269 + // Phase 3: Process records in chunked transactions. 270 + // All network I/O should be cached by now, so transactions stay fast. 271 + const chunkSize = 20 272 + recordCount := 0 273 + 274 + for i := 0; i < len(allRecords); i += chunkSize { 275 + end := i + chunkSize 276 + if end > len(allRecords) { 277 + end = len(allRecords) 278 + } 279 + 280 + tx, err := b.db.Begin() 281 + if err != nil { 282 + return recordCount, fmt.Errorf("failed to begin transaction: %w", err) 283 + } 284 + 285 + txProcessor := NewProcessor(tx, false, b.processor.statsCache) 286 + 287 + for j := i; j < end; j++ { 288 + if err := b.processRecordWith(ctx, txProcessor, did, collection, &allRecords[j]); err != nil { 289 + slog.Warn("Backfill failed to process record", "uri", allRecords[j].URI, "error", err) 290 + continue 291 + } 292 + recordCount++ 293 + } 294 + 295 + if err := tx.Commit(); err != nil { 296 + tx.Rollback() 297 + return recordCount, fmt.Errorf("failed to commit transaction: %w", err) 298 + } 269 299 } 270 300 271 301 // Reconciliation runs outside the transaction (involves network I/O and fewer writes) ··· 352 382 } 353 383 354 384 return nil 385 + } 386 + 387 + // prewarmHoldCaches resolves hold DIDs and caches captain records before 388 + // records are processed inside transactions. This ensures ProcessRecord's 389 + // network-dependent code paths (ResolveHoldDID, queryCaptainRecord) hit 390 + // cached data so transactions stay fast and don't timeout. 391 + func (b *BackfillWorker) prewarmHoldCaches(ctx context.Context, collection string, records []atproto.Record) { 392 + seen := make(map[string]bool) 393 + 394 + for _, record := range records { 395 + var holdRef string 396 + 397 + switch collection { 398 + case atproto.SailorProfileCollection: 399 + var profileRecord atproto.SailorProfileRecord 400 + if err := json.Unmarshal(record.Value, &profileRecord); err == nil { 401 + holdRef = profileRecord.DefaultHold 402 + } 403 + case atproto.ManifestCollection: 404 + var manifestRecord atproto.ManifestRecord 405 + if err := json.Unmarshal(record.Value, &manifestRecord); err == nil { 406 + // Only legacy manifests need network resolution (URL → DID) 407 + if manifestRecord.HoldDID == "" && manifestRecord.HoldEndpoint != "" { 408 + holdRef = manifestRecord.HoldEndpoint 409 + } 410 + } 411 + default: 412 + return // No hold references in other collections 413 + } 414 + 415 + if holdRef == "" || seen[holdRef] { 416 + continue 417 + } 418 + seen[holdRef] = true 419 + 420 + // Resolve hold identifier to DID (caches in resolver) 421 + holdDID, err := atproto.ResolveHoldDID(ctx, holdRef) 422 + if err != nil { 423 + slog.Warn("Backfill failed to pre-resolve hold DID", "hold_ref", holdRef, "error", err) 424 + continue 425 + } 426 + 427 + // Pre-cache captain record (skips if cached within last hour) 428 + if err := b.queryCaptainRecord(ctx, holdDID); err != nil { 429 + slog.Warn("Backfill failed to pre-cache captain record", "hold_did", holdDID, "error", err) 430 + } 431 + } 355 432 } 356 433 357 434 // processRecordWith processes a single record using the given processor.
+1 -1
pkg/appview/jetstream/processor.go
··· 925 925 switch status { 926 926 case "deleted": 927 927 // Account permanently deleted - remove all cached data 928 - if err := db.DeleteUserData(p.db, did); err != nil { 928 + if _, err := db.DeleteUserData(p.db, did); err != nil { 929 929 slog.Error("Failed to delete user data for deleted account", 930 930 "component", "processor", 931 931 "did", did,
+2 -2
pkg/appview/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 - <symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol> 10 9 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 11 10 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> 12 11 <symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol> ··· 26 25 <symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol> 27 26 <symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol> 28 27 <symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol> 28 + <symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol> 29 29 <symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol> 30 30 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 31 31 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 32 - <symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol> 33 32 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol> 34 33 <symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol> 35 34 <symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol> 36 35 <symbol id="refresh-ccw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></symbol> 36 + <symbol id="refresh-cw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol> 37 37 <symbol id="save" viewBox="0 0 24 24"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></symbol> 38 38 <symbol id="search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></symbol> 39 39 <symbol id="server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
+8
pkg/appview/routes/routes.go
··· 151 151 &uihandlers.RepositoryPageHandler{BaseUIHandler: base}, 152 152 ).ServeHTTP) 153 153 154 + router.Get("/api/repo-tags/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 155 + &uihandlers.RepositoryTagsHandler{BaseUIHandler: base}, 156 + ).ServeHTTP) 157 + 158 + router.Get("/d/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 159 + &uihandlers.DigestDetailHandler{BaseUIHandler: base}, 160 + ).ServeHTTP) 161 + 154 162 // Authenticated routes 155 163 router.Group(func(r chi.Router) { 156 164 r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database))
+1 -1
pkg/appview/templates/components/repo-card.html
··· 68 68 {{ end }} 69 69 </div> 70 70 {{ if not .LastUpdated.IsZero }} 71 - <span class="text-base-content/60 text-sm">{{ timeAgo .LastUpdated }}</span> 71 + <span class="text-base-content/60 text-sm flex items-center gap-1">{{ icon "history" "size-4" }}{{ timeAgoShort .LastUpdated }}</span> 72 72 {{ end }} 73 73 </div> 74 74 </div>
+106 -216
pkg/appview/templates/pages/repository.html
··· 74 74 <div class="space-y-2"> 75 75 {{ if eq .ArtifactType "helm-chart" }} 76 76 <p class="font-semibold">Pull this chart</p> 77 - {{ if .Tags }} 78 - {{ $firstTag := index .Tags 0 }} 79 - {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " $firstTag.Tag.Tag) }} 77 + {{ if .LatestTag }} 78 + {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .LatestTag) }} 80 79 {{ else }} 81 80 {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name) }} 82 81 {{ end }} 83 82 {{ else }} 84 83 <p class="font-semibold">Pull this image</p> 85 - {{ if .Tags }} 86 - {{ $firstTag := index .Tags 0 }} 87 - {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }} 84 + {{ if .LatestTag }} 85 + {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }} 88 86 {{ else }} 89 87 {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }} 90 88 {{ end }} ··· 92 90 </div> 93 91 </div> 94 92 95 - <!-- README and Tags/Manifests Layout --> 96 - {{ if .ReadmeHTML }} 97 - <div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-8"> 98 - <!-- README Section (Left) --> 93 + <!-- Tab Navigation --> 94 + <div class="border-b border-base-300"> 95 + <nav class="flex gap-0" role="tablist"> 96 + <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors" 97 + data-tab="overview" 98 + role="tab" 99 + onclick="switchRepoTab('overview')"> 100 + Overview 101 + </button> 102 + <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors" 103 + data-tab="tags" 104 + role="tab" 105 + id="tags-tab-btn" 106 + onclick="switchRepoTab('tags')"> 107 + Artifacts 108 + </button> 109 + </nav> 110 + </div> 111 + 112 + <!-- Tab Panels --> 113 + <!-- Overview Panel --> 114 + <div id="tab-overview" class="repo-panel"> 115 + {{ if .ReadmeHTML }} 99 116 <div class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0"> 100 - <h2 class="text-xl font-semibold">Overview</h2> 101 117 <div class="prose prose-sm max-w-none"> 102 118 {{ .ReadmeHTML }} 103 119 </div> 104 120 </div> 105 - 106 - <!-- Tags and Manifests (Right) --> 107 - <div class="space-y-8 min-w-0"> 108 - {{ end }} 109 - 110 - <!-- Tags Section --> 111 - <div class="card bg-base-100 shadow-sm p-6 space-y-4"> 112 - <h2 class="text-xl font-semibold">Tags</h2> 113 - {{ if .Tags }} 114 - <div class="space-y-4"> 115 - {{ range .Tags }} 116 - <div class="bg-base-200 rounded-lg p-4 space-y-3" id="tag-{{ sanitizeID .Tag.Tag }}"> 117 - <div class="flex flex-wrap items-center justify-between gap-2"> 118 - <div class="flex flex-wrap items-center gap-2"> 119 - <span class="font-mono font-semibold text-lg">{{ .Tag.Tag }}</span> 120 - {{ if eq .ArtifactType "helm-chart" }} 121 - <span class="badge badge-md badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm chart</span> 122 - {{ else if .IsMultiArch }} 123 - <span class="badge badge-md badge-soft badge-accent">Multi-arch</span> 124 - {{ end }} 125 - {{ if .HasAttestations }} 126 - <button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80" 127 - hx-get="/api/attestation-details?digest={{ .Tag.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}" 128 - hx-target="#attestation-modal-body" 129 - hx-swap="innerHTML" 130 - onclick="document.getElementById('attestation-detail-modal').showModal()"> 131 - {{ icon "shield-check" "size-3" }} Attestations 132 - </button> 133 - {{ end }} 134 - </div> 135 - <div class="flex items-center gap-2"> 136 - <time class="text-sm text-base-content/60" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 137 - {{ timeAgo .Tag.CreatedAt }} 138 - </time> 139 - {{ if $.IsOwner }} 140 - <button class="btn btn-ghost btn-sm text-error" 141 - hx-ext="json-enc" 142 - hx-delete="/api/tags" 143 - hx-vals='{"repo": "{{ $.Repository.Name }}", "tag": "{{ .Tag.Tag }}"}' 144 - hx-confirm="Delete tag {{ .Tag.Tag }}?" 145 - hx-target="#tag-{{ sanitizeID .Tag.Tag }}" 146 - hx-swap="outerHTML" 147 - aria-label="Delete tag {{ .Tag.Tag }}"> 148 - {{ icon "trash-2" "size-4" }} 149 - </button> 150 - {{ end }} 151 - </div> 152 - </div> 153 - <div class="text-sm space-y-2"> 154 - <div class="flex items-center gap-2"> 155 - <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code> 156 - <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Tag.Digest }}')" aria-label="Copy tag digest to clipboard">{{ icon "copy" "size-3" }}</button> 157 - </div> 158 - {{ if .Platforms }} 159 - <div class="space-y-1"> 160 - {{ range .Platforms }} 161 - <div class="flex flex-wrap items-center gap-2"> 162 - <span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 163 - {{ if .Digest }} 164 - <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Digest }}">{{ .Digest }}</code> 165 - <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')" aria-label="Copy platform digest to clipboard">{{ icon "copy" "size-3" }}</button> 166 - {{ if .HoldEndpoint }} 167 - <span id="scan-badge-{{ trimPrefix "sha256:" .Digest }}"></span> 168 - {{ end }} 169 - {{ end }} 170 - </div> 171 - {{ end }} 172 - </div> 173 - {{ else if .HoldEndpoint }} 174 - {{/* Single-arch: scan badge for the tag's own digest */}} 175 - <div><span id="scan-badge-{{ trimPrefix "sha256:" .Tag.Digest }}"></span></div> 176 - {{ end }} 177 - </div> 178 - {{ if eq .ArtifactType "helm-chart" }} 179 - {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .Tag.Tag) }} 180 - {{ else }} 181 - {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }} 182 - {{ end }} 183 - </div> 184 - {{ end }} 185 - </div> 186 - {{ if $.ScanBatchParams }} 187 - {{ range $.ScanBatchParams }} 188 - <div hx-get="/api/scan-results?{{ . }}" 189 - hx-trigger="load delay:500ms" 190 - hx-swap="none" 191 - style="display:none"></div> 192 - {{ end }} 193 - {{ end }} 194 121 {{ else }} 195 - <p class="text-base-content/60">No tags available</p> 122 + <div class="card bg-base-100 shadow-sm p-6"> 123 + <p class="text-base-content/60">No description available</p> 124 + </div> 196 125 {{ end }} 197 126 </div> 198 127 199 - <!-- Manifests Section --> 200 - <div class="card bg-base-100 shadow-sm p-6 space-y-4"> 201 - <div class="flex flex-wrap justify-between items-center gap-4"> 202 - <h2 class="text-xl font-semibold">Manifests</h2> 203 - <div class="flex items-center gap-4"> 204 - {{ if $.IsOwner }} 205 - <button class="btn btn-ghost btn-sm text-error" 206 - onclick="document.getElementById('untagged-delete-modal').showModal()" 207 - aria-label="Delete all untagged manifests"> 208 - {{ icon "trash-2" "size-4" }} Delete untagged 209 - </button> 210 - {{ end }} 211 - <label class="flex items-center gap-2 text-sm cursor-pointer"> 212 - <input type="checkbox" class="checkbox checkbox-sm" id="show-offline-toggle" onchange="toggleOfflineManifests()"> 213 - <span>Show offline images</span> 214 - </label> 128 + <!-- Tags Panel --> 129 + <div id="tab-tags" class="repo-panel hidden"> 130 + <div id="tags-content"> 131 + <div class="flex justify-center py-12"> 132 + <span class="loading loading-spinner loading-lg"></span> 215 133 </div> 216 134 </div> 217 - {{ if .Manifests }} 218 - <div class="space-y-4 manifests-list"> 219 - {{ range .Manifests }} 220 - <div class="bg-base-200 rounded-lg p-4 space-y-3" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}"> 221 - <div class="flex flex-wrap items-start justify-between gap-2"> 222 - <div class="space-y-2"> 223 - <div class="flex flex-wrap items-center gap-2"> 224 - {{ if .IsManifestList }} 225 - <span class="flex items-center gap-1 font-medium">{{ icon "package" "size-5" }} Multi-arch</span> 226 - {{ else if eq .ArtifactType "helm-chart" }} 227 - <span class="flex items-center gap-1 font-medium text-helm">{{ icon "helm" "size-5" }} Helm Chart</span> 228 - {{ else }} 229 - <span class="flex items-center gap-1 font-medium">{{ icon "box" "size-5" }} Image</span> 230 - {{ end }} 231 - {{ if .HasAttestations }} 232 - <button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80" 233 - hx-get="/api/attestation-details?digest={{ .Manifest.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}" 234 - hx-target="#attestation-modal-body" 235 - hx-swap="innerHTML" 236 - onclick="document.getElementById('attestation-detail-modal').showModal()"> 237 - {{ icon "shield-check" "size-3" }} Attestations 238 - </button> 239 - {{ end }} 240 - {{ if .Pending }} 241 - <span class="badge badge-sm badge-info" 242 - hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}" 243 - hx-trigger="load delay:2s" 244 - hx-swap="outerHTML"> 245 - {{ icon "refresh-ccw" "size-3" }} Checking... 246 - </span> 247 - {{ else if not .Reachable }} 248 - <span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span> 249 - {{ end }} 250 - </div> 251 - <div class="flex items-center gap-2"> 252 - <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code> 253 - <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy manifest digest to clipboard">{{ icon "copy" "size-3" }}</button> 254 - </div> 255 - </div> 256 - <div class="flex items-center gap-2"> 257 - <time class="text-sm text-base-content/60" datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 258 - {{ timeAgo .Manifest.CreatedAt }} 259 - </time> 260 - {{ if $.IsOwner }} 261 - <button class="btn btn-ghost btn-sm text-error" 262 - onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')" 263 - aria-label="Delete manifest {{ truncateDigest .Manifest.Digest 16 }}"> 264 - {{ icon "trash-2" "size-4" }} 265 - </button> 266 - {{ end }} 267 - </div> 268 - </div> 269 - <div class="text-sm"> 270 - <div class="flex flex-wrap justify-between items-center gap-2"> 271 - <div> 272 - {{ if .Tags }} 273 - <span class="text-base-content/60">Tags:</span> 274 - {{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }} 275 - {{ else }} 276 - <span class="text-base-content/50">(untagged)</span> 277 - {{ end }} 278 - </div> 279 - {{ if .IsManifestList }} 280 - {{ if .Platforms }} 281 - <div class="flex flex-wrap gap-1"> 282 - {{ range .Platforms }} 283 - <span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 284 - {{ end }} 285 - </div> 286 - {{ end }} 287 - {{ end }} 288 - </div> 289 - </div> 290 - </div> 291 - {{ end }} 292 - </div> 293 - {{ else }} 294 - <p class="text-base-content/60">No manifests available</p> 295 - {{ end }} 296 135 </div> 297 - 298 - {{ if .ReadmeHTML }} 299 - </div><!-- Close sidebar --> 300 - </div><!-- Close grid layout --> 301 - {{ end }} 302 136 </div> 303 137 </main> 304 138 ··· 326 160 <dialog id="untagged-delete-modal" class="modal"> 327 161 <div class="modal-box"> 328 162 <h3 class="text-lg font-bold">Delete Untagged Manifests</h3> 329 - <p class="py-2">This will delete all untagged manifests in this repository.</p> 163 + <p class="py-2">This will delete <strong>all</strong> untagged manifests in this repository, including those not currently visible.</p> 330 164 <p class="font-bold py-2 text-error">This action cannot be undone.</p> 331 165 <div class="modal-action"> 332 166 <form method="dialog"><button class="btn">Cancel</button></form> ··· 339 173 <form method="dialog" class="modal-backdrop"><button>close</button></form> 340 174 </dialog> 341 175 342 - <!-- Vulnerability Details Modal --> 343 - <dialog id="vuln-detail-modal" class="modal"> 344 - <div class="modal-box max-w-6xl"> 345 - <h3 class="text-lg font-bold">Vulnerability Scan Results</h3> 346 - <div id="vuln-modal-body" class="py-4"> 347 - <span class="loading loading-spinner loading-md"></span> 348 - </div> 349 - <div class="modal-action"> 350 - <form method="dialog"><button class="btn">Close</button></form> 351 - </div> 352 - </div> 353 - <form method="dialog" class="modal-backdrop"><button>close</button></form> 354 - </dialog> 355 - 356 176 <!-- Attestation Details Modal --> 357 177 <dialog id="attestation-detail-modal" class="modal"> 358 178 <div class="modal-box max-w-2xl"> ··· 366 186 </div> 367 187 <form method="dialog" class="modal-backdrop"><button>close</button></form> 368 188 </dialog> 189 + 190 + <script> 191 + (function() { 192 + var validTabs = ['overview', 'tags']; 193 + var tagsLoading = false; 194 + 195 + function loadTags() { 196 + if (tagsLoading) return; 197 + tagsLoading = true; 198 + var target = document.getElementById('tags-content'); 199 + fetch('/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}') 200 + .then(function(r) { return r.text(); }) 201 + .then(function(html) { 202 + target.innerHTML = html; 203 + htmx.process(target); 204 + }); 205 + } 206 + 207 + window.switchRepoTab = function(tabId) { 208 + document.querySelectorAll('.repo-panel').forEach(function(p) { 209 + p.classList.add('hidden'); 210 + }); 211 + var panel = document.getElementById('tab-' + tabId); 212 + if (panel) panel.classList.remove('hidden'); 213 + 214 + document.querySelectorAll('.repo-tab').forEach(function(tab) { 215 + if (tab.dataset.tab === tabId) { 216 + tab.classList.add('border-primary', 'text-primary'); 217 + tab.classList.remove('border-transparent', 'text-base-content/60', 'hover:text-base-content'); 218 + } else { 219 + tab.classList.remove('border-primary', 'text-primary'); 220 + tab.classList.add('border-transparent', 'text-base-content/60', 'hover:text-base-content'); 221 + } 222 + }); 223 + 224 + history.replaceState(null, '', '#' + tabId); 225 + if (tabId === 'tags') loadTags(); 226 + }; 227 + 228 + window.sortTags = function(method) { 229 + var container = document.getElementById('tags-list'); 230 + if (!container) return; 231 + var entries = Array.from(container.querySelectorAll('.artifact-entry')); 232 + entries.sort(function(a, b) { 233 + switch (method) { 234 + case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created); 235 + case 'az': return a.dataset.tag.localeCompare(b.dataset.tag); 236 + case 'za': return b.dataset.tag.localeCompare(a.dataset.tag); 237 + default: return parseInt(b.dataset.created) - parseInt(a.dataset.created); 238 + } 239 + }); 240 + entries.forEach(function(el) { container.appendChild(el); }); 241 + }; 242 + 243 + window.filterTags = function(query) { 244 + var q = query.toLowerCase(); 245 + document.querySelectorAll('#tags-list .artifact-entry').forEach(function(el) { 246 + el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none'; 247 + }); 248 + }; 249 + 250 + // Prefetch on hover 251 + document.getElementById('tags-tab-btn').addEventListener('mouseenter', loadTags, { once: true }); 252 + 253 + // Initialize tab from hash 254 + var hash = window.location.hash.replace('#', '') || 'overview'; 255 + if (validTabs.indexOf(hash) === -1) hash = 'overview'; 256 + switchRepoTab(hash); 257 + })(); 258 + </script> 369 259 370 260 {{ template "footer" . }} 371 261 </body>
+2 -4
pkg/appview/templates/partials/vuln-badge.html
··· 6 6 {{ else if eq .Total 0 }} 7 7 <span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span> 8 8 {{ else }} 9 - <button class="vuln-strip cursor-pointer hover:opacity-80 transition-opacity" 10 - onclick="openVulnDetails('{{ .Digest }}', '{{ .HoldEndpoint }}')" 11 - title="Click for vulnerability details (scanned {{ .ScannedAt }})"> 9 + <span class="vuln-strip" title="Vulnerabilities: {{ .Critical }} critical, {{ .High }} high, {{ .Medium }} medium, {{ .Low }} low (scanned {{ .ScannedAt }})"> 12 10 <span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Critical }}</span> 13 11 <span class="tooltip vuln-box-high" data-tip="High">{{ .High }}</span> 14 12 <span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Medium }}</span> 15 13 <span class="tooltip vuln-box-low" data-tip="Low">{{ .Low }}</span> 16 - </button> 14 + </span> 17 15 {{ end }} 18 16 {{ end }}
+20 -21
pkg/appview/templates/partials/vuln-details.html
··· 9 9 <span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span> 10 10 <span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span> 11 11 </span> 12 - <p class="text-base-content/60 text-sm">{{ .Error }}</p> 13 - {{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }} 12 + <p class="text-sm">{{ .Error }}</p> 13 + {{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }} 14 14 </div> 15 15 {{ else }} 16 - <p class="text-base-content/60">{{ .Error }}</p> 16 + <p>{{ .Error }}</p> 17 17 {{ end }} 18 18 {{ else }} 19 19 <div class="space-y-4"> 20 20 <!-- Summary badges --> 21 21 <div class="flex flex-wrap items-center gap-3"> 22 - <span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities found</span> 22 + <span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities</span> 23 23 <span class="vuln-strip"> 24 24 <span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span> 25 25 <span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span> ··· 28 28 </span> 29 29 </div> 30 30 31 - {{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }} 31 + {{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }} 32 32 33 33 {{ if .Matches }} 34 34 <!-- CVE table --> 35 - <div class="overflow-x-auto max-h-96"> 36 - <table class="table table-sm table-pin-rows"> 35 + <div class="overflow-y-auto max-h-[32rem]"> 36 + <table class="table table-xs table-pin-rows w-full"> 37 37 <thead> 38 38 <tr> 39 39 <th>CVE</th> 40 - <th>Severity</th> 40 + <th></th> 41 41 <th>Package</th> 42 - <th>Installed</th> 43 - <th>Fixed In</th> 42 + <th>Version</th> 43 + <th>Fix</th> 44 44 </tr> 45 45 </thead> 46 46 <tbody> ··· 55 55 </td> 56 56 <td> 57 57 {{ if eq .Severity "Critical" }} 58 - <span class="badge badge-sm badge-error">Critical</span> 58 + <span class="badge badge-xs badge-error" title="Critical">C</span> 59 59 {{ else if eq .Severity "High" }} 60 - <span class="badge badge-sm badge-warning">High</span> 60 + <span class="badge badge-xs badge-warning" title="High">H</span> 61 61 {{ else if eq .Severity "Medium" }} 62 - <span class="badge badge-sm badge-soft badge-warning">Medium</span> 62 + <span class="badge badge-xs badge-soft badge-warning" title="Medium">M</span> 63 63 {{ else if eq .Severity "Low" }} 64 - <span class="badge badge-sm badge-info">Low</span> 64 + <span class="badge badge-xs badge-info" title="Low">L</span> 65 65 {{ else }} 66 - <span class="badge badge-sm badge-ghost">{{ .Severity }}</span> 66 + <span class="badge badge-xs badge-ghost" title="{{ .Severity }}">?</span> 67 67 {{ end }} 68 68 </td> 69 - <td> 70 - <span class="font-mono text-xs">{{ .Package }}</span> 71 - {{ if .Type }}<span class="text-base-content/40 text-xs">({{ .Type }})</span>{{ end }} 69 + <td class="text-xs"> 70 + {{ .Package }}{{ if .Type }} <span class="opacity-60">({{ .Type }})</span>{{ end }} 72 71 </td> 73 - <td class="font-mono text-xs break-all">{{ .Version }}</td> 74 - <td class="font-mono text-xs break-all"> 72 + <td class="font-mono text-xs">{{ .Version }}</td> 73 + <td class="font-mono text-xs"> 75 74 {{ if .FixedIn }} 76 75 <span class="text-success">{{ .FixedIn }}</span> 77 76 {{ else }} 78 - <span class="text-base-content/40">No fix</span> 77 + <span class="opacity-40">-</span> 79 78 {{ end }} 80 79 </td> 81 80 </tr>
+16
pkg/appview/ui.go
··· 157 157 } 158 158 }, 159 159 160 + "timeAgoShort": func(t time.Time) string { 161 + duration := time.Since(t) 162 + 163 + if duration < time.Minute { 164 + return "now" 165 + } else if duration < time.Hour { 166 + return fmt.Sprintf("%dm", int(duration.Minutes())) 167 + } else if duration < 24*time.Hour { 168 + return fmt.Sprintf("%dh", int(duration.Hours())) 169 + } else if duration < 365*24*time.Hour { 170 + return fmt.Sprintf("%dd", int(duration.Hours()/24)) 171 + } else { 172 + return fmt.Sprintf("%dy", int(duration.Hours()/(24*365))) 173 + } 174 + }, 175 + 160 176 "humanizeBytes": func(bytes int64) string { 161 177 const unit = 1024 162 178 if bytes < unit {
+202
pkg/atproto/cbor_gen.go
··· 2425 2425 2426 2426 return nil 2427 2427 } 2428 + func (t *ImageConfigRecord) MarshalCBOR(w io.Writer) error { 2429 + if t == nil { 2430 + _, err := w.Write(cbg.CborNull) 2431 + return err 2432 + } 2433 + 2434 + cw := cbg.NewCborWriter(w) 2435 + 2436 + if _, err := cw.Write([]byte{164}); err != nil { 2437 + return err 2438 + } 2439 + 2440 + // t.Type (string) (string) 2441 + if len("$type") > 8192 { 2442 + return xerrors.Errorf("Value in field \"$type\" was too long") 2443 + } 2444 + 2445 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2446 + return err 2447 + } 2448 + if _, err := cw.WriteString(string("$type")); err != nil { 2449 + return err 2450 + } 2451 + 2452 + if len(t.Type) > 8192 { 2453 + return xerrors.Errorf("Value in field t.Type was too long") 2454 + } 2455 + 2456 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 2457 + return err 2458 + } 2459 + if _, err := cw.WriteString(string(t.Type)); err != nil { 2460 + return err 2461 + } 2462 + 2463 + // t.Manifest (string) (string) 2464 + if len("manifest") > 8192 { 2465 + return xerrors.Errorf("Value in field \"manifest\" was too long") 2466 + } 2467 + 2468 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifest"))); err != nil { 2469 + return err 2470 + } 2471 + if _, err := cw.WriteString(string("manifest")); err != nil { 2472 + return err 2473 + } 2474 + 2475 + if len(t.Manifest) > 8192 { 2476 + return xerrors.Errorf("Value in field t.Manifest was too long") 2477 + } 2478 + 2479 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Manifest))); err != nil { 2480 + return err 2481 + } 2482 + if _, err := cw.WriteString(string(t.Manifest)); err != nil { 2483 + return err 2484 + } 2485 + 2486 + // t.CreatedAt (string) (string) 2487 + if len("createdAt") > 8192 { 2488 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2489 + } 2490 + 2491 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2492 + return err 2493 + } 2494 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2495 + return err 2496 + } 2497 + 2498 + if len(t.CreatedAt) > 8192 { 2499 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2500 + } 2501 + 2502 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2503 + return err 2504 + } 2505 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2506 + return err 2507 + } 2508 + 2509 + // t.ConfigJSON (string) (string) 2510 + if len("configJson") > 8192 { 2511 + return xerrors.Errorf("Value in field \"configJson\" was too long") 2512 + } 2513 + 2514 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("configJson"))); err != nil { 2515 + return err 2516 + } 2517 + if _, err := cw.WriteString(string("configJson")); err != nil { 2518 + return err 2519 + } 2520 + 2521 + if len(t.ConfigJSON) > 8192 { 2522 + return xerrors.Errorf("Value in field t.ConfigJSON was too long") 2523 + } 2524 + 2525 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ConfigJSON))); err != nil { 2526 + return err 2527 + } 2528 + if _, err := cw.WriteString(string(t.ConfigJSON)); err != nil { 2529 + return err 2530 + } 2531 + return nil 2532 + } 2533 + 2534 + func (t *ImageConfigRecord) UnmarshalCBOR(r io.Reader) (err error) { 2535 + *t = ImageConfigRecord{} 2536 + 2537 + cr := cbg.NewCborReader(r) 2538 + 2539 + maj, extra, err := cr.ReadHeader() 2540 + if err != nil { 2541 + return err 2542 + } 2543 + defer func() { 2544 + if err == io.EOF { 2545 + err = io.ErrUnexpectedEOF 2546 + } 2547 + }() 2548 + 2549 + if maj != cbg.MajMap { 2550 + return fmt.Errorf("cbor input should be of type map") 2551 + } 2552 + 2553 + if extra > cbg.MaxLength { 2554 + return fmt.Errorf("ImageConfigRecord: map struct too large (%d)", extra) 2555 + } 2556 + 2557 + n := extra 2558 + 2559 + nameBuf := make([]byte, 10) 2560 + for i := uint64(0); i < n; i++ { 2561 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2562 + if err != nil { 2563 + return err 2564 + } 2565 + 2566 + if !ok { 2567 + // Field doesn't exist on this type, so ignore it 2568 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2569 + return err 2570 + } 2571 + continue 2572 + } 2573 + 2574 + switch string(nameBuf[:nameLen]) { 2575 + // t.Type (string) (string) 2576 + case "$type": 2577 + 2578 + { 2579 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2580 + if err != nil { 2581 + return err 2582 + } 2583 + 2584 + t.Type = string(sval) 2585 + } 2586 + // t.Manifest (string) (string) 2587 + case "manifest": 2588 + 2589 + { 2590 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2591 + if err != nil { 2592 + return err 2593 + } 2594 + 2595 + t.Manifest = string(sval) 2596 + } 2597 + // t.CreatedAt (string) (string) 2598 + case "createdAt": 2599 + 2600 + { 2601 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2602 + if err != nil { 2603 + return err 2604 + } 2605 + 2606 + t.CreatedAt = string(sval) 2607 + } 2608 + // t.ConfigJSON (string) (string) 2609 + case "configJson": 2610 + 2611 + { 2612 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2613 + if err != nil { 2614 + return err 2615 + } 2616 + 2617 + t.ConfigJSON = string(sval) 2618 + } 2619 + 2620 + default: 2621 + // Field doesn't exist on this type, so ignore it 2622 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2623 + return err 2624 + } 2625 + } 2626 + } 2627 + 2628 + return nil 2629 + }
+12
pkg/atproto/endpoints.go
··· 46 46 // Response: {"success": true, "layersCreated": 5, "postCreated": true, "postUri": "at://..."} 47 47 HoldNotifyManifest = "/xrpc/io.atcr.hold.notifyManifest" 48 48 49 + // HoldGetLayersForManifest returns layer records for a specific manifest. 50 + // Method: GET 51 + // Query: manifest={at-uri} 52 + // Response: {"layers": [{...}]} 53 + HoldGetLayersForManifest = "/xrpc/io.atcr.hold.getLayersForManifest" 54 + 55 + // HoldGetImageConfig returns the OCI image config record for a manifest. 56 + // Method: GET 57 + // Query: digest={manifest-digest} 58 + // Response: ImageConfigRecord JSON 59 + HoldGetImageConfig = "/xrpc/io.atcr.hold.image.getConfig" 60 + 49 61 // HoldGetQuota returns storage quota information for a user. 50 62 // Method: GET 51 63 // Query: userDid={did}
+1
pkg/atproto/generate.go
··· 33 33 atproto.TangledProfileRecord{}, 34 34 atproto.StatsRecord{}, 35 35 atproto.ScanRecord{}, 36 + atproto.ImageConfigRecord{}, 36 37 ); err != nil { 37 38 fmt.Printf("Failed to generate CBOR encoders: %v\n", err) 38 39 os.Exit(1)
+25
pkg/atproto/lexicon.go
··· 48 48 // Stored in hold's embedded PDS to track scan results per manifest 49 49 ScanCollection = "io.atcr.hold.scan" 50 50 51 + // ImageConfigCollection is the collection name for OCI image configs 52 + // Stored in hold's embedded PDS, one per manifest (keyed by manifest digest hex) 53 + ImageConfigCollection = "io.atcr.hold.image.config" 54 + 51 55 // TangledProfileCollection is the collection name for tangled profiles 52 56 // Stored in hold's embedded PDS (singleton record at rkey "self") 53 57 TangledProfileCollection = "sh.tangled.actor.profile" ··· 818 822 func ScanRecordKey(manifestDigest string) string { 819 823 // Remove the "sha256:" prefix - the hex digest is already a valid rkey 820 824 return strings.TrimPrefix(manifestDigest, "sha256:") 825 + } 826 + 827 + // ImageConfigRecord represents an OCI image config stored in the hold's embedded PDS 828 + // Collection: io.atcr.hold.image.config 829 + // One record per manifest, keyed by manifest digest hex (same pattern as ScanRecord) 830 + // Stores the full OCI config JSON so the appview can display layer history including empty layers 831 + type ImageConfigRecord struct { 832 + Type string `json:"$type" cborgen:"$type"` 833 + Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the manifest 834 + ConfigJSON string `json:"configJson" cborgen:"configJson"` // Raw OCI image config JSON 835 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` // RFC3339 timestamp 836 + } 837 + 838 + // NewImageConfigRecord creates a new image config record 839 + func NewImageConfigRecord(manifestURI, configJSON string) *ImageConfigRecord { 840 + return &ImageConfigRecord{ 841 + Type: ImageConfigCollection, 842 + Manifest: manifestURI, 843 + ConfigJSON: configJSON, 844 + CreatedAt: time.Now().Format(time.RFC3339), 845 + } 821 846 } 822 847 823 848 // TangledProfileRecord represents a Tangled profile for the hold
+1
pkg/hold/admin/admin.go
··· 417 417 r.Post("/admin/api/gc/reconcile", ui.handleGCReconcile) 418 418 r.Post("/admin/api/gc/delete-records", ui.handleGCDeleteRecords) 419 419 r.Post("/admin/api/gc/delete-blobs", ui.handleGCDeleteBlobs) 420 + r.Post("/admin/api/gc/backfill-configs", ui.handleGCBackfillConfigs) 420 421 r.Get("/admin/api/gc/status", ui.handleGCStatus) 421 422 422 423 // API endpoints (for HTMX)
+21
pkg/hold/admin/handlers_gc.go
··· 136 136 }) 137 137 } 138 138 139 + // handleGCBackfillConfigs starts image config backfill in the background 140 + func (ui *AdminUI) handleGCBackfillConfigs(w http.ResponseWriter, r *http.Request) { 141 + if ui.gc == nil { 142 + ui.renderTemplate(w, "partials/gc_error.html", struct{ Error string }{"GC not available"}) 143 + return 144 + } 145 + 146 + session := getSessionFromContext(r.Context()) 147 + 148 + if ui.gc.StartBackfillConfigs() { 149 + slog.Info("GC backfill configs started via admin panel", "by", session.DID) 150 + } 151 + 152 + progress := ui.gc.GetProgress() 153 + ui.renderTemplate(w, "partials/gc_progress.html", gcProgressData{ 154 + Phase: progress.Phase, 155 + Message: progress.Message, 156 + OpType: progress.OperationType, 157 + }) 158 + } 159 + 139 160 // handleGCDeleteBlobs starts orphaned blob deletion in the background 140 161 func (ui *AdminUI) handleGCDeleteBlobs(w http.ResponseWriter, r *http.Request) { 141 162 if ui.gc == nil {
+2 -2
pkg/hold/admin/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 - <symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol> 10 9 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 11 10 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> 12 11 <symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol> ··· 26 25 <symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol> 27 26 <symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol> 28 27 <symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol> 28 + <symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol> 29 29 <symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol> 30 30 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 31 31 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 32 - <symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol> 33 32 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol> 34 33 <symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol> 35 34 <symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol> 36 35 <symbol id="refresh-ccw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></symbol> 36 + <symbol id="refresh-cw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol> 37 37 <symbol id="save" viewBox="0 0 24 24"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></symbol> 38 38 <symbol id="search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></symbol> 39 39 <symbol id="server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
+9
pkg/hold/admin/templates/partials/tab_storage.html
··· 41 41 {{ icon "search" "size-4" }} 42 42 Scan for Orphans 43 43 </button> 44 + <button class="btn btn-outline gap-2" 45 + hx-post="/admin/api/gc/backfill-configs" 46 + hx-target="#gc-results" 47 + hx-swap="innerHTML" 48 + hx-confirm="Backfill image config records from OCI config blobs in S3?" 49 + {{if .Running}}disabled{{end}}> 50 + {{ icon "refresh-cw" "size-4" }} 51 + Backfill Image Configs 52 + </button> 44 53 </div> 45 54 46 55 <div id="gc-results">
+152
pkg/hold/gc/gc.go
··· 815 815 } 816 816 } 817 817 818 + // StartBackfillConfigs launches image config backfill in the background. 819 + // Creates io.atcr.hold.image.config records for manifests that don't have one yet 820 + // by fetching OCI config blobs from S3. 821 + func (gc *GarbageCollector) StartBackfillConfigs() bool { 822 + return gc.startBackground("backfill-configs", "records", "Scanning for manifests missing image config records...", func(ctx context.Context) error { 823 + _, err := gc.doBackfillConfigs(ctx) 824 + return err 825 + }) 826 + } 827 + 828 + // doBackfillConfigs creates image config records for manifests that are missing them. 829 + func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, error) { 830 + recordsIndex := gc.pds.RecordsIndex() 831 + if recordsIndex == nil { 832 + return nil, fmt.Errorf("records index not available") 833 + } 834 + 835 + // Step 1: Collect unique manifest URIs from layer records 836 + manifestURIs := make(map[string]bool) 837 + cursor := "" 838 + totalScanned := 0 839 + 840 + for { 841 + records, nextCursor, err := recordsIndex.ListRecords(atproto.LayerCollection, 1000, cursor, true) 842 + if err != nil { 843 + return nil, fmt.Errorf("list layer records: %w", err) 844 + } 845 + 846 + for _, rec := range records { 847 + totalScanned++ 848 + layer, err := gc.decodeLayerRecord(ctx, rec) 849 + if err != nil { 850 + continue 851 + } 852 + manifestURIs[layer.Manifest] = true 853 + } 854 + 855 + if nextCursor == "" { 856 + break 857 + } 858 + cursor = nextCursor 859 + } 860 + 861 + gc.logger.Info("Found unique manifests from layer records", 862 + "manifests", len(manifestURIs), 863 + "layersScanned", totalScanned) 864 + 865 + // Step 2: For each manifest, check if config record exists, create if not 866 + start := time.Now() 867 + result := &GCResult{} 868 + created := int64(0) 869 + skipped := int64(0) 870 + processed := 0 871 + httpClient := &http.Client{Timeout: 30 * time.Second} 872 + 873 + for manifestURI := range manifestURIs { 874 + processed++ 875 + gc.setProgress("records", 876 + fmt.Sprintf("Backfilling configs (%d/%d manifests)...", processed, len(manifestURIs)), 877 + "backfill-configs") 878 + 879 + aturi, err := syntax.ParseATURI(manifestURI) 880 + if err != nil { 881 + gc.logger.Warn("Invalid manifest URI", "uri", manifestURI, "error", err) 882 + continue 883 + } 884 + 885 + manifestDigest := "sha256:" + aturi.RecordKey().String() 886 + 887 + // Check if config record already exists 888 + if _, _, err := gc.pds.GetImageConfigRecord(ctx, manifestDigest); err == nil { 889 + skipped++ 890 + continue 891 + } 892 + 893 + userDID := aturi.Authority().String() 894 + manifestRkey := aturi.RecordKey().String() 895 + 896 + pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, userDID) 897 + if err != nil { 898 + gc.logger.Warn("Failed to resolve PDS for backfill", "did", userDID, "error", err) 899 + continue 900 + } 901 + 902 + // Fetch manifest via getRecord to get config digest 903 + reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 904 + pdsEndpoint, 905 + url.QueryEscape(userDID), 906 + url.QueryEscape(atproto.ManifestCollection), 907 + url.QueryEscape(manifestRkey)) 908 + 909 + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 910 + if err != nil { 911 + continue 912 + } 913 + resp, err := httpClient.Do(req) 914 + if err != nil { 915 + gc.logger.Warn("Failed to fetch manifest for backfill", "uri", manifestURI, "error", err) 916 + continue 917 + } 918 + if resp.StatusCode != http.StatusOK { 919 + resp.Body.Close() 920 + continue 921 + } 922 + 923 + var envelope struct { 924 + Value json.RawMessage `json:"value"` 925 + } 926 + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 927 + resp.Body.Close() 928 + continue 929 + } 930 + resp.Body.Close() 931 + 932 + var manifest atproto.ManifestRecord 933 + if err := json.Unmarshal(envelope.Value, &manifest); err != nil { 934 + continue 935 + } 936 + 937 + if manifest.Config == nil || manifest.Config.Digest == "" { 938 + continue 939 + } 940 + 941 + // Fetch config blob from S3 942 + configBytes, err := gc.s3.GetBytes(ctx, s3.BlobPath(manifest.Config.Digest)) 943 + if err != nil { 944 + gc.logger.Warn("Failed to fetch config blob", "digest", manifest.Config.Digest, "error", err) 945 + continue 946 + } 947 + 948 + // Create image config record 949 + configRecord := atproto.NewImageConfigRecord(manifestURI, string(configBytes)) 950 + if _, _, err := gc.pds.CreateImageConfigRecord(ctx, configRecord, manifestDigest); err != nil { 951 + gc.logger.Warn("Failed to create image config record", "manifest", manifestURI, "error", err) 952 + continue 953 + } 954 + created++ 955 + time.Sleep(200 * time.Millisecond) // throttle firehose events 956 + } 957 + 958 + result.RecordsReconciled = created 959 + result.Duration = time.Since(start) 960 + 961 + gc.mu.Lock() 962 + gc.lastResult = result 963 + gc.lastResultAt = time.Now() 964 + gc.mu.Unlock() 965 + 966 + gc.logger.Info("Image config backfill complete", "created", created, "skipped", skipped) 967 + return result, nil 968 + } 969 + 818 970 // discoverUserDIDs returns all DIDs that may have manifests referencing this hold. 819 971 // Union of: captain owner + crew members + distinct DIDs from layer records. 820 972 func (gc *GarbageCollector) discoverUserDIDs(ctx context.Context) ([]string, error) {
+34 -13
pkg/hold/oci/xrpc.go
··· 297 297 // Build manifest AT-URI for layer records 298 298 manifestURI := atproto.BuildManifestURI(req.UserDID, req.ManifestDigest) 299 299 300 - // Create layer records for each blob 301 - for _, layer := range req.Manifest.Layers { 302 - record := atproto.NewLayerRecord( 303 - layer.Digest, 304 - layer.Size, 305 - layer.MediaType, 306 - req.UserDID, 307 - manifestURI, 308 - ) 300 + // Skip layer record creation if records already exist for this manifest 301 + existingLayers, _ := h.pds.ListLayerRecordsForManifest(ctx, manifestURI) 302 + if len(existingLayers) > 0 { 303 + layersCreated = len(existingLayers) 304 + slog.Debug("Layer records already exist for manifest, skipping creation", 305 + "manifestURI", manifestURI, "existing", len(existingLayers)) 306 + } else { 307 + // Create layer records for each blob 308 + for _, layer := range req.Manifest.Layers { 309 + record := atproto.NewLayerRecord( 310 + layer.Digest, 311 + layer.Size, 312 + layer.MediaType, 313 + req.UserDID, 314 + manifestURI, 315 + ) 316 + 317 + _, _, err := h.pds.CreateLayerRecord(ctx, record) 318 + if err != nil { 319 + slog.Error("Failed to create layer record", "error", err) 320 + // Continue creating other records 321 + } else { 322 + layersCreated++ 323 + } 324 + } 325 + } 309 326 310 - _, _, err := h.pds.CreateLayerRecord(ctx, record) 327 + // Store OCI image config as a separate record (best-effort) 328 + if req.Manifest.Config.Digest != "" { 329 + configBytes, err := h.s3Service.GetBytes(ctx, s3.BlobPath(req.Manifest.Config.Digest)) 311 330 if err != nil { 312 - slog.Error("Failed to create layer record", "error", err) 313 - // Continue creating other records 331 + slog.Warn("Failed to fetch config blob for image config record", "error", err, "configDigest", req.Manifest.Config.Digest) 314 332 } else { 315 - layersCreated++ 333 + configRecord := atproto.NewImageConfigRecord(manifestURI, string(configBytes)) 334 + if _, _, err := h.pds.CreateImageConfigRecord(ctx, configRecord, req.ManifestDigest); err != nil { 335 + slog.Warn("Failed to create image config record", "error", err) 336 + } 316 337 } 317 338 } 318 339
+1 -1
pkg/hold/oci/xrpc_test.go
··· 678 678 679 679 // 6. Verify blob was moved to final location in mock S3 680 680 finalS3Key := "test-prefix/docker/registry/v2/blobs/sha256/ab/abc123def456/data" 681 - if mockS3Client.GetObject(finalS3Key) == nil { 681 + if mockS3Client.GetObjectBytes(finalS3Key) == nil { 682 682 t.Errorf("Expected blob at final S3 key %s", finalS3Key) 683 683 } 684 684 }
+84
pkg/hold/pds/layer.go
··· 90 90 return nil, fmt.Errorf("GetLayerRecord not yet implemented - use via XRPC listRecords instead") 91 91 } 92 92 93 + // UpdateLayerRecord updates an existing layer record by rkey. 94 + func (p *HoldPDS) UpdateLayerRecord(ctx context.Context, rkey string, record *atproto.LayerRecord) error { 95 + _, err := p.repomgr.UpdateRecord(ctx, p.uid, atproto.LayerCollection, rkey, record) 96 + if err != nil { 97 + return fmt.Errorf("failed to update layer record: %w", err) 98 + } 99 + return nil 100 + } 101 + 93 102 // DeleteLayerRecord deletes a layer record by rkey 94 103 // This deletes from both the repo (MST) and the records index 95 104 func (p *HoldPDS) DeleteLayerRecord(ctx context.Context, rkey string) error { ··· 294 303 295 304 return records, nil 296 305 } 306 + 307 + // ListLayerRecordsForManifest returns all layer records for a specific manifest AT-URI. 308 + func (p *HoldPDS) ListLayerRecordsForManifest(ctx context.Context, manifestURI string) ([]*atproto.LayerRecord, error) { 309 + if p.recordsIndex == nil { 310 + return nil, fmt.Errorf("records index not available") 311 + } 312 + 313 + session, err := p.carstore.ReadOnlySession(p.uid) 314 + if err != nil { 315 + return nil, fmt.Errorf("failed to create session: %w", err) 316 + } 317 + 318 + head, err := p.carstore.GetUserRepoHead(ctx, p.uid) 319 + if err != nil { 320 + return nil, fmt.Errorf("failed to get repo head: %w", err) 321 + } 322 + 323 + if !head.Defined() { 324 + return []*atproto.LayerRecord{}, nil 325 + } 326 + 327 + repoHandle, err := repo.OpenRepo(ctx, session, head) 328 + if err != nil { 329 + return nil, fmt.Errorf("failed to open repo: %w", err) 330 + } 331 + 332 + var records []*atproto.LayerRecord 333 + seen := make(map[string]int) // digest → index in records slice 334 + cursor := "" 335 + batchSize := 1000 336 + 337 + for { 338 + indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, false) 339 + if err != nil { 340 + return nil, fmt.Errorf("failed to list layer records: %w", err) 341 + } 342 + 343 + for _, rec := range indexRecords { 344 + recordPath := rec.Collection + "/" + rec.Rkey 345 + 346 + _, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath) 347 + if err != nil { 348 + continue 349 + } 350 + 351 + recordValue, err := lexutil.CborDecodeValue(*recBytes) 352 + if err != nil { 353 + continue 354 + } 355 + 356 + layerRecord, ok := recordValue.(*atproto.LayerRecord) 357 + if !ok { 358 + continue 359 + } 360 + 361 + if layerRecord.Manifest == manifestURI { 362 + if _, exists := seen[layerRecord.Digest]; !exists { 363 + seen[layerRecord.Digest] = len(records) 364 + records = append(records, layerRecord) 365 + } 366 + } 367 + } 368 + 369 + if nextCursor == "" { 370 + break 371 + } 372 + cursor = nextCursor 373 + } 374 + 375 + if records == nil { 376 + records = []*atproto.LayerRecord{} 377 + } 378 + 379 + return records, nil 380 + }
+1
pkg/hold/pds/server.go
··· 32 32 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 33 33 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{}) 34 34 lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{}) 35 + lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{}) 35 36 } 36 37 37 38 // HoldPDS is a minimal ATProto PDS implementation for a hold service
+45
pkg/hold/pds/xrpc.go
··· 168 168 r.Get(atproto.RepoDescribeRepo, h.HandleDescribeRepo) 169 169 r.Get(atproto.RepoGetRecord, h.HandleGetRecord) 170 170 r.Get(atproto.RepoListRecords, h.HandleListRecords) 171 + r.Get(atproto.HoldGetLayersForManifest, h.HandleGetLayersForManifest) 172 + r.Get(atproto.HoldGetImageConfig, h.HandleGetImageConfig) 171 173 172 174 // Sync endpoints 173 175 r.Get(atproto.SyncListBlobs, h.HandleListBlobs) ··· 485 487 "cid": recordCID.String(), 486 488 "value": recordValue, 487 489 }) 490 + } 491 + 492 + // HandleGetLayersForManifest returns layer records for a specific manifest AT-URI. 493 + func (h *XRPCHandler) HandleGetLayersForManifest(w http.ResponseWriter, r *http.Request) { 494 + manifestURI := r.URL.Query().Get("manifest") 495 + if manifestURI == "" { 496 + http.Error(w, `{"error":"InvalidRequest","message":"manifest parameter is required"}`, http.StatusBadRequest) 497 + return 498 + } 499 + 500 + records, err := h.pds.ListLayerRecordsForManifest(r.Context(), manifestURI) 501 + if err != nil { 502 + slog.Error("Failed to list layer records for manifest", "error", err, "manifest", manifestURI) 503 + http.Error(w, `{"error":"InternalServerError","message":"failed to list layer records"}`, http.StatusInternalServerError) 504 + return 505 + } 506 + 507 + w.Header().Set("Content-Type", "application/json") 508 + if err := json.NewEncoder(w).Encode(map[string]any{ 509 + "layers": records, 510 + }); err != nil { 511 + slog.Error("Failed to encode layer records response", "error", err) 512 + } 513 + } 514 + 515 + // HandleGetImageConfig returns the OCI image config record for a manifest digest. 516 + func (h *XRPCHandler) HandleGetImageConfig(w http.ResponseWriter, r *http.Request) { 517 + digest := r.URL.Query().Get("digest") 518 + if digest == "" { 519 + http.Error(w, `{"error":"InvalidRequest","message":"digest parameter is required"}`, http.StatusBadRequest) 520 + return 521 + } 522 + 523 + _, record, err := h.pds.GetImageConfigRecord(r.Context(), digest) 524 + if err != nil { 525 + http.Error(w, `{"error":"NotFound","message":"image config not found"}`, http.StatusNotFound) 526 + return 527 + } 528 + 529 + w.Header().Set("Content-Type", "application/json") 530 + if err := json.NewEncoder(w).Encode(record); err != nil { 531 + slog.Error("Failed to encode image config response", "error", err) 532 + } 488 533 } 489 534 490 535 // HandleListRecords lists records in a collection
+105
pkg/hold/pds/xrpc_test.go
··· 1062 1062 } 1063 1063 } 1064 1064 1065 + // Tests for HandleGetLayersForManifest 1066 + 1067 + func TestHandleGetLayersForManifest(t *testing.T) { 1068 + handler, ctx := setupTestXRPCHandlerWithIndex(t) 1069 + 1070 + manifestURI := "at://did:plc:testuser/io.atcr.manifest/abc123" 1071 + otherManifestURI := "at://did:plc:testuser/io.atcr.manifest/def456" 1072 + 1073 + // Create layer records for the target manifest 1074 + for i := range 3 { 1075 + record := atproto.NewLayerRecord( 1076 + fmt.Sprintf("sha256:layer%d", i), 1077 + int64(1024*(i+1)), 1078 + "application/vnd.oci.image.layer.v1.tar+gzip", 1079 + "did:plc:testuser", 1080 + manifestURI, 1081 + ) 1082 + if _, _, err := handler.pds.CreateLayerRecord(ctx, record); err != nil { 1083 + t.Fatalf("Failed to create layer record %d: %v", i, err) 1084 + } 1085 + } 1086 + 1087 + // Create a layer record for a different manifest (should not be returned) 1088 + otherRecord := atproto.NewLayerRecord( 1089 + "sha256:otherlayer", 1090 + 2048, 1091 + "application/vnd.oci.image.layer.v1.tar+gzip", 1092 + "did:plc:testuser", 1093 + otherManifestURI, 1094 + ) 1095 + if _, _, err := handler.pds.CreateLayerRecord(ctx, otherRecord); err != nil { 1096 + t.Fatalf("Failed to create other layer record: %v", err) 1097 + } 1098 + 1099 + // Query layers for the target manifest 1100 + req := makeXRPCGetRequest(atproto.HoldGetLayersForManifest, map[string]string{ 1101 + "manifest": manifestURI, 1102 + }) 1103 + w := httptest.NewRecorder() 1104 + handler.HandleGetLayersForManifest(w, req) 1105 + 1106 + result := assertJSONResponse(t, w, http.StatusOK) 1107 + 1108 + layers, ok := result["layers"].([]any) 1109 + if !ok { 1110 + t.Fatal("Expected layers array in response") 1111 + } 1112 + 1113 + if len(layers) != 3 { 1114 + t.Fatalf("Expected 3 layers, got %d", len(layers)) 1115 + } 1116 + 1117 + // Verify all layers have digests and belong to the target manifest 1118 + digests := make(map[string]bool) 1119 + for i, l := range layers { 1120 + layer, ok := l.(map[string]any) 1121 + if !ok { 1122 + t.Fatalf("Layer %d: expected map, got %T", i, l) 1123 + } 1124 + digest, ok := layer["digest"].(string) 1125 + if !ok || digest == "" { 1126 + t.Errorf("Layer %d: expected non-empty digest", i) 1127 + } 1128 + digests[digest] = true 1129 + } 1130 + for i := range 3 { 1131 + expected := fmt.Sprintf("sha256:layer%d", i) 1132 + if !digests[expected] { 1133 + t.Errorf("Expected digest %q in results", expected) 1134 + } 1135 + } 1136 + } 1137 + 1138 + func TestHandleGetLayersForManifest_MissingParam(t *testing.T) { 1139 + handler, _ := setupTestXRPCHandlerWithIndex(t) 1140 + 1141 + req := makeXRPCGetRequest(atproto.HoldGetLayersForManifest, map[string]string{}) 1142 + w := httptest.NewRecorder() 1143 + handler.HandleGetLayersForManifest(w, req) 1144 + 1145 + if w.Code != http.StatusBadRequest { 1146 + t.Errorf("Expected status 400, got %d", w.Code) 1147 + } 1148 + } 1149 + 1150 + func TestHandleGetLayersForManifest_NoMatchingLayers(t *testing.T) { 1151 + handler, _ := setupTestXRPCHandlerWithIndex(t) 1152 + 1153 + req := makeXRPCGetRequest(atproto.HoldGetLayersForManifest, map[string]string{ 1154 + "manifest": "at://did:plc:nobody/io.atcr.manifest/nonexistent", 1155 + }) 1156 + w := httptest.NewRecorder() 1157 + handler.HandleGetLayersForManifest(w, req) 1158 + 1159 + result := assertJSONResponse(t, w, http.StatusOK) 1160 + 1161 + layers, ok := result["layers"].([]any) 1162 + if !ok { 1163 + t.Fatal("Expected layers array in response") 1164 + } 1165 + if len(layers) != 0 { 1166 + t.Errorf("Expected 0 layers, got %d", len(layers)) 1167 + } 1168 + } 1169 + 1065 1170 // Tests for HandleDeleteRecord 1066 1171 1067 1172 // TestHandleDeleteRecord tests com.atproto.repo.deleteRecord
+19 -2
pkg/s3/mock.go
··· 400 400 m.Objects[key] = append([]byte{}, data...) 401 401 } 402 402 403 - // GetObject is a test helper to read an object from the mock store (nil if not found). 404 - func (m *MockS3Client) GetObject(key string) []byte { 403 + // GetObject implements S3Client 404 + func (m *MockS3Client) GetObject(ctx context.Context, input *awss3.GetObjectInput, opts ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) { 405 + m.mu.Lock() 406 + defer m.mu.Unlock() 407 + 408 + key := aws.ToString(input.Key) 409 + data, ok := m.Objects[key] 410 + if !ok { 411 + return nil, fmt.Errorf("NoSuchKey: %s", key) 412 + } 413 + 414 + return &awss3.GetObjectOutput{ 415 + Body: io.NopCloser(bytes.NewReader(bytes.Clone(data))), 416 + ContentLength: aws.Int64(int64(len(data))), 417 + }, nil 418 + } 419 + 420 + // GetObjectBytes is a test helper to read an object from the mock store (nil if not found). 421 + func (m *MockS3Client) GetObjectBytes(key string) []byte { 405 422 m.mu.Lock() 406 423 defer m.mu.Unlock() 407 424 data, ok := m.Objects[key]
+26
pkg/s3/types.go
··· 6 6 "bytes" 7 7 "context" 8 8 "fmt" 9 + "io" 9 10 "log/slog" 10 11 "net/url" 11 12 "strings" ··· 27 28 AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) 28 29 29 30 // Direct object operations 31 + GetObject(ctx context.Context, input *awss3.GetObjectInput, opts ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) 30 32 HeadObject(ctx context.Context, input *awss3.HeadObjectInput, opts ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) 31 33 PutObject(ctx context.Context, input *awss3.PutObjectInput, opts ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) 32 34 CopyObject(ctx context.Context, input *awss3.CopyObjectInput, opts ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error) ··· 68 70 // AbortMultipartUpload implements S3Client 69 71 func (r *RealS3Client) AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) { 70 72 return r.client.AbortMultipartUpload(ctx, input, opts...) 73 + } 74 + 75 + // GetObject implements S3Client 76 + func (r *RealS3Client) GetObject(ctx context.Context, input *awss3.GetObjectInput, opts ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) { 77 + return r.client.GetObject(ctx, input, opts...) 71 78 } 72 79 73 80 // HeadObject implements S3Client ··· 147 154 Client S3Client // S3 client for presigned URLs (interface for testability) 148 155 Bucket string // S3 bucket name 149 156 PathPrefix string // S3 path prefix (if any) 157 + } 158 + 159 + // GetBytes fetches an object from S3 and returns its contents as bytes. 160 + func (s *S3Service) GetBytes(ctx context.Context, blobPath string) ([]byte, error) { 161 + s3Key := strings.TrimPrefix(blobPath, "/") 162 + if s.PathPrefix != "" { 163 + s3Key = s.PathPrefix + "/" + s3Key 164 + } 165 + 166 + result, err := s.Client.GetObject(ctx, &awss3.GetObjectInput{ 167 + Bucket: &s.Bucket, 168 + Key: &s3Key, 169 + }) 170 + if err != nil { 171 + return nil, fmt.Errorf("get object %s: %w", s3Key, err) 172 + } 173 + defer result.Body.Close() 174 + 175 + return io.ReadAll(result.Body) 150 176 } 151 177 152 178 // NewS3Service initializes the S3 client for presigned URL generation