mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
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:
@@ -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) |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user