+22
-17
pkg/appview/db/models.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
}