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

begin support for helm-charts

evan.jarrett.net 347db5c3 e97e51a5

verified
Changed files
+162 -63
pkg
appview
auth
oauth
+22 -17
pkg/appview/db/models.go
··· 22 22 MediaType string 23 23 ConfigDigest string 24 24 ConfigSize int64 25 + ArtifactType string // container-image, helm-chart, unknown 25 26 CreatedAt time.Time 26 27 // Annotations removed - now stored in repository_annotations table 27 28 } ··· 75 76 CreatedAt time.Time 76 77 HoldEndpoint string // Hold endpoint for health checking 77 78 Reachable bool // Whether the hold endpoint is reachable 79 + ArtifactType string // container-image, helm-chart, unknown 78 80 } 79 81 80 82 // Repository represents an aggregated view of a user's repository ··· 108 110 109 111 // FeaturedRepository represents a repository in the featured section 110 112 type FeaturedRepository struct { 111 - OwnerDID string 112 - OwnerHandle string 113 - Repository string 114 - Title string 115 - Description string 116 - IconURL string 117 - StarCount int 118 - PullCount int 119 - IsStarred bool // Whether the current user has starred this repository 113 + OwnerDID string 114 + OwnerHandle string 115 + Repository string 116 + Title string 117 + Description string 118 + IconURL string 119 + StarCount int 120 + PullCount int 121 + IsStarred bool // Whether the current user has starred this repository 122 + ArtifactType string // container-image, helm-chart, unknown 120 123 } 121 124 122 125 // RepositoryWithStats combines repository data with statistics ··· 127 130 128 131 // RepoCardData contains all data needed to render a repository card 129 132 type RepoCardData struct { 130 - OwnerHandle string 131 - Repository string 132 - Title string 133 - Description string 134 - IconURL string 135 - StarCount int 136 - PullCount int 137 - IsStarred bool // Whether the current user has starred this repository 133 + OwnerHandle string 134 + Repository string 135 + Title string 136 + Description string 137 + IconURL string 138 + StarCount int 139 + PullCount int 140 + IsStarred bool // Whether the current user has starred this repository 141 + ArtifactType string // container-image, helm-chart, unknown 138 142 } 139 143 140 144 // PlatformInfo represents platform information (OS/Architecture) ··· 163 167 HasAttestations bool // true if manifest list contains attestation references 164 168 Reachable bool // Whether the hold endpoint is reachable 165 169 Pending bool // Whether health check is still in progress 170 + // Note: ArtifactType is available via embedded Manifest struct 166 171 }
+39 -18
pkg/appview/db/queries.go
··· 13 13 return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid) 14 14 } 15 15 16 + // GetArtifactType determines the artifact type based on config media type 17 + // Returns: "helm-chart", "container-image", or "unknown" 18 + func GetArtifactType(configMediaType string) string { 19 + switch { 20 + case strings.Contains(configMediaType, "helm.config"): 21 + return "helm-chart" 22 + case strings.Contains(configMediaType, "oci.image.config") || 23 + strings.Contains(configMediaType, "docker.container.image"): 24 + return "container-image" 25 + case configMediaType == "": 26 + // Manifest lists don't have a config - treat as container-image 27 + return "container-image" 28 + default: 29 + return "unknown" 30 + } 31 + } 32 + 16 33 // escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching. 17 34 // It also sanitizes the input to prevent injection attacks via special characters. 18 35 func escapeLikePattern(s string) string { ··· 53 70 COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 54 71 t.created_at, 55 72 m.hold_endpoint, 56 - COALESCE(rp.avatar_cid, '') 73 + COALESCE(rp.avatar_cid, ''), 74 + COALESCE(m.artifact_type, 'container-image') 57 75 FROM tags t 58 76 JOIN users u ON t.did = u.did 59 77 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest ··· 82 100 var p Push 83 101 var isStarredInt int 84 102 var avatarCID string 85 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil { 103 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID, &p.ArtifactType); err != nil { 86 104 return nil, 0, err 87 105 } 88 106 p.IsStarred = isStarredInt > 0 ··· 133 151 COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 134 152 t.created_at, 135 153 m.hold_endpoint, 136 - COALESCE(rp.avatar_cid, '') 154 + COALESCE(rp.avatar_cid, ''), 155 + COALESCE(m.artifact_type, 'container-image') 137 156 FROM tags t 138 157 JOIN users u ON t.did = u.did 139 158 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest ··· 162 181 var p Push 163 182 var isStarredInt int 164 183 var avatarCID string 165 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil { 184 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID, &p.ArtifactType); err != nil { 166 185 return nil, 0, err 167 186 } 168 187 p.IsStarred = isStarredInt > 0 ··· 274 293 // Get manifests for this repo 275 294 manifestRows, err := db.Query(` 276 295 SELECT id, digest, hold_endpoint, schema_version, media_type, 277 - config_digest, config_size, created_at 296 + config_digest, config_size, artifact_type, created_at 278 297 FROM manifests 279 298 WHERE did = ? AND repository = ? 280 299 ORDER BY created_at DESC ··· 290 309 m.Repository = r.Name 291 310 292 311 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 293 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil { 312 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil { 294 313 manifestRows.Close() 295 314 return nil, err 296 315 } ··· 560 579 _, err := db.Exec(` 561 580 INSERT INTO manifests 562 581 (did, repository, digest, hold_endpoint, schema_version, media_type, 563 - config_digest, config_size, created_at) 564 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 582 + config_digest, config_size, artifact_type, created_at) 583 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 565 584 ON CONFLICT(did, repository, digest) DO UPDATE SET 566 585 hold_endpoint = excluded.hold_endpoint, 567 586 schema_version = excluded.schema_version, 568 587 media_type = excluded.media_type, 569 588 config_digest = excluded.config_digest, 570 - config_size = excluded.config_size 589 + config_size = excluded.config_size, 590 + artifact_type = excluded.artifact_type 571 591 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 572 592 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 573 - manifest.ConfigSize, manifest.CreatedAt) 593 + manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt) 574 594 575 595 if err != nil { 576 596 return 0, err ··· 931 951 SELECT 932 952 m.id, m.did, m.repository, m.digest, m.media_type, 933 953 m.schema_version, m.created_at, 934 - m.config_digest, m.config_size, m.hold_endpoint, 954 + m.config_digest, m.config_size, m.hold_endpoint, m.artifact_type, 935 955 GROUP_CONCAT(DISTINCT t.tag) as tags, 936 956 COUNT(DISTINCT mr.digest) as platform_count 937 957 FROM manifests m ··· 964 984 if err := rows.Scan( 965 985 &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, 966 986 &m.SchemaVersion, &m.CreatedAt, 967 - &configDigest, &configSize, &m.HoldEndpoint, 987 + &configDigest, &configSize, &m.HoldEndpoint, &m.ArtifactType, 968 988 &tags, &m.PlatformCount, 969 989 ); err != nil { 970 990 return nil, err ··· 1062 1082 SELECT 1063 1083 m.id, m.did, m.repository, m.digest, m.media_type, 1064 1084 m.schema_version, m.created_at, 1065 - m.config_digest, m.config_size, m.hold_endpoint, 1085 + m.config_digest, m.config_size, m.hold_endpoint, m.artifact_type, 1066 1086 GROUP_CONCAT(DISTINCT t.tag) as tags 1067 1087 FROM manifests m 1068 1088 LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository ··· 1071 1091 `, did, repository, digest).Scan( 1072 1092 &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, 1073 1093 &m.SchemaVersion, &m.CreatedAt, 1074 - &configDigest, &configSize, &m.HoldEndpoint, 1094 + &configDigest, &configSize, &m.HoldEndpoint, &m.ArtifactType, 1075 1095 &tags, 1076 1096 ) 1077 1097 ··· 1374 1394 // Get manifests for this repo 1375 1395 manifestRows, err := db.Query(` 1376 1396 SELECT id, digest, hold_endpoint, schema_version, media_type, 1377 - config_digest, config_size, created_at 1397 + config_digest, config_size, artifact_type, created_at 1378 1398 FROM manifests 1379 1399 WHERE did = ? AND repository = ? 1380 1400 ORDER BY created_at DESC ··· 1390 1410 m.Repository = repository 1391 1411 1392 1412 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 1393 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil { 1413 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil { 1394 1414 manifestRows.Close() 1395 1415 return nil, err 1396 1416 } ··· 1675 1695 rs.pull_count, 1676 1696 rs.star_count, 1677 1697 COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0), 1678 - COALESCE(rp.avatar_cid, '') 1698 + COALESCE(rp.avatar_cid, ''), 1699 + COALESCE(m.artifact_type, 'container-image') 1679 1700 FROM latest_manifests lm 1680 1701 JOIN manifests m ON lm.latest_id = m.id 1681 1702 JOIN users u ON m.did = u.did ··· 1698 1719 var avatarCID string 1699 1720 1700 1721 if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, 1701 - &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt, &avatarCID); err != nil { 1722 + &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt, &avatarCID, &f.ArtifactType); err != nil { 1702 1723 return nil, err 1703 1724 } 1704 1725 f.IsStarred = isStarredInt > 0
+2
pkg/appview/db/schema.sql
··· 27 27 media_type TEXT NOT NULL, 28 28 config_digest TEXT, 29 29 config_size INTEGER, 30 + artifact_type TEXT NOT NULL DEFAULT 'container-image', -- container-image, helm-chart, unknown 30 31 created_at TIMESTAMP NOT NULL, 31 32 UNIQUE(did, repository, digest), 32 33 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ··· 34 35 CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 35 36 CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 36 37 CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 38 + CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type); 37 39 38 40 CREATE TABLE IF NOT EXISTS repository_annotations ( 39 41 did TEXT NOT NULL,
+9 -8
pkg/appview/handlers/home.go
··· 39 39 cards := make([]db.RepoCardData, len(featured)) 40 40 for i, repo := range featured { 41 41 cards[i] = db.RepoCardData{ 42 - OwnerHandle: repo.OwnerHandle, 43 - Repository: repo.Repository, 44 - Title: repo.Title, 45 - Description: repo.Description, 46 - IconURL: repo.IconURL, 47 - StarCount: repo.StarCount, 48 - PullCount: repo.PullCount, 49 - IsStarred: repo.IsStarred, 42 + OwnerHandle: repo.OwnerHandle, 43 + Repository: repo.Repository, 44 + Title: repo.Title, 45 + Description: repo.Description, 46 + IconURL: repo.IconURL, 47 + StarCount: repo.StarCount, 48 + PullCount: repo.PullCount, 49 + IsStarred: repo.IsStarred, 50 + ArtifactType: repo.ArtifactType, 50 51 } 51 52 } 52 53
+26 -17
pkg/appview/handlers/repository.go
··· 231 231 } 232 232 } 233 233 234 + // Determine dominant artifact type from manifests 235 + artifactType := "container-image" 236 + if len(manifests) > 0 { 237 + // Use the most recent manifest's artifact type 238 + artifactType = manifests[0].ArtifactType 239 + } 240 + 234 241 data := struct { 235 242 PageData 236 - Owner *db.User // Repository owner 237 - Repository *db.Repository // Repository summary 238 - Tags []db.TagWithPlatforms // Tags with platform info 239 - Manifests []db.ManifestWithMetadata // Top-level manifests only 240 - StarCount int 241 - IsStarred bool 242 - IsOwner bool // Whether current user owns this repository 243 - ReadmeHTML template.HTML 243 + Owner *db.User // Repository owner 244 + Repository *db.Repository // Repository summary 245 + Tags []db.TagWithPlatforms // Tags with platform info 246 + Manifests []db.ManifestWithMetadata // Top-level manifests only 247 + StarCount int 248 + IsStarred bool 249 + IsOwner bool // Whether current user owns this repository 250 + ReadmeHTML template.HTML 251 + ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown 244 252 }{ 245 - PageData: NewPageData(r, h.RegistryURL), 246 - Owner: owner, 247 - Repository: repo, 248 - Tags: tagsWithPlatforms, 249 - Manifests: manifests, 250 - StarCount: stats.StarCount, 251 - IsStarred: isStarred, 252 - IsOwner: isOwner, 253 - ReadmeHTML: readmeHTML, 253 + PageData: NewPageData(r, h.RegistryURL), 254 + Owner: owner, 255 + Repository: repo, 256 + Tags: tagsWithPlatforms, 257 + Manifests: manifests, 258 + StarCount: stats.StarCount, 259 + IsStarred: isStarred, 260 + IsOwner: isOwner, 261 + ReadmeHTML: readmeHTML, 262 + ArtifactType: artifactType, 254 263 } 255 264 256 265 if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+1 -1
pkg/appview/jetstream/backfill.go
··· 49 49 50 50 return &BackfillWorker{ 51 51 db: database, 52 - client: client, // This points to the relay 52 + client: client, // This points to the relay 53 53 processor: NewProcessor(database, false, NewStatsCache()), // Stats cache for aggregation 54 54 defaultHoldDID: defaultHoldDID, 55 55 testMode: testMode,
+7
pkg/appview/jetstream/processor.go
··· 119 119 holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 120 120 } 121 121 122 + // Detect artifact type from config media type 123 + artifactType := "container-image" 124 + if !isManifestList && manifestRecord.Config != nil { 125 + artifactType = db.GetArtifactType(manifestRecord.Config.MediaType) 126 + } 127 + 122 128 // Prepare manifest for insertion (WITHOUT annotation fields) 123 129 manifest := &db.Manifest{ 124 130 DID: did, ··· 127 133 MediaType: manifestRecord.MediaType, 128 134 SchemaVersion: manifestRecord.SchemaVersion, 129 135 HoldEndpoint: holdDID, 136 + ArtifactType: artifactType, 130 137 CreatedAt: manifestRecord.CreatedAt, 131 138 // Annotations removed - stored separately in repository_annotations table 132 139 }
+28
pkg/appview/static/css/style.css
··· 2429 2429 font-size: 1.5rem; 2430 2430 } 2431 2431 } 2432 + 2433 + /* Artifact type badges */ 2434 + .artifact-badge { 2435 + display: inline-flex; 2436 + align-items: center; 2437 + justify-content: center; 2438 + padding: 0.15rem 0.35rem; 2439 + border-radius: 4px; 2440 + font-size: 0.7rem; 2441 + font-weight: 500; 2442 + margin-left: 0.5rem; 2443 + vertical-align: middle; 2444 + } 2445 + 2446 + .artifact-badge.helm { 2447 + background-color: rgba(13, 108, 191, 0.15); 2448 + color: #0d6cbf; 2449 + } 2450 + 2451 + .artifact-badge i { 2452 + width: 12px; 2453 + height: 12px; 2454 + } 2455 + 2456 + .manifest-type.helm { 2457 + background-color: rgba(13, 108, 191, 0.15); 2458 + color: #0d6cbf; 2459 + }
+4
pkg/appview/templates/components/repo-card.html
··· 10 10 - IconURL: string (optional) - Repository icon URL 11 11 - StarCount: int - Number of stars 12 12 - PullCount: int - Number of pulls 13 + - ArtifactType: string - container-image, helm-chart, unknown 13 14 */}} 14 15 <a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="featured-card"> 15 16 <div class="featured-header"> ··· 23 24 <span class="featured-owner">{{ .OwnerHandle }}</span> 24 25 <span class="featured-separator">/</span> 25 26 <span class="featured-name">{{ .Repository }}</span> 27 + {{ if eq .ArtifactType "helm-chart" }} 28 + <span class="artifact-badge helm"><i data-lucide="anchor"></i></span> 29 + {{ end }} 26 30 </div> 27 31 {{ if .Description }} 28 32 <p class="featured-description">{{ .Description }}</p>
+18 -2
pkg/appview/templates/pages/repository.html
··· 101 101 102 102 <!-- Pull Command --> 103 103 <div class="pull-command-section"> 104 + {{ if eq .ArtifactType "helm-chart" }} 105 + <h3>Pull this chart</h3> 106 + {{ if .Tags }} 107 + {{ $firstTag := index .Tags 0 }} 108 + {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " $firstTag.Tag.Tag) }} 109 + {{ else }} 110 + {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name) }} 111 + {{ end }} 112 + {{ else }} 104 113 <h3>Pull this image</h3> 105 114 {{ if .Tags }} 106 115 {{ $firstTag := index .Tags 0 }} 107 116 {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }} 108 117 {{ else }} 109 118 {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }} 119 + {{ end }} 110 120 {{ end }} 111 121 </div> 112 122 </div> ··· 173 183 {{ end }} 174 184 </div> 175 185 </div> 176 - {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }} 186 + {{ if eq $.ArtifactType "helm-chart" }} 187 + {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .Tag.Tag) }} 188 + {{ else }} 189 + {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }} 190 + {{ end }} 177 191 </div> 178 192 {{ end }} 179 193 </div> ··· 199 213 <div> 200 214 {{ if .IsManifestList }} 201 215 <span class="manifest-type"><i data-lucide="package"></i> Multi-arch</span> 216 + {{ else if eq .ArtifactType "helm-chart" }} 217 + <span class="manifest-type helm"><i data-lucide="anchor"></i> Helm Chart</span> 202 218 {{ else }} 203 - <span class="manifest-type"><i data-lucide="file-text"></i> Image</span> 219 + <span class="manifest-type"><i data-lucide="box"></i> Image</span> 204 220 {{ end }} 205 221 {{ if .HasAttestations }} 206 222 <span class="badge-attestation"><i data-lucide="shield-check"></i> Attestations</span>
+3
pkg/appview/templates/partials/push-list.html
··· 14 14 <a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a> 15 15 <span class="push-separator">:</span> 16 16 <span class="push-tag">{{ .Tag }}</span> 17 + {{ if eq .ArtifactType "helm-chart" }} 18 + <span class="artifact-badge helm"><i data-lucide="anchor"></i></span> 19 + {{ end }} 17 20 </div> 18 21 <div class="push-stats"> 19 22 <span class="push-stat">
+3
pkg/auth/oauth/client.go
··· 93 93 "blob:application/vnd.docker.distribution.manifest.list.v2+json", 94 94 // OCI artifact manifests (for cosign signatures, SBOMs, attestations) 95 95 "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", 96 + // Helm chart support 97 + "blob:application/vnd.cncf.helm.config.v1+json", 98 + "blob:application/vnd.cncf.helm.chart.content.v1.tar+gzip", 96 99 // Image avatars 97 100 "blob:image/*", 98 101 }