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

This commit is contained in:
Evan Jarrett
2026-03-22 21:10:47 -05:00
parent 8adbc7505f
commit 385f8987fe
37 changed files with 1652 additions and 600 deletions
+1
View File
@@ -109,6 +109,7 @@ The hold's embedded PDS stores all operational data as ATProto records in a CAR
| `io.atcr.hold.layer` | Per-layer | Layer metadata (digest, size, media type) |
| `io.atcr.hold.stats` | Per-repo | Push/pull counts per owner+repository |
| `io.atcr.hold.scan` | Per-scan | Vulnerability scan results |
| `io.atcr.hold.image.config` | Per-manifest | OCI image config (history, env, entrypoint, labels) |
| `app.bsky.feed.post` | Status posts | Online/offline status, push notifications |
| `sh.tangled.actor.profile` | Singleton | Hold profile (name, description, avatar) |
+2
View File
@@ -80,6 +80,8 @@ All require `blob:write` permission via service token:
| `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership |
| `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export |
| `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info |
| `/xrpc/io.atcr.hold.getLayersForManifest` | GET | none | Get layer records for a manifest AT-URI |
| `/xrpc/io.atcr.hold.image.getConfig` | GET | none | Get OCI image config record for a manifest digest |
| `/xrpc/io.atcr.hold.listTiers` | GET | none | List hold's available tiers with quotas and features (scanOnPush) |
| `/xrpc/io.atcr.hold.updateCrewTier` | POST | appview token | Update crew member's tier |
+1 -1
View File
@@ -36,7 +36,7 @@ func DeleteUserDataFull(db DBTX, oauthStore *OAuthStore, did string) error {
}
// 3. Delete user (cascades to manifests, tags, stars, annotations, etc.)
if err := DeleteUserData(db, did); err != nil {
if _, err := DeleteUserData(db, did); err != nil {
slog.Error("Failed to delete user data", "did", did, "error", err)
return fmt.Errorf("failed to delete user data: %w", err)
}
+23 -6
View File
@@ -124,12 +124,13 @@ func SetRegistryURL(cards []RepoCardData, registryURL string) {
// PlatformInfo represents platform information (OS/Architecture)
type PlatformInfo struct {
OS string
Architecture string
Variant string
OSVersion string
Digest string // child platform manifest digest (for manifest lists)
HoldEndpoint string // hold endpoint for this platform manifest
OS string
Architecture string
Variant string
OSVersion string
Digest string // child platform manifest digest (for manifest lists)
HoldEndpoint string // hold endpoint for this platform manifest
CompressedSize int64 // sum of layer sizes (compressed)
}
// TagWithPlatforms extends Tag with platform information
@@ -140,6 +141,7 @@ type TagWithPlatforms struct {
IsMultiArch bool
HasAttestations bool // true if manifest list contains attestation references
ArtifactType string // container-image, helm-chart, unknown
CompressedSize int64 // sum of layer sizes for single-arch tags
}
// ManifestWithMetadata extends Manifest with tags and platform information
@@ -155,6 +157,21 @@ type ManifestWithMetadata struct {
// Note: ArtifactType is available via embedded Manifest struct
}
// ManifestEntry is a unified view model for the tags tab.
// Every entry is a manifest — labeled by tag name or digest.
type ManifestEntry struct {
Label string // tag name, or digest if untagged
Digest string // manifest digest
IsTagged bool
CreatedAt time.Time
HoldEndpoint string
Platforms []PlatformInfo
IsMultiArch bool
HasAttestations bool
ArtifactType string
CompressedSize int64 // for single-arch
}
// AttestationDetail represents an attestation manifest and its layers
type AttestationDetail struct {
Digest string
+95 -71
View File
@@ -692,12 +692,59 @@ func DeleteTag(db DBTX, did, repository, tag string) error {
return err
}
// GetTagsWithPlatforms returns all tags for a repository with platform information
// LatestTagInfo holds the most recent tag name and its artifact type.
type LatestTagInfo struct {
Tag string
ArtifactType string
}
// RepositoryExists checks if any manifests exist for a given repository.
func RepositoryExists(db DBTX, did, repository string) (bool, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM manifests WHERE did = ? AND repository = ? LIMIT 1`, did, repository).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetLatestTag returns the most recently created tag and its artifact type for a repository.
// Returns nil if no tags exist.
func GetLatestTag(db DBTX, did, repository string) (*LatestTagInfo, error) {
var info LatestTagInfo
err := db.QueryRow(`
SELECT t.tag, COALESCE(m.artifact_type, 'container-image')
FROM tags t
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
WHERE t.did = ? AND t.repository = ?
ORDER BY t.created_at DESC LIMIT 1
`, did, repository).Scan(&info.Tag, &info.ArtifactType)
if err != nil {
return nil, nil // no tags is not an error
}
return &info, nil
}
// CountTags returns the total number of tags for a repository.
func CountTags(db DBTX, did, repository string) (int, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM tags WHERE did = ? AND repository = ?`, did, repository).Scan(&count)
return count, err
}
// GetTagsWithPlatforms returns tags for a repository with platform information
// Only multi-arch tags (manifest lists) have platform info in manifest_references
// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
func GetTagsWithPlatforms(db DBTX, did, repository string) ([]TagWithPlatforms, error) {
func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) {
rows, err := db.Query(`
WITH paged_tags AS (
SELECT id, did, repository, tag, digest, created_at
FROM tags
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
)
SELECT
t.id,
t.did,
@@ -714,14 +761,14 @@ func GetTagsWithPlatforms(db DBTX, did, repository string) ([]TagWithPlatforms,
COALESCE(mr.platform_os_version, '') as platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation,
COALESCE(mr.digest, '') as child_digest,
COALESCE(child_m.hold_endpoint, m.hold_endpoint, '') as child_hold_endpoint
FROM tags t
COALESCE(child_m.hold_endpoint, m.hold_endpoint, '') as child_hold_endpoint,
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = COALESCE(child_m.id, m.id)), 0) as compressed_size
FROM paged_tags t
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository
WHERE t.did = ? AND t.repository = ?
ORDER BY t.created_at DESC, mr.reference_index
`, did, repository)
`, did, repository, limit, offset)
if err != nil {
return nil, err
@@ -738,11 +785,12 @@ func GetTagsWithPlatforms(db DBTX, did, repository string) ([]TagWithPlatforms,
var platformOS, platformArch, platformVariant, platformOSVersion string
var isAttestation bool
var childDigest, childHoldEndpoint string
var compressedSize int64
if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt,
&mediaType, &artifactType, &holdEndpoint,
&platformOS, &platformArch, &platformVariant, &platformOSVersion,
&isAttestation, &childDigest, &childHoldEndpoint); err != nil {
&isAttestation, &childDigest, &childHoldEndpoint, &compressedSize); err != nil {
return nil, err
}
@@ -750,10 +798,11 @@ func GetTagsWithPlatforms(db DBTX, did, repository string) ([]TagWithPlatforms,
tagKey := t.Tag
if _, exists := tagMap[tagKey]; !exists {
tagMap[tagKey] = &TagWithPlatforms{
Tag: t,
HoldEndpoint: holdEndpoint,
Platforms: []PlatformInfo{},
ArtifactType: artifactType,
Tag: t,
HoldEndpoint: holdEndpoint,
Platforms: []PlatformInfo{},
ArtifactType: artifactType,
CompressedSize: compressedSize, // for single-arch (no manifest_references row)
}
tagOrder = append(tagOrder, tagKey)
}
@@ -768,12 +817,13 @@ func GetTagsWithPlatforms(db DBTX, did, repository string) ([]TagWithPlatforms,
// Add platform info if present (only for multi-arch manifest lists)
if platformOS != "" || platformArch != "" {
tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{
OS: platformOS,
Architecture: platformArch,
Variant: platformVariant,
OSVersion: platformOSVersion,
Digest: childDigest,
HoldEndpoint: childHoldEndpoint,
OS: platformOS,
Architecture: platformArch,
Variant: platformVariant,
OSVersion: platformOSVersion,
Digest: childDigest,
HoldEndpoint: childHoldEndpoint,
CompressedSize: compressedSize,
})
}
}
@@ -809,19 +859,14 @@ func DeleteManifest(db DBTX, did, repository, digest string) error {
//
// Due to ON DELETE CASCADE in the schema, deleting from users will automatically
// cascade to: manifests, tags, layers, references, annotations, stars, repo_pages, etc.
func DeleteUserData(db DBTX, did string) error {
func DeleteUserData(db DBTX, did string) (bool, error) {
result, err := db.Exec(`DELETE FROM users WHERE did = ?`, did)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
return false, fmt.Errorf("failed to delete user: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
// User didn't exist, nothing to delete
return nil
}
return nil
return rowsAffected > 0, nil
}
// GetManifest fetches a single manifest by digest
@@ -1086,15 +1131,19 @@ func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([
if manifests[i].IsManifestList {
platformRows, err := db.Query(`
SELECT
mr.platform_os,
mr.platform_architecture,
mr.platform_variant,
mr.platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation
COALESCE(mr.platform_os, '') as platform_os,
COALESCE(mr.platform_architecture, '') as platform_architecture,
COALESCE(mr.platform_variant, '') as platform_variant,
COALESCE(mr.platform_os_version, '') as platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation,
COALESCE(mr.digest, '') as child_digest,
COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint,
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = child_m.id), 0) as compressed_size
FROM manifest_references mr
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ?
WHERE mr.manifest_id = ?
ORDER BY mr.reference_index
`, manifests[i].ID)
`, manifests[i].DID, manifests[i].Repository, manifests[i].ID)
if err != nil {
return nil, err
@@ -1103,10 +1152,10 @@ func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([
manifests[i].Platforms = []PlatformInfo{}
for platformRows.Next() {
var p PlatformInfo
var os, arch, variant, osVersion sql.NullString
var isAttestation bool
if err := platformRows.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
if err := platformRows.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion,
&isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil {
platformRows.Close()
return nil, err
}
@@ -1114,23 +1163,9 @@ func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([
// Track if manifest list has attestations
if isAttestation {
manifests[i].HasAttestations = true
// Skip attestation references in platform display
continue
}
if os.Valid {
p.OS = os.String
}
if arch.Valid {
p.Architecture = arch.String
}
if variant.Valid {
p.Variant = variant.String
}
if osVersion.Valid {
p.OSVersion = osVersion.String
}
manifests[i].Platforms = append(manifests[i].Platforms, p)
}
platformRows.Close()
@@ -1190,19 +1225,23 @@ func GetManifestDetail(db DBTX, did, repository, digest string) (*ManifestWithMe
// Determine if manifest list
m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
// If this is a manifest list, get platform details
// If this is a manifest list, get platform details with child digests and sizes
if m.IsManifestList {
platforms, err := db.Query(`
SELECT
mr.platform_os,
mr.platform_architecture,
mr.platform_variant,
mr.platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation
COALESCE(mr.platform_os, '') as platform_os,
COALESCE(mr.platform_architecture, '') as platform_architecture,
COALESCE(mr.platform_variant, '') as platform_variant,
COALESCE(mr.platform_os_version, '') as platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation,
COALESCE(mr.digest, '') as child_digest,
COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint,
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = child_m.id), 0) as compressed_size
FROM manifest_references mr
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ?
WHERE mr.manifest_id = ?
ORDER BY mr.reference_index
`, m.ID)
`, m.DID, m.Repository, m.ID)
if err != nil {
return nil, err
@@ -1212,33 +1251,18 @@ func GetManifestDetail(db DBTX, did, repository, digest string) (*ManifestWithMe
m.Platforms = []PlatformInfo{}
for platforms.Next() {
var p PlatformInfo
var os, arch, variant, osVersion sql.NullString
var isAttestation bool
if err := platforms.Scan(&os, &arch, &variant, &osVersion, &isAttestation); err != nil {
if err := platforms.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion,
&isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil {
return nil, err
}
// Track if manifest list has attestations
if isAttestation {
m.HasAttestations = true
// Skip attestation references in platform display
continue
}
if os.Valid {
p.OS = os.String
}
if arch.Valid {
p.Architecture = arch.String
}
if variant.Valid {
p.Variant = variant.String
}
if osVersion.Valid {
p.OSVersion = osVersion.String
}
m.Platforms = append(m.Platforms, p)
}
+6 -4
View File
@@ -882,7 +882,7 @@ func TestGetTagsWithPlatforms(t *testing.T) {
t.Fatalf("Failed to insert single-arch tag: %v", err)
}
tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp")
tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0)
if err != nil {
t.Fatalf("Failed to get tags with platforms: %v", err)
}
@@ -951,7 +951,7 @@ func TestGetTagsWithPlatforms(t *testing.T) {
t.Fatalf("Failed to insert multi-arch tag: %v", err)
}
multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp")
multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0)
if err != nil {
t.Fatalf("Failed to get multi-arch tags with platforms: %v", err)
}
@@ -1280,7 +1280,7 @@ func TestDeleteUserData(t *testing.T) {
}
// Delete user data
if err := DeleteUserData(db, testUser.DID); err != nil {
if _, err := DeleteUserData(db, testUser.DID); err != nil {
t.Fatalf("Failed to delete user data: %v", err)
}
@@ -1303,8 +1303,10 @@ func TestDeleteUserData(t *testing.T) {
}
// Test idempotency - deleting non-existent user should not error
if err := DeleteUserData(db, testUser.DID); err != nil {
if deleted, err := DeleteUserData(db, testUser.DID); err != nil {
t.Errorf("Deleting non-existent user should not error, got: %v", err)
} else if deleted {
t.Errorf("Deleting non-existent user should return false, got true")
}
}
+271 -148
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -50,97 +51,41 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
owner.Handle = resolvedHandle
}
// Fetch tags with platform information
tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository)
// Check if repository exists
exists, err := db.RepositoryExists(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Fetch top-level manifests (filters out platform-specific manifests)
manifests, err := db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Check health status for each manifest's hold endpoint (concurrent with 1s timeout)
if h.HealthChecker != nil {
// Create context with 1 second deadline for fast-fail
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
for i := range manifests {
if manifests[i].HoldEndpoint == "" {
// No hold endpoint, mark as unreachable
manifests[i].Reachable = false
manifests[i].Pending = false
continue
}
wg.Go(func() {
endpoint := manifests[i].HoldEndpoint
// Try to get cached status first (instant)
if cached := h.HealthChecker.GetCachedStatus(endpoint); cached != nil {
mu.Lock()
manifests[i].Reachable = cached.Reachable
manifests[i].Pending = false
mu.Unlock()
return
}
// Perform health check with timeout context
reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
mu.Lock()
if ctx.Err() == context.DeadlineExceeded {
// Timeout - mark as pending for HTMX polling
manifests[i].Reachable = false
manifests[i].Pending = true
} else if err != nil {
// Error - mark as unreachable
manifests[i].Reachable = false
manifests[i].Pending = false
} else {
// Success
manifests[i].Reachable = reachable
manifests[i].Pending = false
}
mu.Unlock()
})
}
// Wait for all checks to complete or timeout
wg.Wait()
} else {
// If no health checker, assume all are reachable (backward compatibility)
for i := range manifests {
manifests[i].Reachable = true
manifests[i].Pending = false
}
}
if len(tagsWithPlatforms) == 0 && len(manifests) == 0 {
if !exists {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
// Fetch latest tag for pull command
latestTag, err := db.GetLatestTag(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Determine artifact type from latest tag
artifactType := "container-image"
latestTagName := ""
if latestTag != nil {
latestTagName = latestTag.Tag
artifactType = latestTag.ArtifactType
}
// Create repository summary
repo := &db.Repository{
Name: repository,
TagCount: len(tagsWithPlatforms),
ManifestCount: len(manifests),
Name: repository,
}
// Fetch repository metadata from annotations table
metadata, err := db.GetRepositoryMetadata(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
slog.Warn("Failed to fetch repository metadata", "error", err)
// Continue without metadata on error
} else {
repo.Title = metadata["org.opencontainers.image.title"]
repo.Description = metadata["org.opencontainers.image.description"]
@@ -156,7 +101,6 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
slog.Warn("Failed to fetch repository stats", "error", err)
// Continue with zero stats on error
stats = &db.RepositoryStats{StarCount: 0}
}
@@ -164,10 +108,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
isStarred := false
user := middleware.GetUser(r)
if user != nil && h.Refresher != nil && h.Directory != nil {
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Check if star record exists
rkey := atproto.StarRecordKey(owner.DID, repository)
_, err := pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey)
isStarred = (err == nil)
@@ -182,14 +123,11 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Fetch README content from repo page record or annotations
var readmeHTML template.HTML
// Try repo page record from database (synced from PDS via Jetstream)
repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository)
if err == nil && repoPage != nil {
// Use repo page avatar if present
if repoPage.AvatarCID != "" {
repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID)
}
// Render description as markdown if present
if repoPage.Description != "" && h.ReadmeFetcher != nil {
html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description))
if err != nil {
@@ -199,12 +137,9 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
}
}
// Fall back to fetching README from URL annotations if no description in repo page
if readmeHTML == "" && h.ReadmeFetcher != nil {
// Fall back to fetching from URL annotations
readmeURL := repo.ReadmeURL
if readmeURL == "" && repo.SourceURL != "" {
// Try to derive README URL from source URL
readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "main")
if readmeURL == "" {
readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "master")
@@ -220,44 +155,6 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
}
// Determine artifact type for header section from first tag
// This is used for the "Pull this image/chart" header command
artifactType := "container-image"
if len(tagsWithPlatforms) > 0 {
artifactType = tagsWithPlatforms[0].ArtifactType
} else if len(manifests) > 0 {
// Fallback to manifests if no tags
artifactType = manifests[0].ArtifactType
}
// Collect digests for batch scan-result requests, grouped by hold endpoint
holdDigests := make(map[string][]string) // holdEndpoint → []hexDigest
seen := make(map[string]bool) // dedup digests
for _, t := range tagsWithPlatforms {
if len(t.Platforms) > 0 {
// Multi-arch: collect each platform's child digest
for _, p := range t.Platforms {
if p.Digest != "" && p.HoldEndpoint != "" && !seen[p.Digest] {
seen[p.Digest] = true
hex := strings.TrimPrefix(p.Digest, "sha256:")
holdDigests[p.HoldEndpoint] = append(holdDigests[p.HoldEndpoint], hex)
}
}
} else if t.HoldEndpoint != "" {
// Single-arch: use tag's own digest
if !seen[t.Digest] {
seen[t.Digest] = true
hex := strings.TrimPrefix(t.Digest, "sha256:")
holdDigests[t.HoldEndpoint] = append(holdDigests[t.HoldEndpoint], hex)
}
}
}
var scanBatchParams []template.HTML
for hold, digests := range holdDigests {
scanBatchParams = append(scanBatchParams, template.HTML(
"holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests, ",")))
}
// Build page meta
title := owner.Handle + "/" + repository + " - " + h.ClientShortName
if repo.Title != "" {
@@ -284,32 +181,28 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
data := struct {
PageData
Meta *PageMeta
Owner *db.User // Repository owner
Repository *db.Repository // Repository summary
Tags []db.TagWithPlatforms // Tags with platform info
Manifests []db.ManifestWithMetadata // Top-level manifests only
StarCount int
PullCount int
IsStarred bool
IsOwner bool // Whether current user owns this repository
ReadmeHTML template.HTML
ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown
ScanBatchParams []template.HTML // Pre-encoded query strings for batch scan-result endpoint (one per hold)
Meta *PageMeta
Owner *db.User
Repository *db.Repository
LatestTag string
StarCount int
PullCount int
IsStarred bool
IsOwner bool
ReadmeHTML template.HTML
ArtifactType string
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
Owner: owner,
Repository: repo,
Tags: tagsWithPlatforms,
Manifests: manifests,
StarCount: stats.StarCount,
PullCount: stats.PullCount,
IsStarred: isStarred,
IsOwner: isOwner,
ReadmeHTML: readmeHTML,
ArtifactType: artifactType,
ScanBatchParams: scanBatchParams,
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
Owner: owner,
Repository: repo,
LatestTag: latestTagName,
StarCount: stats.StarCount,
PullCount: stats.PullCount,
IsStarred: isStarred,
IsOwner: isOwner,
ReadmeHTML: readmeHTML,
ArtifactType: artifactType,
}
if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
@@ -317,3 +210,233 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
}
// RepositoryTagsHandler returns the tags+manifests HTMX partial for a repository
type RepositoryTagsHandler struct {
BaseUIHandler
}
func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "handle")
repository := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil || owner == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
// Parse pagination
const pageSize = 50
offset := 0
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed > 0 {
offset = parsed
}
}
// Count total tags for pagination
totalTags, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Fetch tags with platform information and compressed sizes
tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Fetch untagged manifests only on first page
var manifests []db.ManifestWithMetadata
if offset == 0 {
manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// Check health status for each manifest's hold endpoint
if h.HealthChecker != nil {
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
for i := range manifests {
if manifests[i].HoldEndpoint == "" {
manifests[i].Reachable = false
manifests[i].Pending = false
continue
}
wg.Go(func() {
endpoint := manifests[i].HoldEndpoint
if cached := h.HealthChecker.GetCachedStatus(endpoint); cached != nil {
mu.Lock()
manifests[i].Reachable = cached.Reachable
manifests[i].Pending = false
mu.Unlock()
return
}
reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
mu.Lock()
if ctx.Err() == context.DeadlineExceeded {
manifests[i].Reachable = false
manifests[i].Pending = true
} else if err != nil {
manifests[i].Reachable = false
manifests[i].Pending = false
} else {
manifests[i].Reachable = reachable
manifests[i].Pending = false
}
mu.Unlock()
})
}
wg.Wait()
} else {
for i := range manifests {
manifests[i].Reachable = true
manifests[i].Pending = false
}
}
// Check if current user is the repository owner
isOwner := false
user := middleware.GetUser(r)
if user != nil {
isOwner = (user.DID == owner.DID)
}
// Build unified entries list: tagged first, then untagged.
// Single-arch entries get a one-element Platforms slice so the template
// can always just range over .Platforms without branching.
var entries []db.ManifestEntry
for _, t := range tagsWithPlatforms {
platforms := t.Platforms
if len(platforms) == 0 {
platforms = []db.PlatformInfo{{
Digest: t.Digest,
HoldEndpoint: t.HoldEndpoint,
CompressedSize: t.CompressedSize,
}}
}
entries = append(entries, db.ManifestEntry{
Label: t.Tag.Tag,
Digest: t.Digest,
IsTagged: true,
CreatedAt: t.CreatedAt,
HoldEndpoint: t.HoldEndpoint,
Platforms: platforms,
IsMultiArch: t.IsMultiArch,
HasAttestations: t.HasAttestations,
ArtifactType: t.ArtifactType,
})
}
for _, m := range manifests {
if len(m.Tags) > 0 {
continue
}
platforms := m.Platforms
if len(platforms) == 0 {
platforms = []db.PlatformInfo{{
Digest: m.Digest,
HoldEndpoint: m.HoldEndpoint,
}}
}
entries = append(entries, db.ManifestEntry{
Label: m.Digest,
Digest: m.Digest,
IsTagged: false,
CreatedAt: m.CreatedAt,
HoldEndpoint: m.HoldEndpoint,
Platforms: platforms,
IsMultiArch: m.IsManifestList,
HasAttestations: m.HasAttestations,
ArtifactType: m.ArtifactType,
})
}
// Collect digests for batch scan-result requests
holdDigests := make(map[string][]string)
seen := make(map[string]bool)
for _, e := range entries {
if len(e.Platforms) > 0 {
for _, p := range e.Platforms {
if p.Digest != "" && p.HoldEndpoint != "" && !seen[p.Digest] {
seen[p.Digest] = true
hex := strings.TrimPrefix(p.Digest, "sha256:")
holdDigests[p.HoldEndpoint] = append(holdDigests[p.HoldEndpoint], hex)
}
}
} else if e.HoldEndpoint != "" {
if !seen[e.Digest] {
seen[e.Digest] = true
hex := strings.TrimPrefix(e.Digest, "sha256:")
holdDigests[e.HoldEndpoint] = append(holdDigests[e.HoldEndpoint], hex)
}
}
}
var scanBatchParams []template.HTML
for hold, digests := range holdDigests {
// Chunk into batches of 50 to match the batch handler's limit
for i := 0; i < len(digests); i += 50 {
end := i + 50
if end > len(digests) {
end = len(digests)
}
scanBatchParams = append(scanBatchParams, template.HTML(
"holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests[i:end], ",")))
}
}
hasMore := offset+pageSize < totalTags
isFirstPage := offset == 0
data := struct {
Owner *db.User
Repository *db.Repository
Entries []db.ManifestEntry
IsOwner bool
ScanBatchParams []template.HTML
RegistryURL string
HasMore bool
NextOffset int
IsFirstPage bool
}{
Owner: owner,
Repository: &db.Repository{Name: repository},
Entries: entries,
IsOwner: isOwner,
ScanBatchParams: scanBatchParams,
RegistryURL: h.RegistryURL,
HasMore: hasMore,
NextOffset: offset + pageSize,
IsFirstPage: isFirstPage,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templateName := "repo-tags"
if !isFirstPage {
templateName = "repo-tags-page"
}
if err := h.Templates.ExecuteTemplate(w, templateName, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
+37 -57
View File
@@ -49,30 +49,14 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve hold identity: holdEndpoint may be a DID or URL
holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint)
hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if err != nil {
slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err)
h.renderBadge(w, vulnBadgeData{Error: true})
return
}
// Check if this hold has a successor — scan records may live there instead
resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID)
// Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID;
// otherwise use the original holdEndpoint (which may already be a URL).
holdURLTarget := holdEndpoint
if resolvedHoldDID != holdDID {
holdDID = resolvedHoldDID
holdURLTarget = resolvedHoldDID
}
holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget)
if err != nil {
slog.Debug("Failed to resolve hold URL", "holdEndpoint", holdEndpoint, "error", err)
slog.Debug("Failed to resolve hold", "holdEndpoint", holdEndpoint, "error", err)
h.renderBadge(w, vulnBadgeData{Error: true})
return
}
holdDID := hold.DID
holdURL := hold.URL
// Compute rkey from digest (strip sha256: prefix)
rkey := strings.TrimPrefix(digest, "sha256:")
@@ -226,36 +210,17 @@ func (h *BatchScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
digests = digests[:50]
}
holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint)
hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if err != nil {
// Can't resolve hold — render empty OOB spans
slog.Debug("Failed to resolve hold DID for batch scan", "holdEndpoint", holdEndpoint, "error", err)
w.Header().Set("Content-Type", "text/html")
for _, d := range digests {
fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d))
}
return
}
// Check if this hold has a successor — scan records may live there instead
resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID)
// Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID;
// otherwise use the original holdEndpoint (which may already be a URL).
holdURLTarget := holdEndpoint
if resolvedHoldDID != holdDID {
holdDID = resolvedHoldDID
holdURLTarget = resolvedHoldDID
}
holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget)
if err != nil {
slog.Debug("Failed to resolve hold URL for batch scan", "holdEndpoint", holdEndpoint, "error", err)
slog.Debug("Failed to resolve hold for batch scan", "holdEndpoint", holdEndpoint, "error", err)
w.Header().Set("Content-Type", "text/html")
for _, d := range digests {
fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d))
}
return
}
holdDID := hold.DID
holdURL := hold.URL
// Fetch scan records concurrently with a semaphore to limit parallelism
type result struct {
@@ -294,21 +259,36 @@ func (h *BatchScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
}
}
// resolveHoldSuccessor checks if a hold has a successor in the cached captain records.
// Returns the successor DID if set, otherwise returns the original holdDID.
// Single-hop only — does not follow chains.
func resolveHoldSuccessor(database *sql.DB, holdDID string) string {
if database == nil {
return holdDID
// ResolvedHold contains the resolved DID and URL for a hold endpoint,
// after following any successor chain.
type ResolvedHold struct {
DID string
URL string
}
// ResolveHold resolves a hold endpoint (DID, URL, or hostname) to its final
// DID and URL, following a single successor hop if one exists in the captain records.
func ResolveHold(ctx context.Context, database *sql.DB, holdEndpoint string) (*ResolvedHold, error) {
holdDID, err := atproto.ResolveHoldDID(ctx, holdEndpoint)
if err != nil {
return nil, fmt.Errorf("resolve hold DID: %w", err)
}
captain, err := db.GetCaptainRecord(database, holdDID)
if err != nil || captain == nil {
return holdDID
// Check for successor
resolveTarget := holdEndpoint
if database != nil {
captain, err := db.GetCaptainRecord(database, holdDID)
if err == nil && captain != nil && captain.Successor != "" {
slog.Debug("Following hold successor", "from", holdDID, "to", captain.Successor)
holdDID = captain.Successor
resolveTarget = captain.Successor
}
}
if captain.Successor != "" {
slog.Debug("Scan result: following hold successor",
"from", holdDID, "to", captain.Successor)
return captain.Successor
holdURL, err := atproto.ResolveHoldURL(ctx, resolveTarget)
if err != nil {
return nil, fmt.Errorf("resolve hold URL: %w", err)
}
return holdDID
return &ResolvedHold{DID: holdDID, URL: holdURL}, nil
}
+65 -6
View File
@@ -110,9 +110,9 @@ func TestScanResult_WithVulnerabilities(t *testing.T) {
if !strings.Contains(body, `data-tip="Low">3<`) {
t.Error("Expected low count of 3")
}
// Should be clickable (has openVulnDetails)
if !strings.Contains(body, "openVulnDetails") {
t.Error("Expected body to contain openVulnDetails click handler")
// Should show vulnerability strip with tooltip
if !strings.Contains(body, "vuln-strip") {
t.Error("Expected body to contain vuln-strip class")
}
}
@@ -141,9 +141,9 @@ func TestScanResult_Clean(t *testing.T) {
if !strings.Contains(body, "badge-success") {
t.Error("Expected body to contain badge-success for clean scan")
}
// Should NOT be clickable
if strings.Contains(body, "openVulnDetails") {
t.Error("Clean badge should not have openVulnDetails click handler")
// Clean badge should not have vuln-strip
if strings.Contains(body, "vuln-strip") {
t.Error("Clean badge should not have vuln-strip")
}
}
@@ -415,6 +415,65 @@ func TestBatchScanResult_HoldUnreachable(t *testing.T) {
}
}
// --- ResolveHold tests ---
func TestResolveHold_DirectURL(t *testing.T) {
// Mock hold that serves DID resolution
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/atproto-did" {
w.Write([]byte("did:web:hold.example.com"))
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer hold.Close()
resolved, err := handlers.ResolveHold(t.Context(), nil, hold.URL)
if err != nil {
t.Fatalf("ResolveHold failed: %v", err)
}
if resolved.DID != "did:web:hold.example.com" {
t.Errorf("DID = %q, want %q", resolved.DID, "did:web:hold.example.com")
}
if resolved.URL != hold.URL {
t.Errorf("URL = %q, want %q", resolved.URL, hold.URL)
}
}
func TestResolveHold_NilDB_NoSuccessor(t *testing.T) {
// With nil DB, successor check is skipped
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/atproto-did" {
w.Write([]byte("did:web:hold.example.com"))
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer hold.Close()
resolved, err := handlers.ResolveHold(t.Context(), nil, hold.URL)
if err != nil {
t.Fatalf("ResolveHold failed: %v", err)
}
// Should resolve to the original hold since no DB to check successor
if resolved.DID != "did:web:hold.example.com" {
t.Errorf("DID = %q, want %q", resolved.DID, "did:web:hold.example.com")
}
if resolved.URL != hold.URL {
t.Errorf("URL = %q, want %q", resolved.URL, hold.URL)
}
}
func TestResolveHold_Unreachable(t *testing.T) {
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
hold.Close()
_, err := handlers.ResolveHold(t.Context(), nil, hold.URL)
if err == nil {
t.Error("Expected error for unreachable hold")
}
}
func TestBatchScanResult_SingleDigest(t *testing.T) {
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if handleMockDID(w, r) {
+143 -9
View File
@@ -97,19 +97,14 @@ func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint)
if err != nil {
slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err)
h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold identity"})
return
}
// Resolve to HTTP endpoint URL (handles DID, URL, or hostname)
holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint)
hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if err != nil {
slog.Debug("Failed to resolve hold", "holdEndpoint", holdEndpoint, "error", err)
h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold endpoint"})
return
}
holdDID := hold.DID
holdURL := hold.URL
rkey := strings.TrimPrefix(digest, "sha256:")
@@ -255,3 +250,142 @@ func (h *VulnDetailsHandler) renderDetails(w http.ResponseWriter, data vulnDetai
slog.Warn("Failed to render vuln details", "error", err)
}
}
// FetchVulnDetails fetches vulnerability scan details for a digest from a hold.
// This is the shared logic used by both VulnDetailsHandler and DigestDetailHandler.
// holdEndpoint should already be resolved (successor-aware) before calling this.
func FetchVulnDetails(ctx context.Context, holdEndpoint, digest string) vulnDetailsData {
holdDID, err := atproto.ResolveHoldDID(ctx, holdEndpoint)
if err != nil {
return vulnDetailsData{Error: "Could not resolve hold identity"}
}
holdURL, err := atproto.ResolveHoldURL(ctx, holdEndpoint)
if err != nil {
return vulnDetailsData{Error: "Could not resolve hold endpoint"}
}
rkey := strings.TrimPrefix(digest, "sha256:")
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Fetch the scan record
scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
holdURL,
url.QueryEscape(holdDID),
url.QueryEscape(atproto.ScanCollection),
url.QueryEscape(rkey),
)
req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
if err != nil {
return vulnDetailsData{Error: "Failed to build request"}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return vulnDetailsData{Error: "Hold service unreachable"}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return vulnDetailsData{Error: "No scan record found"}
}
var envelope struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return vulnDetailsData{Error: "Failed to parse scan record"}
}
var scanRecord atproto.ScanRecord
if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
return vulnDetailsData{Error: "Failed to parse scan record"}
}
summary := vulnSummary{
Critical: scanRecord.Critical,
High: scanRecord.High,
Medium: scanRecord.Medium,
Low: scanRecord.Low,
Total: scanRecord.Total,
}
// Fetch the vulnerability report blob
if scanRecord.VulnReportBlob == nil || scanRecord.VulnReportBlob.Ref.String() == "" {
return vulnDetailsData{
Summary: summary,
ScannedAt: scanRecord.ScannedAt,
Error: "No detailed vulnerability report available. Only summary counts were recorded.",
}
}
blobCID := scanRecord.VulnReportBlob.Ref.String()
blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
holdURL,
url.QueryEscape(holdDID),
url.QueryEscape(blobCID),
)
blobReq, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil)
if err != nil {
return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to build blob request"}
}
blobResp, err := http.DefaultClient.Do(blobReq)
if err != nil {
return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to fetch vulnerability report"}
}
defer blobResp.Body.Close()
if blobResp.StatusCode != http.StatusOK {
return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Vulnerability report not accessible"}
}
var report grypeReport
if err := json.NewDecoder(blobResp.Body).Decode(&report); err != nil {
return vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to parse vulnerability report"}
}
matches := make([]vulnMatch, 0, len(report.Matches))
for _, m := range report.Matches {
fixedIn := ""
if len(m.Vulnerability.Fix.Versions) > 0 {
fixedIn = strings.Join(m.Vulnerability.Fix.Versions, ", ")
}
cveURL := ""
if strings.HasPrefix(m.Vulnerability.ID, "CVE-") {
cveURL = "https://nvd.nist.gov/vuln/detail/" + m.Vulnerability.ID
} else if strings.HasPrefix(m.Vulnerability.ID, "GHSA-") {
cveURL = "https://github.com/advisories/" + m.Vulnerability.ID
}
matches = append(matches, vulnMatch{
CVEID: m.Vulnerability.ID,
CVEURL: cveURL,
Severity: m.Vulnerability.Metadata.Severity,
Package: m.Package.Name,
Version: m.Package.Version,
FixedIn: fixedIn,
Type: m.Package.Type,
})
}
sort.Slice(matches, func(i, j int) bool {
oi := severityOrder[matches[i].Severity]
oj := severityOrder[matches[j].Severity]
if oi != oj {
return oi < oj
}
return matches[i].CVEID < matches[j].CVEID
})
return vulnDetailsData{
Matches: matches,
Summary: summary,
ScannedAt: scanRecord.ScannedAt,
}
}
+3 -3
View File
@@ -222,9 +222,9 @@ func TestVulnDetails_FullReport(t *testing.T) {
t.Error("Expected body to contain fix version '1.2.4'")
}
// Should contain "No fix" for unfixed vuln
if !strings.Contains(body, "No fix") {
t.Error("Expected body to contain 'No fix' for unfixed vulnerability")
// Should contain "-" placeholder for unfixed vuln
if !strings.Contains(body, `opacity-40`) {
t.Error("Expected body to contain opacity-40 placeholder for unfixed vulnerability")
}
// Should contain a table
+109 -32
View File
@@ -151,9 +151,10 @@ func (b *BackfillWorker) backfillCollection(ctx context.Context, collection stri
strings.Contains(errStr, "Could not find repo") ||
strings.Contains(errStr, "status 400") ||
strings.Contains(errStr, "status 404") {
if delErr := db.DeleteUserData(b.db, repo.DID); delErr != nil {
deleted, delErr := db.DeleteUserData(b.db, repo.DID)
if delErr != nil {
slog.Warn("Backfill failed to delete data for removed repo", "did", repo.DID, "error", delErr)
} else {
} else if deleted {
slog.Info("Backfill cleaned up data for deleted/deactivated repo", "did", repo.DID)
}
} else {
@@ -183,8 +184,9 @@ func (b *BackfillWorker) backfillCollection(ctx context.Context, collection stri
}
// backfillRepo backfills all records for a single repo/DID.
// Per-record processing is wrapped in a single SQL transaction to batch writes
// (one commit per repo instead of per-statement).
// Records are fetched from PDS first, then network-dependent caches are warmed,
// and finally DB writes happen in chunked transactions to batch writes while
// staying under the remote SQLite transaction timeout (~5s on Bunny Database).
func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection string) (int, error) {
// Resolve DID to get user's PDS endpoint
pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, did)
@@ -193,37 +195,24 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
}
// Create a client for this user's PDS with the user's DID
// This allows GetRecord to work properly with the repo parameter
pdsClient := atproto.NewClient(pdsEndpoint, did, "")
// Begin transaction for per-record processing (batches all writes into one commit)
tx, err := b.db.Begin()
if err != nil {
return 0, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Create a transactional processor — all DB writes go through this tx
txProcessor := NewProcessor(tx, false, b.processor.statsCache)
var recordCursor string
recordCount := 0
// Track which records exist on the PDS for reconciliation
var foundManifestDigests []string
var foundTags []struct{ Repository, Tag string }
foundStars := make(map[string]time.Time) // key: "ownerDID/repository", value: createdAt
// Paginate through all records for this repo
// Phase 1: Collect all records from PDS (network I/O, no transaction)
var allRecords []atproto.Record
for {
records, cursor, err := pdsClient.ListRecordsForRepo(ctx, did, collection, 100, recordCursor)
if err != nil {
return recordCount, fmt.Errorf("failed to list records: %w", err)
return 0, fmt.Errorf("failed to list records: %w", err)
}
// Process each record
for _, record := range records {
// Track what we found for deletion reconciliation
switch collection {
case atproto.ManifestCollection:
var manifestRecord atproto.ManifestRecord
@@ -248,24 +237,65 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
}
}
if err := b.processRecordWith(ctx, txProcessor, did, collection, &record); err != nil {
slog.Warn("Backfill failed to process record", "uri", record.URI, "error", err)
allRecords = append(allRecords, record)
}
if cursor == "" {
break
}
recordCursor = cursor
}
// Phase 2: Pre-warm caches outside any transaction so that ProcessRecord
// inside transactions hits only DB (no network I/O that could cause timeouts).
// Ensure user exists in DB (resolves DID → handle/PDS, fetches profile)
switch collection {
case atproto.SailorProfileCollection:
if err := b.processor.EnsureUser(ctx, did); err != nil {
slog.Warn("Backfill failed to pre-ensure user", "did", did, "error", err)
}
case atproto.ManifestCollection, atproto.TagCollection, atproto.StarCollection, atproto.RepoPageCollection:
if err := b.processor.EnsureUserExists(ctx, did); err != nil {
slog.Warn("Backfill failed to pre-ensure user", "did", did, "error", err)
}
}
// Pre-cache hold DIDs and captain records referenced in records.
// ProcessSailorProfile calls ResolveHoldDID + queryCaptainFn,
// ProcessManifest calls ResolveHoldDID for legacy manifests.
b.prewarmHoldCaches(ctx, collection, allRecords)
// Phase 3: Process records in chunked transactions.
// All network I/O should be cached by now, so transactions stay fast.
const chunkSize = 20
recordCount := 0
for i := 0; i < len(allRecords); i += chunkSize {
end := i + chunkSize
if end > len(allRecords) {
end = len(allRecords)
}
tx, err := b.db.Begin()
if err != nil {
return recordCount, fmt.Errorf("failed to begin transaction: %w", err)
}
txProcessor := NewProcessor(tx, false, b.processor.statsCache)
for j := i; j < end; j++ {
if err := b.processRecordWith(ctx, txProcessor, did, collection, &allRecords[j]); err != nil {
slog.Warn("Backfill failed to process record", "uri", allRecords[j].URI, "error", err)
continue
}
recordCount++
}
// Check if there are more pages
if cursor == "" {
break
if err := tx.Commit(); err != nil {
tx.Rollback()
return recordCount, fmt.Errorf("failed to commit transaction: %w", err)
}
recordCursor = cursor
}
// Commit all per-record writes in one batch
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("failed to commit transaction: %w", err)
}
// Reconciliation runs outside the transaction (involves network I/O and fewer writes)
@@ -354,6 +384,53 @@ func (b *BackfillWorker) reconcileDeletions(did, collection string, foundManifes
return nil
}
// prewarmHoldCaches resolves hold DIDs and caches captain records before
// records are processed inside transactions. This ensures ProcessRecord's
// network-dependent code paths (ResolveHoldDID, queryCaptainRecord) hit
// cached data so transactions stay fast and don't timeout.
func (b *BackfillWorker) prewarmHoldCaches(ctx context.Context, collection string, records []atproto.Record) {
seen := make(map[string]bool)
for _, record := range records {
var holdRef string
switch collection {
case atproto.SailorProfileCollection:
var profileRecord atproto.SailorProfileRecord
if err := json.Unmarshal(record.Value, &profileRecord); err == nil {
holdRef = profileRecord.DefaultHold
}
case atproto.ManifestCollection:
var manifestRecord atproto.ManifestRecord
if err := json.Unmarshal(record.Value, &manifestRecord); err == nil {
// Only legacy manifests need network resolution (URL → DID)
if manifestRecord.HoldDID == "" && manifestRecord.HoldEndpoint != "" {
holdRef = manifestRecord.HoldEndpoint
}
}
default:
return // No hold references in other collections
}
if holdRef == "" || seen[holdRef] {
continue
}
seen[holdRef] = true
// Resolve hold identifier to DID (caches in resolver)
holdDID, err := atproto.ResolveHoldDID(ctx, holdRef)
if err != nil {
slog.Warn("Backfill failed to pre-resolve hold DID", "hold_ref", holdRef, "error", err)
continue
}
// Pre-cache captain record (skips if cached within last hour)
if err := b.queryCaptainRecord(ctx, holdDID); err != nil {
slog.Warn("Backfill failed to pre-cache captain record", "hold_did", holdDID, "error", err)
}
}
}
// processRecordWith processes a single record using the given processor.
// This allows backfillRepo to use a transactional processor while other callers use the default.
func (b *BackfillWorker) processRecordWith(ctx context.Context, proc *Processor, did, collection string, record *atproto.Record) error {
+1 -1
View File
@@ -925,7 +925,7 @@ func (p *Processor) ProcessAccount(ctx context.Context, did string, active bool,
switch status {
case "deleted":
// Account permanently deleted - remove all cached data
if err := db.DeleteUserData(p.db, did); err != nil {
if _, err := db.DeleteUserData(p.db, did); err != nil {
slog.Error("Failed to delete user data for deleted account",
"component", "processor",
"did", did,
+2 -2
View File
@@ -6,7 +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>
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
<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>
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<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>
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
@@ -26,14 +25,15 @@
<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>
<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>
<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>
<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>
<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>
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
<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>
<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>
<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>
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<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>
<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>
<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>
<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>
<symbol id="search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></symbol>
<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>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+8
View File
@@ -151,6 +151,14 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
&uihandlers.RepositoryPageHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/api/repo-tags/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.RepositoryTagsHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/d/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.DigestDetailHandler{BaseUIHandler: base},
).ServeHTTP)
// Authenticated routes
router.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database))
@@ -68,7 +68,7 @@
{{ end }}
</div>
{{ if not .LastUpdated.IsZero }}
<span class="text-base-content/60 text-sm">{{ timeAgo .LastUpdated }}</span>
<span class="text-base-content/60 text-sm flex items-center gap-1">{{ icon "history" "size-4" }}{{ timeAgoShort .LastUpdated }}</span>
{{ end }}
</div>
</div>
+106 -216
View File
@@ -74,17 +74,15 @@
<div class="space-y-2">
{{ if eq .ArtifactType "helm-chart" }}
<p class="font-semibold">Pull this chart</p>
{{ if .Tags }}
{{ $firstTag := index .Tags 0 }}
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " $firstTag.Tag.Tag) }}
{{ if .LatestTag }}
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .LatestTag) }}
{{ else }}
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name) }}
{{ end }}
{{ else }}
<p class="font-semibold">Pull this image</p>
{{ if .Tags }}
{{ $firstTag := index .Tags 0 }}
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }}
{{ if .LatestTag }}
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }}
{{ else }}
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
{{ end }}
@@ -92,213 +90,49 @@
</div>
</div>
<!-- README and Tags/Manifests Layout -->
{{ if .ReadmeHTML }}
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-8">
<!-- README Section (Left) -->
<!-- Tab Navigation -->
<div class="border-b border-base-300">
<nav class="flex gap-0" role="tablist">
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors"
data-tab="overview"
role="tab"
onclick="switchRepoTab('overview')">
Overview
</button>
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors"
data-tab="tags"
role="tab"
id="tags-tab-btn"
onclick="switchRepoTab('tags')">
Artifacts
</button>
</nav>
</div>
<!-- Tab Panels -->
<!-- Overview Panel -->
<div id="tab-overview" class="repo-panel">
{{ if .ReadmeHTML }}
<div class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
<h2 class="text-xl font-semibold">Overview</h2>
<div class="prose prose-sm max-w-none">
{{ .ReadmeHTML }}
</div>
</div>
<!-- Tags and Manifests (Right) -->
<div class="space-y-8 min-w-0">
{{ end }}
<!-- Tags Section -->
<div class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Tags</h2>
{{ if .Tags }}
<div class="space-y-4">
{{ range .Tags }}
<div class="bg-base-200 rounded-lg p-4 space-y-3" id="tag-{{ sanitizeID .Tag.Tag }}">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono font-semibold text-lg">{{ .Tag.Tag }}</span>
{{ if eq .ArtifactType "helm-chart" }}
<span class="badge badge-md badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm chart</span>
{{ else if .IsMultiArch }}
<span class="badge badge-md badge-soft badge-accent">Multi-arch</span>
{{ end }}
{{ if .HasAttestations }}
<button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80"
hx-get="/api/attestation-details?digest={{ .Tag.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}"
hx-target="#attestation-modal-body"
hx-swap="innerHTML"
onclick="document.getElementById('attestation-detail-modal').showModal()">
{{ icon "shield-check" "size-3" }} Attestations
</button>
{{ end }}
</div>
<div class="flex items-center gap-2">
<time class="text-sm text-base-content/60" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .Tag.CreatedAt }}
</time>
{{ if $.IsOwner }}
<button class="btn btn-ghost btn-sm text-error"
hx-ext="json-enc"
hx-delete="/api/tags"
hx-vals='{"repo": "{{ $.Repository.Name }}", "tag": "{{ .Tag.Tag }}"}'
hx-confirm="Delete tag {{ .Tag.Tag }}?"
hx-target="#tag-{{ sanitizeID .Tag.Tag }}"
hx-swap="outerHTML"
aria-label="Delete tag {{ .Tag.Tag }}">
{{ icon "trash-2" "size-4" }}
</button>
{{ end }}
</div>
</div>
<div class="text-sm space-y-2">
<div class="flex items-center gap-2">
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Tag.Digest }}')" aria-label="Copy tag digest to clipboard">{{ icon "copy" "size-3" }}</button>
</div>
{{ if .Platforms }}
<div class="space-y-1">
{{ range .Platforms }}
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ if .Digest }}
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Digest }}">{{ .Digest }}</code>
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')" aria-label="Copy platform digest to clipboard">{{ icon "copy" "size-3" }}</button>
{{ if .HoldEndpoint }}
<span id="scan-badge-{{ trimPrefix "sha256:" .Digest }}"></span>
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
{{ else if .HoldEndpoint }}
{{/* Single-arch: scan badge for the tag's own digest */}}
<div><span id="scan-badge-{{ trimPrefix "sha256:" .Tag.Digest }}"></span></div>
{{ end }}
</div>
{{ if eq .ArtifactType "helm-chart" }}
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .Tag.Tag) }}
{{ else }}
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }}
{{ end }}
</div>
{{ end }}
</div>
{{ if $.ScanBatchParams }}
{{ range $.ScanBatchParams }}
<div hx-get="/api/scan-results?{{ . }}"
hx-trigger="load delay:500ms"
hx-swap="none"
style="display:none"></div>
{{ end }}
{{ end }}
{{ else }}
<p class="text-base-content/60">No tags available</p>
<div class="card bg-base-100 shadow-sm p-6">
<p class="text-base-content/60">No description available</p>
</div>
{{ end }}
</div>
<!-- Manifests Section -->
<div class="card bg-base-100 shadow-sm p-6 space-y-4">
<div class="flex flex-wrap justify-between items-center gap-4">
<h2 class="text-xl font-semibold">Manifests</h2>
<div class="flex items-center gap-4">
{{ if $.IsOwner }}
<button class="btn btn-ghost btn-sm text-error"
onclick="document.getElementById('untagged-delete-modal').showModal()"
aria-label="Delete all untagged manifests">
{{ icon "trash-2" "size-4" }} Delete untagged
</button>
{{ end }}
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm" id="show-offline-toggle" onchange="toggleOfflineManifests()">
<span>Show offline images</span>
</label>
<!-- Tags Panel -->
<div id="tab-tags" class="repo-panel hidden">
<div id="tags-content">
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
{{ if .Manifests }}
<div class="space-y-4 manifests-list">
{{ range .Manifests }}
<div class="bg-base-200 rounded-lg p-4 space-y-3" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
{{ if .IsManifestList }}
<span class="flex items-center gap-1 font-medium">{{ icon "package" "size-5" }} Multi-arch</span>
{{ else if eq .ArtifactType "helm-chart" }}
<span class="flex items-center gap-1 font-medium text-helm">{{ icon "helm" "size-5" }} Helm Chart</span>
{{ else }}
<span class="flex items-center gap-1 font-medium">{{ icon "box" "size-5" }} Image</span>
{{ end }}
{{ if .HasAttestations }}
<button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80"
hx-get="/api/attestation-details?digest={{ .Manifest.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}"
hx-target="#attestation-modal-body"
hx-swap="innerHTML"
onclick="document.getElementById('attestation-detail-modal').showModal()">
{{ icon "shield-check" "size-3" }} Attestations
</button>
{{ end }}
{{ if .Pending }}
<span class="badge badge-sm badge-info"
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
hx-trigger="load delay:2s"
hx-swap="outerHTML">
{{ icon "refresh-ccw" "size-3" }} Checking...
</span>
{{ else if not .Reachable }}
<span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span>
{{ end }}
</div>
<div class="flex items-center gap-2">
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy manifest digest to clipboard">{{ icon "copy" "size-3" }}</button>
</div>
</div>
<div class="flex items-center gap-2">
<time class="text-sm text-base-content/60" datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .Manifest.CreatedAt }}
</time>
{{ if $.IsOwner }}
<button class="btn btn-ghost btn-sm text-error"
onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')"
aria-label="Delete manifest {{ truncateDigest .Manifest.Digest 16 }}">
{{ icon "trash-2" "size-4" }}
</button>
{{ end }}
</div>
</div>
<div class="text-sm">
<div class="flex flex-wrap justify-between items-center gap-2">
<div>
{{ if .Tags }}
<span class="text-base-content/60">Tags:</span>
{{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }}
{{ else }}
<span class="text-base-content/50">(untagged)</span>
{{ end }}
</div>
{{ if .IsManifestList }}
{{ if .Platforms }}
<div class="flex flex-wrap gap-1">
{{ range .Platforms }}
<span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ end }}
</div>
{{ end }}
{{ end }}
</div>
</div>
</div>
{{ end }}
</div>
{{ else }}
<p class="text-base-content/60">No manifests available</p>
{{ end }}
</div>
{{ if .ReadmeHTML }}
</div><!-- Close sidebar -->
</div><!-- Close grid layout -->
{{ end }}
</div>
</main>
@@ -326,7 +160,7 @@
<dialog id="untagged-delete-modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Delete Untagged Manifests</h3>
<p class="py-2">This will delete all untagged manifests in this repository.</p>
<p class="py-2">This will delete <strong>all</strong> untagged manifests in this repository, including those not currently visible.</p>
<p class="font-bold py-2 text-error">This action cannot be undone.</p>
<div class="modal-action">
<form method="dialog"><button class="btn">Cancel</button></form>
@@ -339,20 +173,6 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Vulnerability Details Modal -->
<dialog id="vuln-detail-modal" class="modal">
<div class="modal-box max-w-6xl">
<h3 class="text-lg font-bold">Vulnerability Scan Results</h3>
<div id="vuln-modal-body" class="py-4">
<span class="loading loading-spinner loading-md"></span>
</div>
<div class="modal-action">
<form method="dialog"><button class="btn">Close</button></form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Attestation Details Modal -->
<dialog id="attestation-detail-modal" class="modal">
<div class="modal-box max-w-2xl">
@@ -367,6 +187,76 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<script>
(function() {
var validTabs = ['overview', 'tags'];
var tagsLoading = false;
function loadTags() {
if (tagsLoading) return;
tagsLoading = true;
var target = document.getElementById('tags-content');
fetch('/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}')
.then(function(r) { return r.text(); })
.then(function(html) {
target.innerHTML = html;
htmx.process(target);
});
}
window.switchRepoTab = function(tabId) {
document.querySelectorAll('.repo-panel').forEach(function(p) {
p.classList.add('hidden');
});
var panel = document.getElementById('tab-' + tabId);
if (panel) panel.classList.remove('hidden');
document.querySelectorAll('.repo-tab').forEach(function(tab) {
if (tab.dataset.tab === tabId) {
tab.classList.add('border-primary', 'text-primary');
tab.classList.remove('border-transparent', 'text-base-content/60', 'hover:text-base-content');
} else {
tab.classList.remove('border-primary', 'text-primary');
tab.classList.add('border-transparent', 'text-base-content/60', 'hover:text-base-content');
}
});
history.replaceState(null, '', '#' + tabId);
if (tabId === 'tags') loadTags();
};
window.sortTags = function(method) {
var container = document.getElementById('tags-list');
if (!container) return;
var entries = Array.from(container.querySelectorAll('.artifact-entry'));
entries.sort(function(a, b) {
switch (method) {
case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created);
case 'az': return a.dataset.tag.localeCompare(b.dataset.tag);
case 'za': return b.dataset.tag.localeCompare(a.dataset.tag);
default: return parseInt(b.dataset.created) - parseInt(a.dataset.created);
}
});
entries.forEach(function(el) { container.appendChild(el); });
};
window.filterTags = function(query) {
var q = query.toLowerCase();
document.querySelectorAll('#tags-list .artifact-entry').forEach(function(el) {
el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none';
});
};
// Prefetch on hover
document.getElementById('tags-tab-btn').addEventListener('mouseenter', loadTags, { once: true });
// Initialize tab from hash
var hash = window.location.hash.replace('#', '') || 'overview';
if (validTabs.indexOf(hash) === -1) hash = 'overview';
switchRepoTab(hash);
})();
</script>
{{ template "footer" . }}
</body>
</html>
@@ -6,13 +6,11 @@
{{ else if eq .Total 0 }}
<span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span>
{{ else }}
<button class="vuln-strip cursor-pointer hover:opacity-80 transition-opacity"
onclick="openVulnDetails('{{ .Digest }}', '{{ .HoldEndpoint }}')"
title="Click for vulnerability details (scanned {{ .ScannedAt }})">
<span class="vuln-strip" title="Vulnerabilities: {{ .Critical }} critical, {{ .High }} high, {{ .Medium }} medium, {{ .Low }} low (scanned {{ .ScannedAt }})">
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Critical }}</span>
<span class="tooltip vuln-box-high" data-tip="High">{{ .High }}</span>
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Medium }}</span>
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Low }}</span>
</button>
</span>
{{ end }}
{{ end }}
@@ -9,17 +9,17 @@
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span>
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span>
</span>
<p class="text-base-content/60 text-sm">{{ .Error }}</p>
{{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }}
<p class="text-sm">{{ .Error }}</p>
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}
</div>
{{ else }}
<p class="text-base-content/60">{{ .Error }}</p>
<p>{{ .Error }}</p>
{{ end }}
{{ else }}
<div class="space-y-4">
<!-- Summary badges -->
<div class="flex flex-wrap items-center gap-3">
<span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities found</span>
<span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities</span>
<span class="vuln-strip">
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span>
<span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span>
@@ -28,19 +28,19 @@
</span>
</div>
{{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }}
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}
{{ if .Matches }}
<!-- CVE table -->
<div class="overflow-x-auto max-h-96">
<table class="table table-sm table-pin-rows">
<div class="overflow-y-auto max-h-[32rem]">
<table class="table table-xs table-pin-rows w-full">
<thead>
<tr>
<th>CVE</th>
<th>Severity</th>
<th></th>
<th>Package</th>
<th>Installed</th>
<th>Fixed In</th>
<th>Version</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
@@ -55,27 +55,26 @@
</td>
<td>
{{ if eq .Severity "Critical" }}
<span class="badge badge-sm badge-error">Critical</span>
<span class="badge badge-xs badge-error" title="Critical">C</span>
{{ else if eq .Severity "High" }}
<span class="badge badge-sm badge-warning">High</span>
<span class="badge badge-xs badge-warning" title="High">H</span>
{{ else if eq .Severity "Medium" }}
<span class="badge badge-sm badge-soft badge-warning">Medium</span>
<span class="badge badge-xs badge-soft badge-warning" title="Medium">M</span>
{{ else if eq .Severity "Low" }}
<span class="badge badge-sm badge-info">Low</span>
<span class="badge badge-xs badge-info" title="Low">L</span>
{{ else }}
<span class="badge badge-sm badge-ghost">{{ .Severity }}</span>
<span class="badge badge-xs badge-ghost" title="{{ .Severity }}">?</span>
{{ end }}
</td>
<td>
<span class="font-mono text-xs">{{ .Package }}</span>
{{ if .Type }}<span class="text-base-content/40 text-xs">({{ .Type }})</span>{{ end }}
<td class="text-xs">
{{ .Package }}{{ if .Type }} <span class="opacity-60">({{ .Type }})</span>{{ end }}
</td>
<td class="font-mono text-xs break-all">{{ .Version }}</td>
<td class="font-mono text-xs break-all">
<td class="font-mono text-xs">{{ .Version }}</td>
<td class="font-mono text-xs">
{{ if .FixedIn }}
<span class="text-success">{{ .FixedIn }}</span>
{{ else }}
<span class="text-base-content/40">No fix</span>
<span class="opacity-40">-</span>
{{ end }}
</td>
</tr>
+16
View File
@@ -157,6 +157,22 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
}
},
"timeAgoShort": func(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "now"
} else if duration < time.Hour {
return fmt.Sprintf("%dm", int(duration.Minutes()))
} else if duration < 24*time.Hour {
return fmt.Sprintf("%dh", int(duration.Hours()))
} else if duration < 365*24*time.Hour {
return fmt.Sprintf("%dd", int(duration.Hours()/24))
} else {
return fmt.Sprintf("%dy", int(duration.Hours()/(24*365)))
}
},
"humanizeBytes": func(bytes int64) string {
const unit = 1024
if bytes < unit {
+202
View File
@@ -2425,3 +2425,205 @@ func (t *ScanRecord) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
func (t *ImageConfigRecord) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
}
cw := cbg.NewCborWriter(w)
if _, err := cw.Write([]byte{164}); err != nil {
return err
}
// t.Type (string) (string)
if len("$type") > 8192 {
return xerrors.Errorf("Value in field \"$type\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
return err
}
if _, err := cw.WriteString(string("$type")); err != nil {
return err
}
if len(t.Type) > 8192 {
return xerrors.Errorf("Value in field t.Type was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Type)); err != nil {
return err
}
// t.Manifest (string) (string)
if len("manifest") > 8192 {
return xerrors.Errorf("Value in field \"manifest\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifest"))); err != nil {
return err
}
if _, err := cw.WriteString(string("manifest")); err != nil {
return err
}
if len(t.Manifest) > 8192 {
return xerrors.Errorf("Value in field t.Manifest was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Manifest))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Manifest)); err != nil {
return err
}
// t.CreatedAt (string) (string)
if len("createdAt") > 8192 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
return err
}
if _, err := cw.WriteString(string("createdAt")); err != nil {
return err
}
if len(t.CreatedAt) > 8192 {
return xerrors.Errorf("Value in field t.CreatedAt was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
}
// t.ConfigJSON (string) (string)
if len("configJson") > 8192 {
return xerrors.Errorf("Value in field \"configJson\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("configJson"))); err != nil {
return err
}
if _, err := cw.WriteString(string("configJson")); err != nil {
return err
}
if len(t.ConfigJSON) > 8192 {
return xerrors.Errorf("Value in field t.ConfigJSON was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ConfigJSON))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.ConfigJSON)); err != nil {
return err
}
return nil
}
func (t *ImageConfigRecord) UnmarshalCBOR(r io.Reader) (err error) {
*t = ImageConfigRecord{}
cr := cbg.NewCborReader(r)
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
defer func() {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
}()
if maj != cbg.MajMap {
return fmt.Errorf("cbor input should be of type map")
}
if extra > cbg.MaxLength {
return fmt.Errorf("ImageConfigRecord: map struct too large (%d)", extra)
}
n := extra
nameBuf := make([]byte, 10)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
if err != nil {
return err
}
if !ok {
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
return err
}
continue
}
switch string(nameBuf[:nameLen]) {
// t.Type (string) (string)
case "$type":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Type = string(sval)
}
// t.Manifest (string) (string)
case "manifest":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Manifest = string(sval)
}
// t.CreatedAt (string) (string)
case "createdAt":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.CreatedAt = string(sval)
}
// t.ConfigJSON (string) (string)
case "configJson":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.ConfigJSON = string(sval)
}
default:
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
return err
}
}
}
return nil
}
+12
View File
@@ -46,6 +46,18 @@ const (
// Response: {"success": true, "layersCreated": 5, "postCreated": true, "postUri": "at://..."}
HoldNotifyManifest = "/xrpc/io.atcr.hold.notifyManifest"
// HoldGetLayersForManifest returns layer records for a specific manifest.
// Method: GET
// Query: manifest={at-uri}
// Response: {"layers": [{...}]}
HoldGetLayersForManifest = "/xrpc/io.atcr.hold.getLayersForManifest"
// HoldGetImageConfig returns the OCI image config record for a manifest.
// Method: GET
// Query: digest={manifest-digest}
// Response: ImageConfigRecord JSON
HoldGetImageConfig = "/xrpc/io.atcr.hold.image.getConfig"
// HoldGetQuota returns storage quota information for a user.
// Method: GET
// Query: userDid={did}
+1
View File
@@ -33,6 +33,7 @@ func main() {
atproto.TangledProfileRecord{},
atproto.StatsRecord{},
atproto.ScanRecord{},
atproto.ImageConfigRecord{},
); err != nil {
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
os.Exit(1)
+25
View File
@@ -48,6 +48,10 @@ const (
// Stored in hold's embedded PDS to track scan results per manifest
ScanCollection = "io.atcr.hold.scan"
// ImageConfigCollection is the collection name for OCI image configs
// Stored in hold's embedded PDS, one per manifest (keyed by manifest digest hex)
ImageConfigCollection = "io.atcr.hold.image.config"
// TangledProfileCollection is the collection name for tangled profiles
// Stored in hold's embedded PDS (singleton record at rkey "self")
TangledProfileCollection = "sh.tangled.actor.profile"
@@ -820,6 +824,27 @@ func ScanRecordKey(manifestDigest string) string {
return strings.TrimPrefix(manifestDigest, "sha256:")
}
// ImageConfigRecord represents an OCI image config stored in the hold's embedded PDS
// Collection: io.atcr.hold.image.config
// One record per manifest, keyed by manifest digest hex (same pattern as ScanRecord)
// Stores the full OCI config JSON so the appview can display layer history including empty layers
type ImageConfigRecord struct {
Type string `json:"$type" cborgen:"$type"`
Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the manifest
ConfigJSON string `json:"configJson" cborgen:"configJson"` // Raw OCI image config JSON
CreatedAt string `json:"createdAt" cborgen:"createdAt"` // RFC3339 timestamp
}
// NewImageConfigRecord creates a new image config record
func NewImageConfigRecord(manifestURI, configJSON string) *ImageConfigRecord {
return &ImageConfigRecord{
Type: ImageConfigCollection,
Manifest: manifestURI,
ConfigJSON: configJSON,
CreatedAt: time.Now().Format(time.RFC3339),
}
}
// TangledProfileRecord represents a Tangled profile for the hold
// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
// Stored in the hold's embedded PDS
+1
View File
@@ -417,6 +417,7 @@ func (ui *AdminUI) RegisterRoutes(r chi.Router) {
r.Post("/admin/api/gc/reconcile", ui.handleGCReconcile)
r.Post("/admin/api/gc/delete-records", ui.handleGCDeleteRecords)
r.Post("/admin/api/gc/delete-blobs", ui.handleGCDeleteBlobs)
r.Post("/admin/api/gc/backfill-configs", ui.handleGCBackfillConfigs)
r.Get("/admin/api/gc/status", ui.handleGCStatus)
// API endpoints (for HTMX)
+21
View File
@@ -136,6 +136,27 @@ func (ui *AdminUI) handleGCDeleteRecords(w http.ResponseWriter, r *http.Request)
})
}
// handleGCBackfillConfigs starts image config backfill in the background
func (ui *AdminUI) handleGCBackfillConfigs(w http.ResponseWriter, r *http.Request) {
if ui.gc == nil {
ui.renderTemplate(w, "partials/gc_error.html", struct{ Error string }{"GC not available"})
return
}
session := getSessionFromContext(r.Context())
if ui.gc.StartBackfillConfigs() {
slog.Info("GC backfill configs started via admin panel", "by", session.DID)
}
progress := ui.gc.GetProgress()
ui.renderTemplate(w, "partials/gc_progress.html", gcProgressData{
Phase: progress.Phase,
Message: progress.Message,
OpType: progress.OperationType,
})
}
// handleGCDeleteBlobs starts orphaned blob deletion in the background
func (ui *AdminUI) handleGCDeleteBlobs(w http.ResponseWriter, r *http.Request) {
if ui.gc == nil {
+2 -2
View File
@@ -6,7 +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>
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
<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>
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<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>
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
@@ -26,14 +25,15 @@
<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>
<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>
<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>
<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>
<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>
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
<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>
<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>
<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>
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<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>
<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>
<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>
<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>
<symbol id="search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></symbol>
<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>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -41,6 +41,15 @@
{{ icon "search" "size-4" }}
Scan for Orphans
</button>
<button class="btn btn-outline gap-2"
hx-post="/admin/api/gc/backfill-configs"
hx-target="#gc-results"
hx-swap="innerHTML"
hx-confirm="Backfill image config records from OCI config blobs in S3?"
{{if .Running}}disabled{{end}}>
{{ icon "refresh-cw" "size-4" }}
Backfill Image Configs
</button>
</div>
<div id="gc-results">
+152
View File
@@ -815,6 +815,158 @@ func (gc *GarbageCollector) reconcileMissingRecords(ctx context.Context, missing
}
}
// StartBackfillConfigs launches image config backfill in the background.
// Creates io.atcr.hold.image.config records for manifests that don't have one yet
// by fetching OCI config blobs from S3.
func (gc *GarbageCollector) StartBackfillConfigs() bool {
return gc.startBackground("backfill-configs", "records", "Scanning for manifests missing image config records...", func(ctx context.Context) error {
_, err := gc.doBackfillConfigs(ctx)
return err
})
}
// doBackfillConfigs creates image config records for manifests that are missing them.
func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, error) {
recordsIndex := gc.pds.RecordsIndex()
if recordsIndex == nil {
return nil, fmt.Errorf("records index not available")
}
// Step 1: Collect unique manifest URIs from layer records
manifestURIs := make(map[string]bool)
cursor := ""
totalScanned := 0
for {
records, nextCursor, err := recordsIndex.ListRecords(atproto.LayerCollection, 1000, cursor, true)
if err != nil {
return nil, fmt.Errorf("list layer records: %w", err)
}
for _, rec := range records {
totalScanned++
layer, err := gc.decodeLayerRecord(ctx, rec)
if err != nil {
continue
}
manifestURIs[layer.Manifest] = true
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
gc.logger.Info("Found unique manifests from layer records",
"manifests", len(manifestURIs),
"layersScanned", totalScanned)
// Step 2: For each manifest, check if config record exists, create if not
start := time.Now()
result := &GCResult{}
created := int64(0)
skipped := int64(0)
processed := 0
httpClient := &http.Client{Timeout: 30 * time.Second}
for manifestURI := range manifestURIs {
processed++
gc.setProgress("records",
fmt.Sprintf("Backfilling configs (%d/%d manifests)...", processed, len(manifestURIs)),
"backfill-configs")
aturi, err := syntax.ParseATURI(manifestURI)
if err != nil {
gc.logger.Warn("Invalid manifest URI", "uri", manifestURI, "error", err)
continue
}
manifestDigest := "sha256:" + aturi.RecordKey().String()
// Check if config record already exists
if _, _, err := gc.pds.GetImageConfigRecord(ctx, manifestDigest); err == nil {
skipped++
continue
}
userDID := aturi.Authority().String()
manifestRkey := aturi.RecordKey().String()
pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, userDID)
if err != nil {
gc.logger.Warn("Failed to resolve PDS for backfill", "did", userDID, "error", err)
continue
}
// Fetch manifest via getRecord to get config digest
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
pdsEndpoint,
url.QueryEscape(userDID),
url.QueryEscape(atproto.ManifestCollection),
url.QueryEscape(manifestRkey))
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
continue
}
resp, err := httpClient.Do(req)
if err != nil {
gc.logger.Warn("Failed to fetch manifest for backfill", "uri", manifestURI, "error", err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
continue
}
var envelope struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
var manifest atproto.ManifestRecord
if err := json.Unmarshal(envelope.Value, &manifest); err != nil {
continue
}
if manifest.Config == nil || manifest.Config.Digest == "" {
continue
}
// Fetch config blob from S3
configBytes, err := gc.s3.GetBytes(ctx, s3.BlobPath(manifest.Config.Digest))
if err != nil {
gc.logger.Warn("Failed to fetch config blob", "digest", manifest.Config.Digest, "error", err)
continue
}
// Create image config record
configRecord := atproto.NewImageConfigRecord(manifestURI, string(configBytes))
if _, _, err := gc.pds.CreateImageConfigRecord(ctx, configRecord, manifestDigest); err != nil {
gc.logger.Warn("Failed to create image config record", "manifest", manifestURI, "error", err)
continue
}
created++
time.Sleep(200 * time.Millisecond) // throttle firehose events
}
result.RecordsReconciled = created
result.Duration = time.Since(start)
gc.mu.Lock()
gc.lastResult = result
gc.lastResultAt = time.Now()
gc.mu.Unlock()
gc.logger.Info("Image config backfill complete", "created", created, "skipped", skipped)
return result, nil
}
// discoverUserDIDs returns all DIDs that may have manifests referencing this hold.
// Union of: captain owner + crew members + distinct DIDs from layer records.
func (gc *GarbageCollector) discoverUserDIDs(ctx context.Context) ([]string, error) {
+34 -13
View File
@@ -297,22 +297,43 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
// Build manifest AT-URI for layer records
manifestURI := atproto.BuildManifestURI(req.UserDID, req.ManifestDigest)
// Create layer records for each blob
for _, layer := range req.Manifest.Layers {
record := atproto.NewLayerRecord(
layer.Digest,
layer.Size,
layer.MediaType,
req.UserDID,
manifestURI,
)
// Skip layer record creation if records already exist for this manifest
existingLayers, _ := h.pds.ListLayerRecordsForManifest(ctx, manifestURI)
if len(existingLayers) > 0 {
layersCreated = len(existingLayers)
slog.Debug("Layer records already exist for manifest, skipping creation",
"manifestURI", manifestURI, "existing", len(existingLayers))
} else {
// Create layer records for each blob
for _, layer := range req.Manifest.Layers {
record := atproto.NewLayerRecord(
layer.Digest,
layer.Size,
layer.MediaType,
req.UserDID,
manifestURI,
)
_, _, err := h.pds.CreateLayerRecord(ctx, record)
_, _, err := h.pds.CreateLayerRecord(ctx, record)
if err != nil {
slog.Error("Failed to create layer record", "error", err)
// Continue creating other records
} else {
layersCreated++
}
}
}
// Store OCI image config as a separate record (best-effort)
if req.Manifest.Config.Digest != "" {
configBytes, err := h.s3Service.GetBytes(ctx, s3.BlobPath(req.Manifest.Config.Digest))
if err != nil {
slog.Error("Failed to create layer record", "error", err)
// Continue creating other records
slog.Warn("Failed to fetch config blob for image config record", "error", err, "configDigest", req.Manifest.Config.Digest)
} else {
layersCreated++
configRecord := atproto.NewImageConfigRecord(manifestURI, string(configBytes))
if _, _, err := h.pds.CreateImageConfigRecord(ctx, configRecord, req.ManifestDigest); err != nil {
slog.Warn("Failed to create image config record", "error", err)
}
}
}
+1 -1
View File
@@ -678,7 +678,7 @@ func TestHandleCompleteUpload_MockS3_Success(t *testing.T) {
// 6. Verify blob was moved to final location in mock S3
finalS3Key := "test-prefix/docker/registry/v2/blobs/sha256/ab/abc123def456/data"
if mockS3Client.GetObject(finalS3Key) == nil {
if mockS3Client.GetObjectBytes(finalS3Key) == nil {
t.Errorf("Expected blob at final S3 key %s", finalS3Key)
}
}
+84
View File
@@ -90,6 +90,15 @@ func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.Lay
return nil, fmt.Errorf("GetLayerRecord not yet implemented - use via XRPC listRecords instead")
}
// UpdateLayerRecord updates an existing layer record by rkey.
func (p *HoldPDS) UpdateLayerRecord(ctx context.Context, rkey string, record *atproto.LayerRecord) error {
_, err := p.repomgr.UpdateRecord(ctx, p.uid, atproto.LayerCollection, rkey, record)
if err != nil {
return fmt.Errorf("failed to update layer record: %w", err)
}
return nil
}
// DeleteLayerRecord deletes a layer record by rkey
// This deletes from both the repo (MST) and the records index
func (p *HoldPDS) DeleteLayerRecord(ctx context.Context, rkey string) error {
@@ -294,3 +303,78 @@ func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) (
return records, nil
}
// ListLayerRecordsForManifest returns all layer records for a specific manifest AT-URI.
func (p *HoldPDS) ListLayerRecordsForManifest(ctx context.Context, manifestURI string) ([]*atproto.LayerRecord, error) {
if p.recordsIndex == nil {
return nil, fmt.Errorf("records index not available")
}
session, err := p.carstore.ReadOnlySession(p.uid)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
if err != nil {
return nil, fmt.Errorf("failed to get repo head: %w", err)
}
if !head.Defined() {
return []*atproto.LayerRecord{}, nil
}
repoHandle, err := repo.OpenRepo(ctx, session, head)
if err != nil {
return nil, fmt.Errorf("failed to open repo: %w", err)
}
var records []*atproto.LayerRecord
seen := make(map[string]int) // digest → index in records slice
cursor := ""
batchSize := 1000
for {
indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, false)
if err != nil {
return nil, fmt.Errorf("failed to list layer records: %w", err)
}
for _, rec := range indexRecords {
recordPath := rec.Collection + "/" + rec.Rkey
_, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
if err != nil {
continue
}
recordValue, err := lexutil.CborDecodeValue(*recBytes)
if err != nil {
continue
}
layerRecord, ok := recordValue.(*atproto.LayerRecord)
if !ok {
continue
}
if layerRecord.Manifest == manifestURI {
if _, exists := seen[layerRecord.Digest]; !exists {
seen[layerRecord.Digest] = len(records)
records = append(records, layerRecord)
}
}
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
if records == nil {
records = []*atproto.LayerRecord{}
}
return records, nil
}
+1
View File
@@ -32,6 +32,7 @@ func init() {
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{})
}
// HoldPDS is a minimal ATProto PDS implementation for a hold service
+45
View File
@@ -168,6 +168,8 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
r.Get(atproto.RepoDescribeRepo, h.HandleDescribeRepo)
r.Get(atproto.RepoGetRecord, h.HandleGetRecord)
r.Get(atproto.RepoListRecords, h.HandleListRecords)
r.Get(atproto.HoldGetLayersForManifest, h.HandleGetLayersForManifest)
r.Get(atproto.HoldGetImageConfig, h.HandleGetImageConfig)
// Sync endpoints
r.Get(atproto.SyncListBlobs, h.HandleListBlobs)
@@ -487,6 +489,49 @@ func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
})
}
// HandleGetLayersForManifest returns layer records for a specific manifest AT-URI.
func (h *XRPCHandler) HandleGetLayersForManifest(w http.ResponseWriter, r *http.Request) {
manifestURI := r.URL.Query().Get("manifest")
if manifestURI == "" {
http.Error(w, `{"error":"InvalidRequest","message":"manifest parameter is required"}`, http.StatusBadRequest)
return
}
records, err := h.pds.ListLayerRecordsForManifest(r.Context(), manifestURI)
if err != nil {
slog.Error("Failed to list layer records for manifest", "error", err, "manifest", manifestURI)
http.Error(w, `{"error":"InternalServerError","message":"failed to list layer records"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"layers": records,
}); err != nil {
slog.Error("Failed to encode layer records response", "error", err)
}
}
// HandleGetImageConfig returns the OCI image config record for a manifest digest.
func (h *XRPCHandler) HandleGetImageConfig(w http.ResponseWriter, r *http.Request) {
digest := r.URL.Query().Get("digest")
if digest == "" {
http.Error(w, `{"error":"InvalidRequest","message":"digest parameter is required"}`, http.StatusBadRequest)
return
}
_, record, err := h.pds.GetImageConfigRecord(r.Context(), digest)
if err != nil {
http.Error(w, `{"error":"NotFound","message":"image config not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(record); err != nil {
slog.Error("Failed to encode image config response", "error", err)
}
}
// HandleListRecords lists records in a collection
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-list-records
// Supports pagination via limit, cursor, and reverse parameters
+105
View File
@@ -1062,6 +1062,111 @@ func TestHandleListRecords_Indexed_EmptyCollection(t *testing.T) {
}
}
// Tests for HandleGetLayersForManifest
func TestHandleGetLayersForManifest(t *testing.T) {
handler, ctx := setupTestXRPCHandlerWithIndex(t)
manifestURI := "at://did:plc:testuser/io.atcr.manifest/abc123"
otherManifestURI := "at://did:plc:testuser/io.atcr.manifest/def456"
// Create layer records for the target manifest
for i := range 3 {
record := atproto.NewLayerRecord(
fmt.Sprintf("sha256:layer%d", i),
int64(1024*(i+1)),
"application/vnd.oci.image.layer.v1.tar+gzip",
"did:plc:testuser",
manifestURI,
)
if _, _, err := handler.pds.CreateLayerRecord(ctx, record); err != nil {
t.Fatalf("Failed to create layer record %d: %v", i, err)
}
}
// Create a layer record for a different manifest (should not be returned)
otherRecord := atproto.NewLayerRecord(
"sha256:otherlayer",
2048,
"application/vnd.oci.image.layer.v1.tar+gzip",
"did:plc:testuser",
otherManifestURI,
)
if _, _, err := handler.pds.CreateLayerRecord(ctx, otherRecord); err != nil {
t.Fatalf("Failed to create other layer record: %v", err)
}
// Query layers for the target manifest
req := makeXRPCGetRequest(atproto.HoldGetLayersForManifest, map[string]string{
"manifest": manifestURI,
})
w := httptest.NewRecorder()
handler.HandleGetLayersForManifest(w, req)
result := assertJSONResponse(t, w, http.StatusOK)
layers, ok := result["layers"].([]any)
if !ok {
t.Fatal("Expected layers array in response")
}
if len(layers) != 3 {
t.Fatalf("Expected 3 layers, got %d", len(layers))
}
// Verify all layers have digests and belong to the target manifest
digests := make(map[string]bool)
for i, l := range layers {
layer, ok := l.(map[string]any)
if !ok {
t.Fatalf("Layer %d: expected map, got %T", i, l)
}
digest, ok := layer["digest"].(string)
if !ok || digest == "" {
t.Errorf("Layer %d: expected non-empty digest", i)
}
digests[digest] = true
}
for i := range 3 {
expected := fmt.Sprintf("sha256:layer%d", i)
if !digests[expected] {
t.Errorf("Expected digest %q in results", expected)
}
}
}
func TestHandleGetLayersForManifest_MissingParam(t *testing.T) {
handler, _ := setupTestXRPCHandlerWithIndex(t)
req := makeXRPCGetRequest(atproto.HoldGetLayersForManifest, map[string]string{})
w := httptest.NewRecorder()
handler.HandleGetLayersForManifest(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
func TestHandleGetLayersForManifest_NoMatchingLayers(t *testing.T) {
handler, _ := setupTestXRPCHandlerWithIndex(t)
req := makeXRPCGetRequest(atproto.HoldGetLayersForManifest, map[string]string{
"manifest": "at://did:plc:nobody/io.atcr.manifest/nonexistent",
})
w := httptest.NewRecorder()
handler.HandleGetLayersForManifest(w, req)
result := assertJSONResponse(t, w, http.StatusOK)
layers, ok := result["layers"].([]any)
if !ok {
t.Fatal("Expected layers array in response")
}
if len(layers) != 0 {
t.Errorf("Expected 0 layers, got %d", len(layers))
}
}
// Tests for HandleDeleteRecord
// TestHandleDeleteRecord tests com.atproto.repo.deleteRecord
+19 -2
View File
@@ -400,8 +400,25 @@ func (m *MockS3Client) SetObject(key string, data []byte) {
m.Objects[key] = append([]byte{}, data...)
}
// GetObject is a test helper to read an object from the mock store (nil if not found).
func (m *MockS3Client) GetObject(key string) []byte {
// GetObject implements S3Client
func (m *MockS3Client) GetObject(ctx context.Context, input *awss3.GetObjectInput, opts ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := aws.ToString(input.Key)
data, ok := m.Objects[key]
if !ok {
return nil, fmt.Errorf("NoSuchKey: %s", key)
}
return &awss3.GetObjectOutput{
Body: io.NopCloser(bytes.NewReader(bytes.Clone(data))),
ContentLength: aws.Int64(int64(len(data))),
}, nil
}
// GetObjectBytes is a test helper to read an object from the mock store (nil if not found).
func (m *MockS3Client) GetObjectBytes(key string) []byte {
m.mu.Lock()
defer m.mu.Unlock()
data, ok := m.Objects[key]
+26
View File
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/url"
"strings"
@@ -27,6 +28,7 @@ type S3Client interface {
AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error)
// Direct object operations
GetObject(ctx context.Context, input *awss3.GetObjectInput, opts ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
HeadObject(ctx context.Context, input *awss3.HeadObjectInput, opts ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error)
PutObject(ctx context.Context, input *awss3.PutObjectInput, opts ...func(*awss3.Options)) (*awss3.PutObjectOutput, error)
CopyObject(ctx context.Context, input *awss3.CopyObjectInput, opts ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error)
@@ -70,6 +72,11 @@ func (r *RealS3Client) AbortMultipartUpload(ctx context.Context, input *awss3.Ab
return r.client.AbortMultipartUpload(ctx, input, opts...)
}
// GetObject implements S3Client
func (r *RealS3Client) GetObject(ctx context.Context, input *awss3.GetObjectInput, opts ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
return r.client.GetObject(ctx, input, opts...)
}
// HeadObject implements S3Client
func (r *RealS3Client) HeadObject(ctx context.Context, input *awss3.HeadObjectInput, opts ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
return r.client.HeadObject(ctx, input, opts...)
@@ -149,6 +156,25 @@ type S3Service struct {
PathPrefix string // S3 path prefix (if any)
}
// GetBytes fetches an object from S3 and returns its contents as bytes.
func (s *S3Service) GetBytes(ctx context.Context, blobPath string) ([]byte, error) {
s3Key := strings.TrimPrefix(blobPath, "/")
if s.PathPrefix != "" {
s3Key = s.PathPrefix + "/" + s3Key
}
result, err := s.Client.GetObject(ctx, &awss3.GetObjectInput{
Bucket: &s.Bucket,
Key: &s3Key,
})
if err != nil {
return nil, fmt.Errorf("get object %s: %w", s3Key, err)
}
defer result.Body.Close()
return io.ReadAll(result.Body)
}
// NewS3Service initializes the S3 client for presigned URL generation
// S3 is required - this will return an error if not properly configured
func NewS3Service(params map[string]any) (*S3Service, error) {