ui fixes for repo page, fix scanner priority, cleanup goreleaser scripts

This commit is contained in:
Evan Jarrett
2026-04-03 16:48:21 -05:00
parent ca69c0d1c2
commit fd5bfc3c50
51 changed files with 3424 additions and 744 deletions

View File

@@ -63,6 +63,7 @@ publishers:
cmd: ./scripts/publish-artifact.sh
env:
- APP_PASSWORD={{ .Env.APP_PASSWORD }}
- TANGLED_REPO_DID={{ .Env.TANGLED_REPO_DID }}
- REPO_URL={{ .Env.REPO_URL }}
- TAG={{ .Tag }}
- ARTIFACT_PATH={{ abs .ArtifactPath }}

View File

@@ -1,155 +1,37 @@
# Tangled Workflow: Release Credential Helper
#
# This workflow builds cross-platform binaries for the credential helper.
# Creates tarballs for curl/bash installation and provides instructions
# for updating the Homebrew formula.
# Builds cross-platform binaries using GoReleaser and publishes
# artifacts to the repo owner's PDS as sh.tangled.repo.artifact records.
#
# Triggers on version tags (v*) pushed to the repository.
#
# Required secrets: PUBLISH_APP_PASSWORD (ATProto app password for artifact publishing)
when:
- event: ["manual"]
tag: ["v*"]
engine: "nixery"
dependencies:
nixpkgs:
- go_1_24 # Go 1.24+ for building
- goreleaser # For building multi-platform binaries
- curl # Required by go generate for downloading vendor assets
- gnugrep # Required for tag detection
- gnutar # Required for creating tarballs
- gzip # Required for compressing tarballs
- coreutils # Required for sha256sum
engine: kubernetes
image: golang:1.25-trixie
architecture: amd64
environment:
CGO_ENABLED: "0" # Build static binaries
CGO_ENABLED: "0"
REPO_RKEY: "3m2pjukohu322"
steps:
- name: Get tag for current commit
- name: Install tools
command: |
go install github.com/bluesky-social/goat@latest
go install github.com/goreleaser/goreleaser/v2@latest
apt-get update && apt-get install -y jq xxd
- name: Build and publish release
command: |
# Fetch tags (shallow clone doesn't include them by default)
git fetch --tags
# Find the tag that points to the current commit
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1)
# REPO_URL is built from Tangled-provided env vars
export REPO_URL="at://${TANGLED_REPO_DID}/sh.tangled.repo/${REPO_RKEY}"
export APP_PASSWORD="${PUBLISH_APP_PASSWORD}"
if [ -z "$TAG" ]; then
echo "Error: No version tag found for current commit"
echo "Available tags:"
git tag
echo "Current commit:"
git rev-parse HEAD
exit 1
fi
echo "Building version: $TAG"
echo "$TAG" > .version
# Also get the commit hash for reference
COMMIT_HASH=$(git rev-parse HEAD)
echo "Commit: $COMMIT_HASH"
- name: Build binaries with GoReleaser
command: |
VERSION=$(cat .version)
export VERSION
# Build for all platforms using GoReleaser
goreleaser build --clean --snapshot --config .goreleaser.yaml
# List what was built
echo "Built artifacts:"
if [ -d "dist" ]; then
ls -lh dist/
else
echo "Error: dist/ directory was not created by GoReleaser"
exit 1
fi
- name: Package artifacts
command: |
VERSION=$(cat .version)
VERSION_NO_V=${VERSION#v} # Remove 'v' prefix for filenames
cd dist
# Create tarballs for each platform
# GoReleaser creates directories like: credential-helper_{os}_{arch}_v{goversion}
# Darwin x86_64
if [ -d "credential-helper_darwin_amd64_v1" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz" \
-C credential-helper_darwin_amd64_v1 docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz"
fi
# Darwin arm64
for dir in credential-helper_darwin_arm64*; do
if [ -d "$dir" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" \
-C "$dir" docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz"
break
fi
done
# Linux x86_64
if [ -d "credential-helper_linux_amd64_v1" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz" \
-C credential-helper_linux_amd64_v1 docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz"
fi
# Linux arm64
for dir in credential-helper_linux_arm64*; do
if [ -d "$dir" ]; then
tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" \
-C "$dir" docker-credential-atcr
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz"
break
fi
done
echo ""
echo "Tarballs ready:"
ls -lh *.tar.gz 2>/dev/null || echo "Warning: No tarballs created"
- name: Generate checksums
command: |
VERSION=$(cat .version)
VERSION_NO_V=${VERSION#v}
cd dist
echo ""
echo "=========================================="
echo "SHA256 Checksums"
echo "=========================================="
echo ""
# Generate checksums file
sha256sum docker-credential-atcr_${VERSION_NO_V}_*.tar.gz 2>/dev/null | tee checksums.txt || echo "No checksums generated"
- name: Next steps
command: |
VERSION=$(cat .version)
echo ""
echo "=========================================="
echo "Release $VERSION is ready!"
echo "=========================================="
echo ""
echo "Distribution tarballs are in: dist/"
echo ""
echo "Next steps:"
echo ""
echo "1. Upload tarballs to your hosting/CDN (or GitHub releases)"
echo ""
echo "2. For Homebrew users, update the formula:"
echo " ./scripts/update-homebrew-formula.sh $VERSION"
echo " # Then update Formula/docker-credential-atcr.rb and push to homebrew-tap"
echo ""
echo "3. For curl/bash installation, users can download directly:"
echo " curl -L <your-cdn>/docker-credential-atcr_<version>_<os>_<arch>.tar.gz | tar xz"
echo " sudo mv docker-credential-atcr /usr/local/bin/"
goreleaser release --clean

View File

@@ -91,6 +91,10 @@ legal:
company_name: ""
# Governing law jurisdiction for legal terms.
jurisdiction: ""
# AI-powered image advisor settings.
ai:
# Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback.
api_key: ""
# Stripe billing integration (requires -tags billing build).
billing:
# Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set.
@@ -100,9 +104,9 @@ billing:
# ISO 4217 currency code (e.g. "usd").
currency: usd
# Redirect URL after successful checkout. Use {base_url} placeholder.
success_url: '{base_url}/settings#storage'
success_url: '{base_url}/settings#billing'
# Redirect URL after cancelled checkout. Use {base_url} placeholder.
cancel_url: '{base_url}/settings#storage'
cancel_url: '{base_url}/settings#billing'
# Subscription tiers ordered by rank (lowest to highest).
tiers:
- # Tier name. Position in list determines rank (0-based).
@@ -119,6 +123,9 @@ billing:
max_webhooks: 1
# Allow all webhook trigger types (not just first-scan).
webhook_all_triggers: false
# Enable AI Image Advisor for this tier.
ai_advisor: false
# Show supporter badge on user profiles for subscribers at this tier.
supporter_badge: false
- # Tier name. Position in list determines rank (0-based).
name: Supporter
@@ -133,7 +140,10 @@ billing:
# Maximum webhooks for this tier (-1 = unlimited).
max_webhooks: 1
# Allow all webhook trigger types (not just first-scan).
webhook_all_triggers: false
webhook_all_triggers: true
# Enable AI Image Advisor for this tier.
ai_advisor: true
# Show supporter badge on user profiles for subscribers at this tier.
supporter_badge: true
- # Tier name. Position in list determines rank (0-based).
name: bosun
@@ -149,18 +159,9 @@ billing:
max_webhooks: 10
# Allow all webhook trigger types (not just first-scan).
webhook_all_triggers: true
# Enable AI Image Advisor for this tier.
ai_advisor: true
# Show supporter badge on user profiles for subscribers at this tier.
supporter_badge: true
# - # Tier name. Position in list determines rank (0-based).
# name: quartermaster
# # Short description shown on the plan card.
# description: Maximum storage for power users
# # List of features included in this tier.
# features: []
# # Stripe price ID for monthly billing. Empty = free tier.
# stripe_price_monthly: price_xxx
# # Stripe price ID for yearly billing.
# stripe_price_yearly: price_yyy
# # Maximum webhooks for this tier (-1 = unlimited).
# max_webhooks: -1
# # Allow all webhook trigger types (not just first-scan).
# webhook_all_triggers: true
# Show supporter badge on hold owner profiles.
owner_badge: true

View File

@@ -0,0 +1,47 @@
{
"lexicon": 1,
"id": "io.atcr.hold.stats.daily",
"defs": {
"main": {
"type": "record",
"key": "any",
"description": "Daily repository statistics stored in the hold's embedded PDS. Tracks pull/push counts per owner+repository+date combination. Record key is deterministic: base32(sha256(ownerDID + \"/\" + repository + \"/\" + date)[:16]). Complements cumulative io.atcr.hold.stats records by providing daily granularity for trend charts.",
"record": {
"type": "object",
"required": ["ownerDid", "repository", "date", "pullCount", "pushCount", "updatedAt"],
"properties": {
"ownerDid": {
"type": "string",
"format": "did",
"description": "DID of the image owner (e.g., did:plc:xyz123)"
},
"repository": {
"type": "string",
"description": "Repository name (e.g., myapp)",
"maxLength": 256
},
"date": {
"type": "string",
"description": "Date in YYYY-MM-DD format (UTC)",
"maxLength": 10
},
"pullCount": {
"type": "integer",
"minimum": 0,
"description": "Number of manifest downloads on this date"
},
"pushCount": {
"type": "integer",
"minimum": 0,
"description": "Number of manifest uploads on this date"
},
"updatedAt": {
"type": "string",
"format": "datetime",
"description": "RFC3339 timestamp of when this record was last updated"
}
}
}
}
}
}

View File

@@ -32,6 +32,7 @@ type Config struct {
Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."`
CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."`
Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."`
Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
}
@@ -140,6 +141,12 @@ type LegalConfig struct {
Jurisdiction string `yaml:"jurisdiction" comment:"Governing law jurisdiction for legal terms."`
}
// AIConfig defines AI-powered image advisor settings
type AIConfig struct {
// Anthropic API key for the AI Image Advisor feature.
APIKey string `yaml:"api_key" comment:"Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback."`
}
// setDefaults registers all default values on the given Viper instance.
func setDefaults(v *viper.Viper) {
v.SetDefault("version", "0.1")
@@ -189,6 +196,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("log_shipper.batch_size", 100)
v.SetDefault("log_shipper.flush_interval", "5s")
// AI defaults
v.SetDefault("ai.api_key", "")
// Legal defaults
v.SetDefault("legal.company_name", "")
v.SetDefault("legal.jurisdiction", "")
@@ -213,8 +223,8 @@ func ExampleYAML() ([]byte, error) {
// Populate example billing tiers so operators see the structure
cfg.Billing.Currency = "usd"
cfg.Billing.SuccessURL = "{base_url}/settings#storage"
cfg.Billing.CancelURL = "{base_url}/settings#storage"
cfg.Billing.SuccessURL = "{base_url}/settings#billing"
cfg.Billing.CancelURL = "{base_url}/settings#billing"
cfg.Billing.OwnerBadge = true
cfg.Billing.Tiers = []billing.BillingTierConfig{
{Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1},
@@ -255,6 +265,11 @@ func LoadConfig(yamlPath string) (*Config, error) {
cfg.Legal.CompanyName = cfg.Server.ClientName
}
// Post-load: AI API key fallback to CLAUDE_API_KEY env
if cfg.AI.APIKey == "" {
cfg.AI.APIKey = os.Getenv("CLAUDE_API_KEY")
}
// Validation
if cfg.Server.DefaultHoldDID == "" {
return nil, fmt.Errorf("server.default_hold_did is required (env: ATCR_SERVER_DEFAULT_HOLD_DID)")

View File

@@ -0,0 +1,7 @@
description: Cache AI image advisor suggestions per manifest digest
query: |
CREATE TABLE IF NOT EXISTS advisor_suggestions (
manifest_digest TEXT PRIMARY KEY,
suggestions_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,13 @@
description: Add subject_digest column to manifests for tracking OCI referrers (attestations, signatures)
query: |
ALTER TABLE manifests ADD COLUMN subject_digest TEXT;
CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest);
UPDATE manifests SET subject_digest = 'backfill'
WHERE artifact_type = 'unknown'
AND media_type NOT LIKE '%index%'
AND media_type NOT LIKE '%manifest.list%'
AND id IN (
SELECT m.id FROM manifests m
JOIN manifest_references mr ON mr.digest = m.digest
WHERE mr.is_attestation = 1
);

View File

@@ -0,0 +1,12 @@
description: Add daily repository stats table for pull/push trend tracking
query: |
CREATE TABLE IF NOT EXISTS repository_stats_daily (
did TEXT NOT NULL,
repository TEXT NOT NULL,
date TEXT NOT NULL,
pull_count INTEGER NOT NULL DEFAULT 0,
push_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(did, repository, date),
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);

View File

@@ -25,6 +25,7 @@ type Manifest struct {
ConfigDigest string
ConfigSize int64
ArtifactType string // container-image, helm-chart, unknown
SubjectDigest string // digest of the parent manifest (for attestations/referrers)
CreatedAt time.Time
// Annotations removed - now stored in repository_annotations table
}
@@ -92,6 +93,15 @@ type RepositoryStats struct {
LastPush *time.Time `json:"last_push,omitempty"`
}
// DailyStats represents daily pull/push statistics for a repository
type DailyStats struct {
DID string `json:"did"`
Repository string `json:"repository"`
Date string `json:"date"`
PullCount int `json:"pull_count"`
PushCount int `json:"push_count"`
}
// RepositoryWithStats combines repository data with statistics
type RepositoryWithStats struct {
Repository

View File

@@ -627,24 +627,28 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) {
_, err := db.Exec(`
INSERT INTO manifests
(did, repository, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, artifact_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
config_digest, config_size, artifact_type, subject_digest, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(did, repository, digest) DO UPDATE SET
hold_endpoint = excluded.hold_endpoint,
schema_version = excluded.schema_version,
media_type = excluded.media_type,
config_digest = excluded.config_digest,
config_size = excluded.config_size,
artifact_type = excluded.artifact_type
artifact_type = excluded.artifact_type,
subject_digest = excluded.subject_digest
WHERE excluded.hold_endpoint != manifests.hold_endpoint
OR excluded.schema_version != manifests.schema_version
OR excluded.media_type != manifests.media_type
OR excluded.config_digest IS NOT manifests.config_digest
OR excluded.config_size IS NOT manifests.config_size
OR excluded.artifact_type != manifests.artifact_type
OR excluded.subject_digest IS NOT manifests.subject_digest
`, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt)
manifest.ConfigSize, manifest.ArtifactType,
sql.NullString{String: manifest.SubjectDigest, Valid: manifest.SubjectDigest != ""},
manifest.CreatedAt)
if err != nil {
return 0, err
@@ -776,11 +780,27 @@ func CountTags(db DBTX, did, repository string) (int, error) {
// 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, limit, offset int) ([]TagWithPlatforms, error) {
rows, err := db.Query(`
return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset)
}
// getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName.
// If tagName is non-empty, only that specific tag is returned.
func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int) ([]TagWithPlatforms, error) {
var tagFilter string
var args []any
if tagName != "" {
tagFilter = "AND tag = ?"
args = append(args, did, repository, tagName, limit, offset)
} else {
args = append(args, did, repository, limit, offset)
}
query := `
WITH paged_tags AS (
SELECT id, did, repository, tag, digest, created_at
FROM tags
WHERE did = ? AND repository = ?
` + tagFilter + `
ORDER BY created_at DESC
LIMIT ? OFFSET ?
)
@@ -806,9 +826,9 @@ func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([
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
ORDER BY t.created_at DESC, mr.reference_index
`, did, repository, limit, offset)
ORDER BY t.created_at DESC, mr.reference_index`
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
@@ -878,15 +898,17 @@ func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([
return result, nil
}
// DeleteManifest deletes a manifest and its associated layers
// If repository is empty, deletes all manifests matching did and digest
// DeleteManifest deletes a manifest and its associated layers.
// Also deletes any attestation manifests that reference this manifest via subject_digest.
// If repository is empty, deletes all manifests matching did and digest.
func DeleteManifest(db DBTX, did, repository, digest string) error {
var err error
if repository == "" {
// Delete by DID + digest only (used when repository is unknown, e.g., Jetstream DELETE events)
// Delete attestation children first, then the manifest itself
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND subject_digest = ?`, did, digest)
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND digest = ?`, did, digest)
} else {
// Delete specific manifest
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND subject_digest = ?`, did, repository, digest)
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?`, did, repository, digest)
}
return err
@@ -1114,6 +1136,8 @@ func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
WHERE m.did = ? AND m.repository = ?
AND m.subject_digest IS NULL
AND m.artifact_type != 'unknown'
AND (
-- Include manifest lists
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
@@ -1414,6 +1438,7 @@ func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, e
FROM manifests m
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
WHERE m.did = ? AND m.repository = ?
AND m.subject_digest IS NULL
AND (
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
OR
@@ -2435,3 +2460,108 @@ func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error)
}
return true, nil
}
// GetTagByName returns a single tag with platform information by tag name.
// Returns nil, nil if the tag doesn't exist.
func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) {
tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0)
if err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
return &tags[0], nil
}
// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
func GetAllTagNames(db DBTX, did, repository string) ([]string, error) {
rows, err := db.Query(`
SELECT tag FROM tags
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
`, did, repository)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
return names, rows.Err()
}
// GetLayerCountForManifest returns the number of layers for a manifest identified by digest.
func GetLayerCountForManifest(db DBTX, did, repository, digest string) (int, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM layers l
JOIN manifests m ON l.manifest_id = m.id
WHERE m.did = ? AND m.repository = ? AND m.digest = ?
`, did, repository, digest).Scan(&count)
return count, err
}
// GetAdvisorSuggestions returns cached AI advisor suggestions for a manifest digest.
// Returns sql.ErrNoRows if no cached suggestions exist.
func GetAdvisorSuggestions(db DBTX, manifestDigest string) (suggestionsJSON string, createdAt time.Time, err error) {
err = db.QueryRow(
`SELECT suggestions_json, created_at FROM advisor_suggestions WHERE manifest_digest = ?`,
manifestDigest,
).Scan(&suggestionsJSON, &createdAt)
return
}
// UpsertAdvisorSuggestions caches AI advisor suggestions for a manifest digest.
func UpsertAdvisorSuggestions(db DBTX, manifestDigest, suggestionsJSON string) error {
_, err := db.Exec(
`INSERT OR REPLACE INTO advisor_suggestions (manifest_digest, suggestions_json, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
manifestDigest, suggestionsJSON,
)
return err
}
// UpsertDailyStats inserts or updates daily repository stats
func UpsertDailyStats(db DBTX, stats *DailyStats) error {
_, err := db.Exec(`
INSERT INTO repository_stats_daily (did, repository, date, pull_count, push_count)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(did, repository, date) DO UPDATE SET
pull_count = excluded.pull_count,
push_count = excluded.push_count
WHERE excluded.pull_count != repository_stats_daily.pull_count
OR excluded.push_count != repository_stats_daily.push_count
`, stats.DID, stats.Repository, stats.Date, stats.PullCount, stats.PushCount)
return err
}
// GetDailyStats retrieves daily stats for a repository within a date range
// startDate and endDate should be in YYYY-MM-DD format
func GetDailyStats(db DBTX, did, repository, startDate, endDate string) ([]DailyStats, error) {
rows, err := db.Query(`
SELECT did, repository, date, pull_count, push_count
FROM repository_stats_daily
WHERE did = ? AND repository = ? AND date >= ? AND date <= ?
ORDER BY date ASC
`, did, repository, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []DailyStats
for rows.Next() {
var s DailyStats
if err := rows.Scan(&s.DID, &s.Repository, &s.Date, &s.PullCount, &s.PushCount); err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, rows.Err()
}

View File

@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS manifests (
config_digest TEXT,
config_size INTEGER,
artifact_type TEXT NOT NULL DEFAULT 'container-image', -- container-image, helm-chart, unknown
subject_digest TEXT, -- digest of the parent manifest (for attestations/referrers)
created_at TIMESTAMP NOT NULL,
UNIQUE(did, repository, digest),
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
@@ -38,6 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest);
CREATE TABLE IF NOT EXISTS repository_annotations (
did TEXT NOT NULL,
@@ -167,6 +169,17 @@ CREATE TABLE IF NOT EXISTS repository_stats (
CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did);
CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC);
CREATE TABLE IF NOT EXISTS repository_stats_daily (
did TEXT NOT NULL,
repository TEXT NOT NULL,
date TEXT NOT NULL,
pull_count INTEGER NOT NULL DEFAULT 0,
push_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(did, repository, date),
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);
CREATE TABLE IF NOT EXISTS stars (
starrer_did TEXT NOT NULL,
owner_did TEXT NOT NULL,
@@ -273,3 +286,9 @@ CREATE TABLE IF NOT EXISTS scans (
PRIMARY KEY(hold_did, manifest_digest)
);
CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
CREATE TABLE IF NOT EXISTS advisor_suggestions (
manifest_digest TEXT PRIMARY KEY,
suggestions_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -40,9 +40,10 @@ type BaseUIHandler struct {
OAuthStore *db.OAuthStore
// Config
DefaultHoldDID string
CompanyName string
Jurisdiction string
ClientName string // Full name: "AT Container Registry"
ClientShortName string // Short name: "ATCR"
DefaultHoldDID string
CompanyName string
Jurisdiction string
ClientName string // Full name: "AT Container Registry"
ClientShortName string // Short name: "ATCR"
AIAdvisorEnabled bool // True when Claude API key is configured
}

View File

@@ -10,13 +10,14 @@ import (
// PageData contains common fields shared across all page templates
type PageData struct {
User *db.User // Logged-in user (nil if not logged in)
Query string // Search query from URL parameter
RegistryURL string // Docker registry domain (e.g., "buoy.cr")
SiteURL string // Website domain (e.g., "seamark.dev")
ClientName string // Brand name for templates (e.g., "AT Container Registry")
ClientShortName string // Brand name for templates (e.g., "ATCR")
OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
User *db.User // Logged-in user (nil if not logged in)
Query string // Search query from URL parameter
RegistryURL string // Docker registry domain (e.g., "buoy.cr")
SiteURL string // Website domain (e.g., "seamark.dev")
ClientName string // Brand name for templates (e.g., "AT Container Registry")
ClientShortName string // Brand name for templates (e.g., "ATCR")
OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
AIAdvisorEnabled bool // True when AI Image Advisor is available
}
// NewPageData creates a PageData struct with common fields populated from the request
@@ -27,13 +28,14 @@ func NewPageData(r *http.Request, h *BaseUIHandler) PageData {
ociClient = user.OciClient
}
return PageData{
User: user,
Query: r.URL.Query().Get("q"),
RegistryURL: h.RegistryURL,
SiteURL: h.SiteURL,
ClientName: h.ClientName,
ClientShortName: h.ClientShortName,
OciClient: ociClient,
User: user,
Query: r.URL.Query().Get("q"),
RegistryURL: h.RegistryURL,
SiteURL: h.SiteURL,
ClientName: h.ClientName,
ClientShortName: h.ClientShortName,
OciClient: ociClient,
AIAdvisorEnabled: h.AIAdvisorEnabled,
}
}

View File

@@ -210,9 +210,9 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
fromDigest := r.URL.Query().Get("from")
toDigest := r.URL.Query().Get("to")
if fromDigest == "" || toDigest == "" {
fromParam := r.URL.Query().Get("from")
toParam := r.URL.Query().Get("to")
if fromParam == "" || toParam == "" {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
@@ -234,6 +234,26 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
owner.Handle = resolvedHandle
}
// Resolve from/to params — accept either digests (sha256:...) or tag names
fromDigest := fromParam
toDigest := toParam
if !strings.HasPrefix(fromDigest, "sha256:") {
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, fromParam)
if err != nil || tag == nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
fromDigest = tag.Digest
}
if !strings.HasPrefix(toDigest, "sha256:") {
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, toParam)
if err != nil || tag == nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
toDigest = tag.Digest
}
// Fetch both manifests
type manifestData struct {
manifest *db.ManifestWithMetadata

View File

@@ -45,9 +45,9 @@ func TestComputeLayerDiff_SharedPrefixThenDivergence(t *testing.T) {
digest string
}{
{"shared", "sha256:base"},
{"removed", "sha256:old"}, // no command, different digest → -/+
{"removed", "sha256:old"}, // no command, different digest → -/+
{"added", "sha256:new1"},
{"added", "sha256:new2"}, // extra layer in to
{"added", "sha256:new2"}, // extra layer in to
}
for i, e := range expected {

View File

@@ -91,8 +91,29 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil {
slog.Warn("Failed to render digest content", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
// Support rendering individual sections for repo page tabs
section := r.URL.Query().Get("section")
switch section {
case "layers":
if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil {
slog.Warn("Failed to render layers section", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
case "vulns":
if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil {
slog.Warn("Failed to render vulns section", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
case "sbom":
if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil {
slog.Warn("Failed to render sbom section", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
default:
if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil {
slog.Warn("Failed to render digest content", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

View File

@@ -0,0 +1,754 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
// ImageAdvisorHandler returns AI-powered suggestions for improving a container image.
// Returns an HTML fragment (image-advisor-results partial) via HTMX.
type ImageAdvisorHandler struct {
BaseUIHandler
ClaudeAPIKey string
}
type advisorSuggestion struct {
Action string `json:"action"`
Category string `json:"category"`
Impact string `json:"impact"`
Effort string `json:"effort"`
CVEsFixed int `json:"cves_fixed"`
SizeSavedMB int `json:"size_saved_mb"`
Detail string `json:"detail"`
}
type imageAdvisorData struct {
Suggestions []advisorSuggestion
Error string
}
// OCI config types for full image config parsing
type advisorOCIConfig struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
Config advisorOCIContainerConfig `json:"config"`
History []advisorOCIHistory `json:"history"`
}
type advisorOCIContainerConfig struct {
Env []string `json:"Env"`
Cmd []string `json:"Cmd"`
Entrypoint []string `json:"Entrypoint"`
WorkingDir string `json:"WorkingDir"`
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
Labels map[string]string `json:"Labels"`
User string `json:"User"`
}
type advisorOCIHistory struct {
CreatedBy string `json:"created_by"`
EmptyLayer bool `json:"empty_layer"`
}
// SPDX types for SBOM parsing
type advisorSPDX struct {
Packages []advisorSPDXPackage `json:"packages"`
}
type advisorSPDXPackage struct {
SPDXID string `json:"SPDXID"`
Name string `json:"name"`
VersionInfo string `json:"versionInfo"`
Supplier string `json:"supplier"`
}
// advisorReportData holds all fetched data for prompt generation
type advisorReportData struct {
Handle string
Repository string
Digest string
Platform string
Config *advisorOCIConfig
Layers []db.Layer
ScanRecord *atproto.ScanRecord
VulnReport *grypeReport
SBOM *advisorSPDX
}
func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.ClaudeAPIKey == "" {
h.renderResults(w, imageAdvisorData{Error: "AI advisor is not configured"})
return
}
identifier := chi.URLParam(r, "handle")
wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
digest := r.URL.Query().Get("digest")
if wildcard == "" || digest == "" {
h.renderResults(w, imageAdvisorData{Error: "Missing required parameters"})
return
}
// Verify the logged-in user owns this image
user := middleware.GetUser(r)
if user == nil {
h.renderResults(w, imageAdvisorData{Error: "Login required"})
return
}
// Check billing access
if h.BillingManager != nil && !h.BillingManager.HasAIAdvisor(user.DID) {
h.renderResults(w, imageAdvisorData{Error: "upgrade_required"})
return
}
// Check user preference
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(r.Context(), client)
if err == nil && profile != nil && profile.AIAdvisorEnabled != nil && !*profile.AIAdvisorEnabled {
h.renderResults(w, imageAdvisorData{Error: "AI advisor is disabled in your settings"})
return
}
// Resolve identity
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
h.renderResults(w, imageAdvisorData{Error: "Could not resolve identity"})
return
}
if user.DID != did {
h.renderResults(w, imageAdvisorData{Error: "You can only generate suggestions for your own images"})
return
}
// Fetch manifest
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, digest)
if err != nil {
h.renderResults(w, imageAdvisorData{Error: "Manifest not found"})
return
}
// For manifest lists, the caller should pass a platform-specific child digest.
// If they somehow pass the list digest itself, resolve to the first platform.
if manifest.IsManifestList {
if len(manifest.Platforms) == 0 {
h.renderResults(w, imageAdvisorData{Error: "No platforms found in manifest list"})
return
}
childDigest := manifest.Platforms[0].Digest
childManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, childDigest)
if err != nil {
h.renderResults(w, imageAdvisorData{Error: "Could not resolve platform manifest"})
return
}
manifest = childManifest
digest = childDigest
}
// Check cache first
if cachedJSON, _, err := db.GetAdvisorSuggestions(h.ReadOnlyDB, digest); err == nil {
suggestions, err := parseAdvisorResponse(cachedJSON)
if err == nil {
slog.Debug("Serving cached advisor suggestions", "digest", digest)
h.renderResults(w, imageAdvisorData{Suggestions: suggestions})
return
}
slog.Debug("Cached advisor data unparseable, fetching fresh", "digest", digest)
}
// Resolve hold
hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
if err != nil {
h.renderResults(w, imageAdvisorData{Error: "Could not resolve hold endpoint"})
return
}
// Build report data
report := &advisorReportData{
Handle: resolvedHandle,
Repository: wildcard,
Digest: digest,
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Fetch full OCI image config
config, err := fetchAdvisorImageConfig(ctx, hold.URL, digest)
if err != nil {
slog.Debug("Failed to fetch image config for advisor", "error", err)
} else {
report.Config = config
report.Platform = config.OS + "/" + config.Architecture
}
// Fetch layers for size info
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID)
if err != nil {
slog.Debug("Failed to fetch layers for advisor", "error", err)
}
report.Layers = dbLayers
// Fetch scan data (scan record + vuln blob + SBOM blob)
scanRecord, vulnReport, sbom := fetchAdvisorScanData(ctx, hold.URL, hold.DID, digest)
report.ScanRecord = scanRecord
report.VulnReport = vulnReport
report.SBOM = sbom
// Generate prompt
var promptBuf strings.Builder
generateAdvisorPrompt(&promptBuf, report)
// Call Claude API
responseText, err := callClaudeAPI(ctx, h.ClaudeAPIKey, promptBuf.String())
if err != nil {
slog.Warn("Claude API call failed", "error", err)
h.renderResults(w, imageAdvisorData{Error: "AI service request failed: " + err.Error()})
return
}
// Parse JSON response
suggestions, err := parseAdvisorResponse(responseText)
if err != nil {
slog.Warn("Failed to parse advisor response", "error", err, "response", responseText)
h.renderResults(w, imageAdvisorData{Error: "Failed to parse AI response"})
return
}
// Cache the response
if err := db.UpsertAdvisorSuggestions(h.DB, digest, responseText); err != nil {
slog.Warn("Failed to cache advisor suggestions", "error", err)
}
h.renderResults(w, imageAdvisorData{Suggestions: suggestions})
}
func (h *ImageAdvisorHandler) renderResults(w http.ResponseWriter, data imageAdvisorData) {
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "image-advisor-results", data); err != nil {
slog.Warn("Failed to render image advisor results", "error", err)
}
}
// fetchAdvisorImageConfig fetches the full OCI image config from the hold.
func fetchAdvisorImageConfig(ctx context.Context, holdURL, manifestDigest string) (*advisorOCIConfig, error) {
reqURL := fmt.Sprintf("%s%s?digest=%s",
strings.TrimSuffix(holdURL, "/"),
atproto.HoldGetImageConfig,
url.QueryEscape(manifestDigest),
)
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("hold returned %d", resp.StatusCode)
}
var record struct {
ConfigJSON string `json:"configJson"`
}
if err := json.NewDecoder(resp.Body).Decode(&record); err != nil {
return nil, err
}
var config advisorOCIConfig
if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil {
return nil, err
}
return &config, nil
}
// fetchAdvisorScanData fetches the scan record plus vuln and SBOM blobs.
func fetchAdvisorScanData(ctx context.Context, holdURL, holdDID, digest string) (*atproto.ScanRecord, *grypeReport, *advisorSPDX) {
rkey := strings.TrimPrefix(digest, "sha256:")
scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
strings.TrimSuffix(holdURL, "/"),
url.QueryEscape(holdDID),
url.QueryEscape(atproto.ScanCollection),
url.QueryEscape(rkey),
)
req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
if err != nil {
return nil, nil, nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, nil, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, nil
}
var envelope struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return nil, nil, nil
}
var scanRecord atproto.ScanRecord
if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
return nil, nil, nil
}
// Fetch vuln report blob
var vulnReport *grypeReport
if scanRecord.VulnReportBlob != nil && scanRecord.VulnReportBlob.Ref.String() != "" {
vulnReport = fetchAdvisorBlob[grypeReport](ctx, holdURL, holdDID, scanRecord.VulnReportBlob.Ref.String())
}
// Fetch SBOM blob
var sbom *advisorSPDX
if scanRecord.SbomBlob != nil && scanRecord.SbomBlob.Ref.String() != "" {
sbom = fetchAdvisorBlob[advisorSPDX](ctx, holdURL, holdDID, scanRecord.SbomBlob.Ref.String())
}
return &scanRecord, vulnReport, sbom
}
// fetchAdvisorBlob fetches and JSON-decodes a blob from the hold.
func fetchAdvisorBlob[T any](ctx context.Context, holdURL, holdDID, cid string) *T {
blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
strings.TrimSuffix(holdURL, "/"),
url.QueryEscape(holdDID),
url.QueryEscape(cid),
)
req, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil)
if err != nil {
return nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
var result T
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil
}
return &result
}
// generateAdvisorPrompt writes the system+data prompt for the AI advisor.
func generateAdvisorPrompt(w io.Writer, r *advisorReportData) {
// Data block
ref := r.Handle + "/" + r.Repository
totalSize := int64(0)
for _, l := range r.Layers {
totalSize += l.Size
}
fmt.Fprintf(w, "image: %s\ndigest: %s\n", ref, r.Digest)
if r.Platform != "" {
fmt.Fprintf(w, "platform: %s\n", r.Platform)
}
fmt.Fprintf(w, "total_size: %s\nlayers: %d\n", advisorHumanSize(totalSize), len(r.Layers))
if r.Config != nil {
c := r.Config.Config
user := c.User
if user == "" {
user = "root"
}
fmt.Fprintf(w, "user: %s\n", user)
if c.WorkingDir != "" {
fmt.Fprintf(w, "workdir: %s\n", c.WorkingDir)
}
if len(c.Entrypoint) > 0 {
fmt.Fprintf(w, "entrypoint: %s\n", strings.Join(c.Entrypoint, " "))
}
if len(c.Cmd) > 0 {
fmt.Fprintf(w, "cmd: %s\n", strings.Join(c.Cmd, " "))
}
if len(c.ExposedPorts) > 0 {
ports := make([]string, 0, len(c.ExposedPorts))
for p := range c.ExposedPorts {
ports = append(ports, p)
}
fmt.Fprintf(w, "ports: %s\n", strings.Join(ports, ","))
}
if len(c.Env) > 0 {
fmt.Fprintln(w, "env:")
for _, env := range c.Env {
parts := strings.SplitN(env, "=", 2)
if advisorShouldRedact(parts[0]) {
fmt.Fprintf(w, " - %s=[REDACTED]\n", parts[0])
} else {
fmt.Fprintf(w, " - %s\n", env)
}
}
}
if len(c.Labels) > 0 {
fmt.Fprintln(w, "labels:")
keys := make([]string, 0, len(c.Labels))
for k := range c.Labels {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := c.Labels[k]
if len(v) > 80 {
v = v[:77] + "..."
}
fmt.Fprintf(w, " %s: %s\n", k, v)
}
}
// History with layer sizes
fmt.Fprintln(w, "history:")
layerIdx := 0
for _, h := range r.Config.History {
cmd := advisorCleanCommand(h.CreatedBy)
if len(cmd) > 100 {
cmd = cmd[:97] + "..."
}
if !h.EmptyLayer && layerIdx < len(r.Layers) {
fmt.Fprintf(w, " - [%s] %s\n", advisorHumanSize(r.Layers[layerIdx].Size), cmd)
layerIdx++
} else {
fmt.Fprintf(w, " - %s\n", cmd)
}
}
}
// Vulnerability summary
if r.ScanRecord != nil {
sr := r.ScanRecord
fmt.Fprintf(w, "vulns: {critical: %d, high: %d, medium: %d, low: %d, total: %d}\n",
sr.Critical, sr.High, sr.Medium, sr.Low, sr.Total)
}
// Fixable critical/high vulns
if r.VulnReport != nil {
type pkgInfo struct {
version string
typ string
fixes map[string]bool
cves []string
maxSev int
}
pkgs := map[string]*pkgInfo{}
for _, m := range r.VulnReport.Matches {
sev := m.Vulnerability.Metadata.Severity
if sev != "Critical" && sev != "High" {
continue
}
key := m.Package.Name
p, ok := pkgs[key]
if !ok {
p = &pkgInfo{version: m.Package.Version, typ: m.Package.Type, fixes: map[string]bool{}, maxSev: 5}
pkgs[key] = p
}
p.cves = append(p.cves, m.Vulnerability.ID)
for _, f := range m.Vulnerability.Fix.Versions {
p.fixes[f] = true
}
if s := advisorSeverityRank(sev); s < p.maxSev {
p.maxSev = s
}
}
if len(pkgs) > 0 {
fmt.Fprintln(w, "fixable_critical_high:")
type entry struct {
name string
info *pkgInfo
}
sorted := make([]entry, 0, len(pkgs))
for n, p := range pkgs {
sorted = append(sorted, entry{n, p})
}
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].info.maxSev != sorted[j].info.maxSev {
return sorted[i].info.maxSev < sorted[j].info.maxSev
}
return len(sorted[i].info.cves) > len(sorted[j].info.cves)
})
for _, e := range sorted {
fixes := make([]string, 0, len(e.info.fixes))
for f := range e.info.fixes {
fixes = append(fixes, f)
}
sort.Strings(fixes)
fmt.Fprintf(w, " - pkg: %s@%s (%s) cves: %d fix: %s\n",
e.name, e.info.version, e.info.typ, len(e.info.cves), strings.Join(fixes, ","))
}
}
// Unfixable counts
unfixable := map[string]int{}
for _, m := range r.VulnReport.Matches {
if len(m.Vulnerability.Fix.Versions) == 0 {
unfixable[m.Vulnerability.Metadata.Severity]++
}
}
if len(unfixable) > 0 {
fmt.Fprintf(w, "unfixable:")
for _, sev := range []string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"} {
if c, ok := unfixable[sev]; ok {
fmt.Fprintf(w, " %s=%d", strings.ToLower(sev), c)
}
}
fmt.Fprintln(w)
}
}
// SBOM summary
if r.SBOM != nil {
typeCounts := map[string]int{}
total := 0
for _, p := range r.SBOM.Packages {
if strings.HasPrefix(p.SPDXID, "SPDXRef-DocumentRoot") || p.SPDXID == "SPDXRef-DOCUMENT" {
continue
}
total++
pkgType := advisorExtractPackageType(p.Supplier)
if pkgType == "" {
pkgType = "other"
}
typeCounts[pkgType]++
}
fmt.Fprintf(w, "sbom_packages: %d", total)
for t, c := range typeCounts {
fmt.Fprintf(w, " %s=%d", t, c)
}
fmt.Fprintln(w)
if r.VulnReport != nil {
vulnPkgs := map[string]int{}
for _, m := range r.VulnReport.Matches {
vulnPkgs[m.Package.Name]++
}
type pv struct {
name string
count int
}
sorted := make([]pv, 0, len(vulnPkgs))
for n, c := range vulnPkgs {
sorted = append(sorted, pv{n, c})
}
sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count })
if len(sorted) > 10 {
sorted = sorted[:10]
}
fmt.Fprintln(w, "top_vulnerable_packages:")
for _, p := range sorted {
fmt.Fprintf(w, " - %s: %d\n", p.name, p.count)
}
}
}
}
// callClaudeAPI sends the prompt to Claude Haiku using tool use and returns the structured JSON.
func callClaudeAPI(ctx context.Context, apiKey, prompt string) (string, error) {
reqBody := map[string]any{
"model": "claude-haiku-4-5-20251001",
"max_tokens": 2048,
"system": "Analyze the container image data. Provide actionable suggestions sorted by impact (highest first).",
"tools": []map[string]any{{
"name": "suggest_fixes",
"description": "Return actionable suggestions for improving a container image, sorted by impact.",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"suggestions": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "description": "Specific actionable step"},
"category": map[string]any{"type": "string", "enum": []string{"vulnerability", "size", "cache", "security", "best-practice"}},
"impact": map[string]any{"type": "string", "enum": []string{"high", "medium", "low"}},
"effort": map[string]any{"type": "string", "enum": []string{"low", "medium", "high"}},
"cves_fixed": map[string]any{"type": "integer", "description": "Number of CVEs fixed, or 0"},
"size_saved_mb": map[string]any{"type": "integer", "description": "Estimated MB saved, or 0"},
"detail": map[string]any{"type": "string", "description": "One sentence with specific package names, versions, or commands"},
},
"required": []string{"action", "category", "impact", "effort", "cves_fixed", "size_saved_mb", "detail"},
},
},
},
"required": []string{"suggestions"},
},
}},
"tool_choice": map[string]any{"type": "tool", "name": "suggest_fixes"},
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("API request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body))
}
var apiResp struct {
Content []struct {
Type string `json:"type"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
for _, c := range apiResp.Content {
if c.Type == "tool_use" && c.Name == "suggest_fixes" {
return string(c.Input), nil
}
}
return "", fmt.Errorf("no tool_use content in response")
}
// parseAdvisorResponse parses the JSON suggestions (from API or cache) into suggestions.
func parseAdvisorResponse(jsonStr string) ([]advisorSuggestion, error) {
var result struct {
Suggestions []advisorSuggestion `json:"suggestions"`
}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
return nil, err
}
return result.Suggestions, nil
}
// Prompt helper functions
func advisorHumanSize(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}
func advisorCleanCommand(cmd string) string {
cmd = strings.TrimPrefix(cmd, "/bin/sh -c ")
cmd = strings.TrimPrefix(cmd, "#(nop) ")
return strings.TrimSpace(cmd)
}
func advisorShouldRedact(envName string) bool {
upper := strings.ToUpper(envName)
for _, suffix := range []string{"_KEY", "_SECRET", "_PASSWORD", "_TOKEN", "_CREDENTIALS", "_API_KEY"} {
if strings.HasSuffix(upper, suffix) {
return true
}
}
return false
}
func advisorSeverityRank(s string) int {
switch s {
case "Critical":
return 0
case "High":
return 1
case "Medium":
return 2
case "Low":
return 3
case "Negligible":
return 4
default:
return 5
}
}
func advisorExtractPackageType(supplier string) string {
s := strings.ToLower(supplier)
switch {
case strings.Contains(s, "npmjs") || strings.Contains(s, "npm"):
return "npm"
case strings.Contains(s, "pypi") || strings.Contains(s, "python"):
return "python"
case strings.Contains(s, "rubygems"):
return "gem"
case strings.Contains(s, "golang") || strings.Contains(s, "go"):
return "go"
case strings.Contains(s, "debian") || strings.Contains(s, "ubuntu"):
return "deb"
case strings.Contains(s, "alpine"):
return "apk"
case strings.Contains(s, "redhat") || strings.Contains(s, "fedora") || strings.Contains(s, "centos"):
return "rpm"
case strings.Contains(s, "maven") || strings.Contains(s, "java"):
return "java"
case strings.Contains(s, "nuget") || strings.Contains(s, ".net"):
return "nuget"
case strings.Contains(s, "cargo") || strings.Contains(s, "rust"):
return "rust"
default:
return ""
}
}

View File

@@ -12,12 +12,22 @@ import (
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdclient"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
// SelectedTagData holds all data for the currently selected tag on the repo page.
type SelectedTagData struct {
Info *db.TagWithPlatforms
LayerCount int
CompressedSize int64 // total across all platforms
ScanBatchParams []template.HTML
}
// RepositoryPageHandler handles the public repository page
type RepositoryPageHandler struct {
BaseUIHandler
@@ -62,19 +72,93 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
// Fetch latest tag for pull command
latestTag, err := db.GetLatestTag(h.ReadOnlyDB, owner.DID, repository)
// Fetch all tag names for the selector dropdown
allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
slog.Warn("Failed to fetch tag names", "error", err)
}
// Determine artifact type from latest tag
artifactType := "container-image"
latestTagName := ""
if latestTag != nil {
latestTagName = latestTag.Tag
artifactType = latestTag.ArtifactType
// Determine which tag to show
selectedTagName := r.URL.Query().Get("tag")
if selectedTagName == "" {
// Default: "latest" if it exists, otherwise most recent
for _, t := range allTags {
if t == "latest" {
selectedTagName = "latest"
break
}
}
if selectedTagName == "" && len(allTags) > 0 {
selectedTagName = allTags[0] // most recent (already sorted DESC)
}
}
// Fetch the selected tag's full data
var selectedTag *SelectedTagData
var artifactType = "container-image"
if selectedTagName != "" {
tagData, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repository, selectedTagName)
if err != nil {
slog.Warn("Failed to fetch selected tag", "error", err, "tag", selectedTagName)
}
if tagData != nil {
artifactType = tagData.ArtifactType
// Ensure single-arch tags have a one-element Platforms slice
platforms := tagData.Platforms
if len(platforms) == 0 {
platforms = []db.PlatformInfo{{
Digest: tagData.Digest,
HoldEndpoint: tagData.HoldEndpoint,
CompressedSize: tagData.CompressedSize,
}}
}
tagData.Platforms = platforms
// Compute total compressed size across all platforms
var totalSize int64
if tagData.IsMultiArch {
for _, p := range platforms {
totalSize += p.CompressedSize
}
} else {
totalSize = tagData.CompressedSize
}
// Get layer count from image config (includes empty layers)
layerCountDigest := tagData.Digest
if tagData.IsMultiArch && len(platforms) > 0 && platforms[0].Digest != "" {
layerCountDigest = platforms[0].Digest
}
var layerCount int
holdEndpoint := platforms[0].HoldEndpoint
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if holdErr == nil {
config, cfgErr := holdclient.FetchImageConfig(r.Context(), hold.URL, layerCountDigest)
if cfgErr == nil {
layerCount = len(config.History)
} else {
slog.Warn("Failed to fetch image config for layer count", "error", cfgErr)
}
}
// Fall back to DB count (non-empty layers only) if hold unavailable
if layerCount == 0 {
layerCount, err = db.GetLayerCountForManifest(h.ReadOnlyDB, owner.DID, repository, layerCountDigest)
if err != nil {
slog.Warn("Failed to fetch layer count", "error", err)
}
}
// Build scan batch params for first platform only (summary card)
scanBatchParams := buildScanBatchParams(platforms[:1])
selectedTag = &SelectedTagData{
Info: tagData,
LayerCount: layerCount,
CompressedSize: totalSize,
ScanBatchParams: scanBatchParams,
}
}
}
// Create repository summary
@@ -97,13 +181,18 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
repo.Version = metadata["org.opencontainers.image.version"]
}
// Fetch star count
// Fetch stats
stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
slog.Warn("Failed to fetch repository stats", "error", err)
stats = &db.RepositoryStats{StarCount: 0}
}
tagCount, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository)
if err != nil {
slog.Warn("Failed to fetch tag count", "error", err)
}
// Check if current user has starred this repo
isStarred := false
user := middleware.GetUser(r)
@@ -193,9 +282,10 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
Meta *PageMeta
Owner *db.User
Repository *db.Repository
LatestTag string
StarCount int
PullCount int
AllTags []string
SelectedTag *SelectedTagData
Stats *db.RepositoryStats
TagCount int
IsStarred bool
IsOwner bool
ReadmeHTML template.HTML
@@ -206,9 +296,10 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
Meta: meta,
Owner: owner,
Repository: repo,
LatestTag: latestTagName,
StarCount: stats.StarCount,
PullCount: stats.PullCount,
AllTags: allTags,
SelectedTag: selectedTag,
Stats: stats,
TagCount: tagCount,
IsStarred: isStarred,
IsOwner: isOwner,
ReadmeHTML: readmeHTML,
@@ -216,12 +307,57 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
ArtifactType: artifactType,
}
// If the owner has disabled AI advisor in their profile, hide the button
if isOwner && data.AIAdvisorEnabled && user != nil {
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
if profile, err := storage.GetProfile(r.Context(), client); err == nil && profile != nil {
if profile.AIAdvisorEnabled != nil && !*profile.AIAdvisorEnabled {
data.AIAdvisorEnabled = false
}
}
}
// If this is an HTMX request for the tag section, render just the partial
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("tag") != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.Templates.ExecuteTemplate(w, "repo-tag-section", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// buildScanBatchParams builds HTMX query params for batch scan-result requests
// from a list of platform infos.
func buildScanBatchParams(platforms []db.PlatformInfo) []template.HTML {
holdDigests := make(map[string][]string)
seen := make(map[string]bool)
for _, p := range 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)
}
}
var params []template.HTML
for hold, digests := range holdDigests {
for i := 0; i < len(digests); i += 50 {
end := i + 50
if end > len(digests) {
end = len(digests)
}
params = append(params, template.HTML(
"holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests[i:end], ",")))
}
}
return params
}
// RepositoryTagsHandler returns the tags+manifests HTMX partial for a repository
type RepositoryTagsHandler struct {
BaseUIHandler

View File

@@ -138,6 +138,8 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
DefaultHold string
AutoRemoveUntagged bool
OciClient string
AIAdvisorEnabled bool
HasAIAdvisorAccess bool // billing tier grants access
}
ActiveHold *HoldDisplay
OtherHolds []HoldDisplay
@@ -160,6 +162,10 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
data.Profile.DefaultHold = profile.DefaultHold
data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged
data.Profile.OciClient = profile.OciClient
data.Profile.AIAdvisorEnabled = profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled
if h.BillingManager != nil {
data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID)
}
if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -477,6 +483,43 @@ func (h *UpdateOciClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusNoContent)
}
// UpdateAIAdvisorHandler handles toggling the AI Image Advisor setting
type UpdateAIAdvisorHandler struct {
BaseUIHandler
}
func (h *UpdateAIAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
return
}
// Toggle: nil/true → false, false → nil (default enabled)
if profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled {
f := false
profile.AIAdvisorEnabled = &f
} else {
profile.AIAdvisorEnabled = nil
}
profile.UpdatedAt = time.Now()
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally.
// This ensures badge tiers and other captain metadata are available immediately
// without waiting for Jetstream or the next backfill cycle.

View File

@@ -94,7 +94,7 @@ func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
if r.TLS == nil {
scheme = "http"
}
returnURL := scheme + "://" + h.SiteURL + "/settings#storage"
returnURL := scheme + "://" + h.SiteURL + "/settings#billing"
resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL)
if err != nil {

View File

@@ -129,10 +129,9 @@ func (h *UpgradeBannerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
return
}
} else if currentManifest.IsManifestList || newerManifest.IsManifestList {
// One is multi-arch, the other isn't — can't compare meaningfully
// Still show a basic banner without layer/vuln details
}
// If one is multi-arch and the other isn't, we can't match platforms —
// fall through and show a basic banner without layer/vuln details.
// Fetch layers for both
currentDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, currentManifestForLayers.ID)

View File

@@ -83,6 +83,7 @@ func (b *BackfillWorker) Start(ctx context.Context) error {
atproto.SailorProfileCollection, // io.atcr.sailor.profile
atproto.RepoPageCollection, // io.atcr.repo.page
atproto.StatsCollection, // io.atcr.hold.stats (from holds)
atproto.DailyStatsCollection, // io.atcr.hold.stats.daily (from holds)
atproto.CaptainCollection, // io.atcr.hold.captain (from holds)
atproto.CrewCollection, // io.atcr.hold.crew (from holds)
atproto.ScanCollection, // io.atcr.hold.scan (from holds)

View File

@@ -289,6 +289,9 @@ func (p *Processor) ProcessRecord(ctx context.Context, did, collection, rkey str
case atproto.StatsCollection:
return p.ProcessStats(ctx, did, data, isDelete)
case atproto.DailyStatsCollection:
return p.ProcessDailyStats(ctx, did, data, isDelete)
case atproto.CaptainCollection:
if isDelete {
return db.DeleteCaptainRecord(p.db, did)
@@ -355,6 +358,11 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
manifest.ConfigSize = manifestRecord.Config.Size
}
// Track subject digest for attestation/referrer manifests
if manifestRecord.Subject != nil {
manifest.SubjectDigest = manifestRecord.Subject.Digest
}
// Insert manifest
manifestID, err := db.InsertManifest(p.db, manifest)
if err != nil {
@@ -825,6 +833,40 @@ func (p *Processor) ProcessStats(ctx context.Context, holdDID string, recordData
})
}
// ProcessDailyStats handles daily stats record events from hold PDSes
// This is called when Jetstream receives a daily stats create/update/delete event from a hold
func (p *Processor) ProcessDailyStats(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error {
var record atproto.DailyStatsRecord
if err := json.Unmarshal(recordData, &record); err != nil {
return fmt.Errorf("failed to unmarshal daily stats record: %w", err)
}
if isDelete {
// Daily stats deletions are rare — just log and skip
slog.Debug("Daily stats record deleted",
"holdDID", holdDID,
"ownerDID", record.OwnerDID,
"repository", record.Repository,
"date", record.Date)
return nil
}
// Ensure the owner user exists (FK constraint)
if record.OwnerDID != "" {
if err := p.EnsureUserExists(ctx, record.OwnerDID); err != nil {
return fmt.Errorf("failed to ensure daily stats owner user exists: %w", err)
}
}
return db.UpsertDailyStats(p.db, &db.DailyStats{
DID: record.OwnerDID,
Repository: record.Repository,
Date: record.Date,
PullCount: int(record.PullCount),
PushCount: int(record.PushCount),
})
}
// ProcessCaptain handles captain record events from hold PDSes
// This is called when Jetstream receives a captain create/update/delete event from a hold
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownership info

View File

@@ -57,6 +57,7 @@ func setupTestDB(t *testing.T) *sql.DB {
config_digest TEXT,
config_size INTEGER,
artifact_type TEXT NOT NULL DEFAULT 'container-image',
subject_digest TEXT,
created_at TIMESTAMP NOT NULL,
UNIQUE(did, repository, digest)
);

View File

@@ -7,6 +7,7 @@
<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="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol>
<symbol id="book-open" viewBox="0 0 24 24"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></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>
@@ -18,13 +19,16 @@
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
<symbol id="cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol>
<symbol id="credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></symbol>
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol>
<symbol id="file-text" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol>
<symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
<symbol id="fingerprint" viewBox="0 0 24 24"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></symbol>
<symbol id="git-compare" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></symbol>
<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>
@@ -50,9 +54,12 @@
<symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="ship" viewBox="0 0 24 24"><path d="M12 10.189V14"/><path d="M12 2v3"/><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"/><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></symbol>
<symbol id="sparkle" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/></symbol>
<symbol id="sparkles" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></symbol>
<symbol id="star" viewBox="0 0 24 24"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></symbol>
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></symbol>
<symbol id="sun-moon" viewBox="0 0 24 24"><path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/></symbol>
<symbol id="tag" viewBox="0 0 24 24"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/></symbol>
<symbol id="terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol>
<symbol id="trash-2" viewBox="0 0 24 24"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
<symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -44,6 +44,7 @@ type UIDependencies struct {
ClientShortName string // Short name: "ATCR"
BillingManager *billing.Manager // Stripe billing manager (nil if not configured)
WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured)
ClaudeAPIKey string // Anthropic API key for AI advisor (empty = disabled)
}
// RegisterUIRoutes registers all web UI and API routes on the provided router
@@ -78,6 +79,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
Jurisdiction: deps.LegalConfig.Jurisdiction,
ClientName: deps.ClientName,
ClientShortName: deps.ClientShortName,
AIAdvisorEnabled: deps.ClaudeAPIKey != "",
}
// OAuth login routes (public)
@@ -158,6 +160,9 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
router.Get("/api/digest-content/{handle}/*", (&uihandlers.DigestContentHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/api/upgrade-banner/{handle}/*", (&uihandlers.UpgradeBannerHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/api/image-advisor/{handle}/*", middleware.RequireAuth(deps.SessionStore, deps.Database)(
&uihandlers.ImageAdvisorHandler{BaseUIHandler: base, ClaudeAPIKey: deps.ClaudeAPIKey},
).ServeHTTP)
// Diff page: /diff/{handle}/{repo}?from=...&to=...
router.Get("/diff/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
@@ -177,6 +182,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/profile/oci-client", (&uihandlers.UpdateOciClientHandler{BaseUIHandler: base}).ServeHTTP)
r.Post("/api/profile/ai-advisor", (&uihandlers.UpdateAIAdvisorHandler{BaseUIHandler: base}).ServeHTTP)
// Subscription management
r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP)

View File

@@ -144,6 +144,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
})
slog.Info("Configuration loaded successfully from environment")
if cfg.AI.APIKey != "" {
slog.Info("AI Image Advisor enabled")
}
s := &AppViewServer{
Config: cfg,
@@ -330,6 +333,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
ClientShortName: cfg.Server.ClientShortName,
BillingManager: s.BillingManager,
WebhookDispatcher: s.WebhookDispatcher,
ClaudeAPIKey: cfg.AI.APIKey,
LegalConfig: routes.LegalConfig{
CompanyName: cfg.Legal.CompanyName,
Jurisdiction: cfg.Legal.Jurisdiction,

View File

@@ -0,0 +1,80 @@
{{ define "pull-command-switcher" }}
{{/*
Pull command with inline OCI client switcher.
Expects dict with: RegistryURL, OwnerHandle, RepoName, Tag, ArtifactType, OciClient, IsLoggedIn
For helm charts, shows helm command only (no switcher).
For container images, shows a client dropdown that updates the command.
Logged-in users: saves to profile via HTMX POST.
Anonymous users: saves to localStorage.
*/}}
{{ if eq .ArtifactType "helm-chart" }}
<div class="space-y-2">
<p class="text-sm font-medium text-base-content/70">Pull this chart</p>
{{ if .Tag }}
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Tag) }}
{{ else }}
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName) }}
{{ end }}
</div>
{{ else }}
<div class="space-y-2" id="pull-cmd-container">
<p class="text-sm font-medium text-base-content/70">Pull this image</p>
<div class="flex items-center gap-2">
<select id="oci-client-switcher" class="select select-xs select-bordered w-auto"
onchange="updatePullCommand(this.value)">
<option value="docker"{{ if or (eq .OciClient "") (eq .OciClient "docker") }} selected{{ end }}>docker</option>
<option value="podman"{{ if eq .OciClient "podman" }} selected{{ end }}>podman</option>
<option value="nerdctl"{{ if eq .OciClient "nerdctl" }} selected{{ end }}>nerdctl</option>
<option value="buildah"{{ if eq .OciClient "buildah" }} selected{{ end }}>buildah</option>
<option value="crane"{{ if eq .OciClient "crane" }} selected{{ end }}>crane</option>
</select>
<div id="pull-cmd-display" class="flex-1 min-w-0">
{{ if .Tag }}
{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Tag) }}
{{ else }}
{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":latest") }}
{{ end }}
</div>
</div>
</div>
<script>
(function() {
var registryURL = {{ .RegistryURL }};
var ownerHandle = {{ .OwnerHandle }};
var repoName = {{ .RepoName }};
var tag = {{ if .Tag }}{{ .Tag }}{{ else }}"latest"{{ end }};
var isLoggedIn = {{ .IsLoggedIn }};
// Restore from localStorage for anonymous users
if (!isLoggedIn) {
var saved = localStorage.getItem('oci-client');
if (saved) {
var sel = document.getElementById('oci-client-switcher');
if (sel) {
sel.value = saved;
updatePullCommand(saved);
}
}
}
window.updatePullCommand = function(client) {
var cmd = client + ' pull ' + registryURL + '/' + ownerHandle + '/' + repoName + ':' + tag;
var container = document.getElementById('pull-cmd-display');
if (!container) return;
var code = container.querySelector('code');
if (code) code.textContent = cmd;
var btn = container.querySelector('[data-cmd]');
if (btn) btn.dataset.cmd = cmd;
// Persist preference
if (isLoggedIn) {
htmx.ajax('POST', '/api/profile/oci-client', {values: {oci_client: client}, swap: 'none'});
} else {
localStorage.setItem('oci-client', client);
}
};
})();
</script>
{{ end }}
{{ end }}

View File

@@ -72,6 +72,8 @@
htmx.ajax('GET', '/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest=' + encodeURIComponent(digest), {target: target, swap: 'innerHTML'}).then(function() {
loading.classList.add('hidden');
});
}
</script>
{{ end }}

View File

@@ -9,9 +9,9 @@
{{ template "nav" . }}
<main class="container mx-auto px-4 py-8">
<div class="space-y-8">
<!-- Repository Header -->
<div class="card bg-base-100 shadow-sm p-6 space-y-6 w-full">
<div class="space-y-6">
<!-- Static Header: Identity + Metadata (does not change with tag) -->
<div class="card bg-base-100 shadow-sm p-6 space-y-4 w-full">
<div class="flex gap-4 items-start">
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
<div class="flex-1 min-w-0">
@@ -26,16 +26,25 @@
</div>
</div>
<!-- Star Button, Pull Count and Metadata Row -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }}
{{ template "pull-count" (dict "PullCount" .PullCount) }}
<!-- Metadata Row -->
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-3">
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .Stats.StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }}
{{ template "pull-count" (dict "PullCount" .Stats.PullCount) }}
{{ if .TagCount }}
<span class="flex items-center gap-1 text-sm text-base-content/70" title="{{ .TagCount }} tags">
{{ icon "tag" "size-4" }} {{ .TagCount }}
</span>
{{ end }}
{{ if .Stats.LastPush }}
<span class="text-sm text-base-content/50" title="Last pushed {{ (derefTime .Stats.LastPush).Format "2006-01-02T15:04:05Z07:00" }}">
Updated {{ timeAgoShort (derefTime .Stats.LastPush) }}
</span>
{{ end }}
</div>
<!-- Metadata Section -->
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2 ml-auto">
{{ if .Repository.Version }}
<span class="badge badge-md badge-primary badge-outline" title="Version">
{{ .Repository.Version }}
@@ -55,309 +64,134 @@
{{ end }}
{{ end }}
{{ if .Repository.SourceURL }}
<a href="{{ .Repository.SourceURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View source code (opens in new tab)">
Source
<a href="{{ .Repository.SourceURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm flex items-center gap-1" aria-label="View source code (opens in new tab)">
{{ icon "external-link" "size-3" }} Source
</a>
{{ end }}
{{ if .Repository.DocumentationURL }}
<a href="{{ .Repository.DocumentationURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View documentation (opens in new tab)">
Documentation
<a href="{{ .Repository.DocumentationURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm flex items-center gap-1" aria-label="View documentation (opens in new tab)">
{{ icon "book-open" "size-3" }} Docs
</a>
{{ end }}
</div>
{{ end }}
</div>
</div>
<div class="divider my-2"></div>
<!-- Pull Command -->
<div class="space-y-2">
{{ if eq .ArtifactType "helm-chart" }}
<p class="font-semibold">Pull this chart</p>
{{ 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 .LatestTag }}
{{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }}
{{ else }}
{{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
{{ end }}
{{ end }}
<!-- Tag Selector (stays in DOM, never swapped) -->
{{ if .SelectedTag }}
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
{{ icon "tag" "size-4 text-base-content/60" }}
<select id="tag-selector" class="select select-sm select-bordered font-mono"
hx-get="/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"
hx-target="#tag-content"
hx-swap="outerHTML"
hx-push-url="true"
hx-include="this"
name="tag">
{{ range .AllTags }}
<option value="{{ . }}"{{ if eq . $.SelectedTag.Info.Tag.Tag }} selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
</div>
</div>
<!-- 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 cursor-pointer"
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 cursor-pointer"
data-tab="artifacts"
role="tab"
id="artifacts-tab-btn"
onclick="switchRepoTab('artifacts')">
Artifacts
</button>
</nav>
</div>
<!-- Tab Panels -->
<!-- Overview Panel -->
<div id="tab-overview" class="repo-panel">
<!-- View mode -->
<div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
{{ if .IsOwner }}
<div class="flex justify-end">
<button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)">
{{ icon "pencil" "size-4" }}
Edit
</button>
</div>
{{ end }}
<div id="overview-rendered" class="prose prose-sm max-w-none">
{{ if .ReadmeHTML }}
{{ .ReadmeHTML }}
{{ else }}
<p class="text-base-content/60">No description available</p>
{{ if .SelectedTag.Info.IsMultiArch }}
<div id="platform-badges" class="flex flex-wrap items-center gap-1">
{{ range .SelectedTag.Info.Platforms }}
<span class="badge badge-sm badge-outline font-mono">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ end }}
</div>
</div>
<!-- Edit mode (hidden, owner only) -->
{{ if .IsOwner }}
<div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden">
<!-- Write/Preview tabs -->
<div class="border-b border-base-300 mb-4">
<nav class="flex gap-0" role="tablist">
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
data-tab="write" onclick="switchEditorTab('write')">
Write
</button>
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60"
data-tab="preview" onclick="switchEditorTab('preview')">
Preview
</button>
</nav>
{{ else }}
{{ $p := index .SelectedTag.Info.Platforms 0 }}
{{ if $p.OS }}
<div id="platform-badges">
<span class="badge badge-sm badge-outline font-mono">{{ $p.OS }}/{{ $p.Architecture }}{{ if $p.Variant }}/{{ $p.Variant }}{{ end }}</span>
</div>
<!-- Write panel -->
<div id="editor-write" class="editor-panel">
<!-- Toolbar -->
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading">
{{ icon "heading" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold">
{{ icon "bold" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic">
{{ icon "italic" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link">
{{ icon "link" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image">
{{ icon "image" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list">
{{ icon "list" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list">
{{ icon "list-ordered" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code">
{{ icon "code" "size-4" }}
</button>
</div>
<textarea id="md-editor"
class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed"
rows="20"
placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea>
</div>
<!-- Preview panel -->
<div id="editor-preview" class="editor-panel hidden">
<div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg">
<p class="text-base-content/60">Nothing to preview</p>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 mt-4">
<button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button>
<button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button>
</div>
</div>
<script>
(function() {
var textarea = document.getElementById('md-editor');
if (!textarea) return;
var ownerDID = {{ .Owner.DID }};
var repoName = {{ .Repository.Name }};
window.toggleOverviewEditor = function(show) {
document.getElementById('overview-view').classList.toggle('hidden', show);
document.getElementById('overview-edit').classList.toggle('hidden', !show);
if (show) textarea.focus();
};
window.switchEditorTab = function(tab) {
document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); });
document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden');
document.querySelectorAll('.editor-tab').forEach(function(t) {
var active = t.dataset.tab === tab;
t.classList.toggle('border-primary', active);
t.classList.toggle('text-primary', active);
t.classList.toggle('border-transparent', !active);
t.classList.toggle('text-base-content/60', !active);
});
if (tab === 'preview') {
var content = textarea.value;
var previewEl = document.getElementById('preview-content');
if (!content.trim()) {
previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>';
return;
}
var form = new FormData();
form.append('markdown', content);
fetch('/api/repo-page/preview', { method: 'POST', body: form })
.then(function(r) { return r.text(); })
.then(function(html) { previewEl.innerHTML = html; });
}
};
window.insertMd = function(type) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.substring(start, end);
var before = textarea.value.substring(0, start);
var after = textarea.value.substring(end);
var insert, cursorStart, cursorEnd;
switch (type) {
case 'heading':
insert = '## ' + (selected || 'Heading');
cursorStart = start + 3;
cursorEnd = start + insert.length;
break;
case 'bold':
insert = '**' + (selected || 'bold text') + '**';
cursorStart = start + 2;
cursorEnd = start + insert.length - 2;
break;
case 'italic':
insert = '_' + (selected || 'italic text') + '_';
cursorStart = start + 1;
cursorEnd = start + insert.length - 1;
break;
case 'link':
insert = '[' + (selected || 'link text') + '](url)';
cursorStart = start + insert.length - 4;
cursorEnd = start + insert.length - 1;
break;
case 'image':
insert = '![' + (selected || 'alt text') + '](url)';
cursorStart = start + insert.length - 4;
cursorEnd = start + insert.length - 1;
break;
case 'ul':
insert = '- ' + (selected || 'list item');
cursorStart = start + 2;
cursorEnd = start + insert.length;
break;
case 'ol':
insert = '1. ' + (selected || 'list item');
cursorStart = start + 3;
cursorEnd = start + insert.length;
break;
case 'code':
if (selected && selected.indexOf('\n') !== -1) {
insert = '```\n' + selected + '\n```';
cursorStart = start + 4;
cursorEnd = start + 4 + selected.length;
} else {
insert = '`' + (selected || 'code') + '`';
cursorStart = start + 1;
cursorEnd = start + insert.length - 1;
}
break;
default:
return;
}
textarea.value = before + insert + after;
textarea.focus();
textarea.selectionStart = cursorStart;
textarea.selectionEnd = cursorEnd;
};
window.saveOverview = function() {
var btn = document.getElementById('save-overview-btn');
btn.classList.add('btn-disabled');
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...';
var form = new FormData();
form.append('did', ownerDID);
form.append('repository', repoName);
form.append('description', textarea.value);
fetch('/api/repo-page', {
method: 'POST',
body: form,
headers: { 'HX-Request': 'true' }
})
.then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.text();
})
.then(function(html) {
document.getElementById('overview-rendered').innerHTML = html;
toggleOverviewEditor(false);
if (typeof showToast === 'function') showToast('Overview saved', 'success');
})
.catch(function(err) {
if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error');
})
.finally(function() {
btn.classList.remove('btn-disabled');
btn.innerHTML = 'Save';
});
};
// Ctrl+S / Cmd+S to save
textarea.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveOverview();
}
});
})();
</script>
{{ end }}
{{ end }}
{{ if .SelectedTag.Info.HasAttestations }}
<button class="badge badge-sm badge-soft badge-success cursor-pointer hover:opacity-80"
hx-get="/api/attestation-details?digest={{ .SelectedTag.Info.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" }} Attested
</button>
{{ end }}
</div>
{{ end }}
<!-- Tags Panel -->
<div id="tab-artifacts" class="repo-panel hidden">
<div id="tags-content">
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
<!-- Tag-Scoped Content (swapped via HTMX on tag change) -->
{{ template "repo-tag-section" . }}
<!-- Inline Editor (hidden, owner only) — outside HTMX-swapped section so edits survive tag changes -->
{{ if .IsOwner }}
<div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden">
<!-- Write/Preview tabs -->
<div class="border-b border-base-300 mb-4">
<nav class="flex gap-0" role="tablist">
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
data-tab="write" onclick="switchEditorTab('write')">
Write
</button>
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60"
data-tab="preview" onclick="switchEditorTab('preview')">
Preview
</button>
</nav>
</div>
<!-- Write panel -->
<div id="editor-write" class="editor-panel">
<!-- Toolbar -->
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading">
{{ icon "heading" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold">
{{ icon "bold" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic">
{{ icon "italic" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link">
{{ icon "link" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image">
{{ icon "image" "size-4" }}
</button>
<div class="divider divider-horizontal mx-0"></div>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list">
{{ icon "list" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list">
{{ icon "list-ordered" "size-4" }}
</button>
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code">
{{ icon "code" "size-4" }}
</button>
</div>
<textarea id="md-editor"
class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed"
rows="20"
placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea>
</div>
<!-- Preview panel -->
<div id="editor-preview" class="editor-panel hidden">
<div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg">
<p class="text-base-content/60">Nothing to preview</p>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 mt-4">
<button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button>
<button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button>
</div>
</div>
{{ end }}
</div>
</main>
@@ -412,73 +246,292 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{{ if .IsOwner }}
<script>
(function() {
var validTabs = ['overview', 'artifacts'];
var tagsLoading = false;
var textarea = document.getElementById('md-editor');
if (!textarea) return;
function loadTags() {
if (tagsLoading) return;
tagsLoading = true;
var target = document.getElementById('tags-content');
fetch('/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}')
var ownerDID = {{ .Owner.DID }};
var repoName = {{ .Repository.Name }};
window.toggleOverviewEditor = function(show) {
document.getElementById('overview-view').classList.toggle('hidden', show);
document.getElementById('overview-edit').classList.toggle('hidden', !show);
if (show) textarea.focus();
};
window.switchEditorTab = function(tab) {
document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); });
document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden');
document.querySelectorAll('.editor-tab').forEach(function(t) {
var active = t.dataset.tab === tab;
t.classList.toggle('border-primary', active);
t.classList.toggle('text-primary', active);
t.classList.toggle('border-transparent', !active);
t.classList.toggle('text-base-content/60', !active);
});
if (tab === 'preview') {
var content = textarea.value;
var previewEl = document.getElementById('preview-content');
if (!content.trim()) {
previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>';
return;
}
var form = new FormData();
form.append('markdown', content);
fetch('/api/repo-page/preview', { method: 'POST', body: form })
.then(function(r) { return r.text(); })
.then(function(html) { previewEl.innerHTML = html; });
}
};
window.insertMd = function(type) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.substring(start, end);
var before = textarea.value.substring(0, start);
var after = textarea.value.substring(end);
var insert, cursorStart, cursorEnd;
switch (type) {
case 'heading':
insert = '## ' + (selected || 'Heading');
cursorStart = start + 3;
cursorEnd = start + insert.length;
break;
case 'bold':
insert = '**' + (selected || 'bold text') + '**';
cursorStart = start + 2;
cursorEnd = start + insert.length - 2;
break;
case 'italic':
insert = '_' + (selected || 'italic text') + '_';
cursorStart = start + 1;
cursorEnd = start + insert.length - 1;
break;
case 'link':
insert = '[' + (selected || 'link text') + '](url)';
cursorStart = start + insert.length - 4;
cursorEnd = start + insert.length - 1;
break;
case 'image':
insert = '![' + (selected || 'alt text') + '](url)';
cursorStart = start + insert.length - 4;
cursorEnd = start + insert.length - 1;
break;
case 'ul':
insert = '- ' + (selected || 'list item');
cursorStart = start + 2;
cursorEnd = start + insert.length;
break;
case 'ol':
insert = '1. ' + (selected || 'list item');
cursorStart = start + 3;
cursorEnd = start + insert.length;
break;
case 'code':
if (selected && selected.indexOf('\n') !== -1) {
insert = '```\n' + selected + '\n```';
cursorStart = start + 4;
cursorEnd = start + 4 + selected.length;
} else {
insert = '`' + (selected || 'code') + '`';
cursorStart = start + 1;
cursorEnd = start + insert.length - 1;
}
break;
default:
return;
}
textarea.value = before + insert + after;
textarea.focus();
textarea.selectionStart = cursorStart;
textarea.selectionEnd = cursorEnd;
};
window.saveOverview = function() {
var btn = document.getElementById('save-overview-btn');
btn.classList.add('btn-disabled');
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...';
var form = new FormData();
form.append('did', ownerDID);
form.append('repository', repoName);
form.append('description', textarea.value);
fetch('/api/repo-page', {
method: 'POST',
body: form,
headers: { 'HX-Request': 'true' }
})
.then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.text();
})
.then(function(html) {
document.getElementById('overview-rendered').innerHTML = html;
toggleOverviewEditor(false);
if (typeof showToast === 'function') showToast('Overview saved', 'success');
})
.catch(function(err) {
if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error');
})
.finally(function() {
btn.classList.remove('btn-disabled');
btn.innerHTML = 'Save';
});
};
// Ctrl+S / Cmd+S to save
textarea.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveOverview();
}
});
})();
</script>
{{ end }}
<script>
// Global helpers (tags sort/filter)
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';
});
};
// Tab controller — reads config from #tag-content data attributes.
// Re-runs on initial load and after every HTMX swap of #tag-content.
(function() {
var validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts'];
var loaded = {};
function lazyLoad(id, url) {
if (loaded[id]) return;
loaded[id] = true;
var target = document.getElementById(id);
if (!target) return;
fetch(url)
.then(function(r) { return r.text(); })
.then(function(html) {
target.innerHTML = html;
htmx.process(target);
// innerHTML doesn't execute <script> tags — re-create them
target.querySelectorAll('script').forEach(function(old) {
var s = document.createElement('script');
s.textContent = old.textContent;
old.parentNode.replaceChild(s, old);
});
if (typeof htmx !== 'undefined') htmx.process(target);
});
}
function contentUrl(section) {
var el = document.getElementById('tag-content');
if (!el) return null;
var owner = el.dataset.owner;
var repo = el.dataset.repo;
var digest = el.dataset.digest;
if (!digest) return null;
return '/api/digest-content/' + owner + '/' + repo + '?digest=' + encodeURIComponent(digest) + '&section=' + section;
}
function tagsUrl() {
var el = document.getElementById('tag-content');
if (!el) return null;
return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo;
}
window.switchRepoTab = function(tabId) {
document.querySelectorAll('.repo-panel').forEach(function(p) {
window._activeRepoTab = tabId;
var section = document.getElementById('tag-content');
if (!section) return;
section.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) {
section.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');
tab.classList.remove('border-transparent', 'text-base-content/60');
} else {
tab.classList.remove('border-primary', 'text-primary');
tab.classList.add('border-transparent', 'text-base-content/60', 'hover:text-base-content');
tab.classList.add('border-transparent', 'text-base-content/60');
}
});
history.replaceState(null, '', '#' + tabId);
if (tabId === 'artifacts') loadTags();
var url = new URL(window.location);
url.hash = tabId;
history.replaceState(null, '', url.toString());
if (tabId === 'artifacts') { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }
if (tabId === 'layers') { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }
if (tabId === 'vulns') { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }
if (tabId === 'sbom') { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }
};
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); });
};
function initTabs() {
// Reset lazy-load tracking (new tag = new content)
loaded = {};
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
var tagsBtn = document.getElementById('artifacts-tab-btn');
if (tagsBtn) tagsBtn.addEventListener('mouseenter', function() { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }, { once: true });
var layersBtn = document.getElementById('layers-tab-btn');
if (layersBtn) layersBtn.addEventListener('mouseenter', function() { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }, { once: true });
var vulnsBtn = document.getElementById('vulns-tab-btn');
if (vulnsBtn) vulnsBtn.addEventListener('mouseenter', function() { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }, { once: true });
var sbomBtn = document.getElementById('sbom-tab-btn');
if (sbomBtn) sbomBtn.addEventListener('mouseenter', function() { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }, { once: true });
// Prefetch on hover
document.getElementById('artifacts-tab-btn').addEventListener('mouseenter', loadTags, { once: true });
// Pick tab: persisted > hash > default
var initTab = window._activeRepoTab || window.location.hash.replace('#', '') || 'overview';
if (validTabs.indexOf(initTab) === -1) initTab = 'overview';
switchRepoTab(initTab);
}
// Initialize tab from hash
var hash = window.location.hash.replace('#', '') || 'overview';
if (validTabs.indexOf(hash) === -1) hash = 'overview';
switchRepoTab(hash);
// Run on initial page load
initTabs();
// Keyboard shortcuts: first letter of each tab name
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' ||
e.target.tagName === 'SELECT' || e.target.isContentEditable) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
var map = { o: 'overview', l: 'layers', v: 'vulns', s: 'sbom', a: 'artifacts' };
var tab = map[e.key.toLowerCase()];
if (tab && validTabs.indexOf(tab) !== -1) switchRepoTab(tab);
});
// Re-run after HTMX swaps tag section content (tag dropdown change)
document.body.addEventListener('htmx:afterSettle', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'tag-content') {
initTabs();
}
});
})();
</script>

View File

@@ -22,6 +22,9 @@
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user">
{{ icon "user" "size-4" }} User
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="billing">
{{ icon "credit-card" "size-4" }} Billing
</button>
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
{{ icon "hard-drive" "size-4" }} Storage
</button>
@@ -41,6 +44,7 @@
<aside class="hidden lg:block w-56 shrink-0">
<ul class="menu bg-base-200 rounded-box w-full">
<li data-tab="user"><a href="#user">{{ icon "user" "size-4" }} User</a></li>
<li data-tab="billing"><a href="#billing">{{ icon "credit-card" "size-4" }} Billing</a></li>
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
<li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li>
@@ -79,14 +83,39 @@
<option value="crane"{{ if eq $oci "crane" }} selected{{ end }}>crane</option>
</select>
</div>
<!-- AI Image Advisor Toggle -->
{{ if .AIAdvisorEnabled }}
<div class="divider my-2"></div>
<div class="flex items-start gap-3">
{{ if .Profile.HasAIAdvisorAccess }}
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/ai-advisor"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AIAdvisorEnabled }}checked{{ end }}>
<div>
<span class="font-medium">AI Image Advisor</span>
<p class="text-xs text-base-content/60">Analyze your container images for optimization suggestions using AI.</p>
</div>
</label>
{{ else }}
<div>
<span class="font-medium text-base-content/50">AI Image Advisor</span>
<p class="text-xs text-base-content/50">Analyze your container images for optimization suggestions using AI.</p>
<p class="text-xs text-primary mt-1">
<a href="/settings#billing" onclick="switchSettingsTab('billing')">Upgrade your plan</a> to enable this feature.
</p>
</div>
{{ end }}
</div>
{{ end }}
</section>
</div>
<!-- STORAGE TAB -->
<div id="tab-storage" class="settings-panel hidden space-y-4">
<!-- Available Plans -->
{{ template "subscription_plans" .Subscription }}
<!-- Holds -->
{{ if .AllHolds }}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@@ -132,6 +161,11 @@
</section>
</div>
<!-- BILLING TAB -->
<div id="tab-billing" class="settings-panel hidden space-y-4">
{{ template "subscription_plans" .Subscription }}
</div>
<!-- DEVICES TAB -->
<div id="tab-devices" class="settings-panel hidden space-y-6">
<section class="card bg-base-100 shadow-sm p-6 space-y-6">

View File

@@ -37,7 +37,7 @@
</div>
<script>
(function() {
var showEmpty = localStorage.getItem('showEmptyLayers') !== 'false';
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; });
window.toggleEmptyLayers = function(show) {
@@ -105,7 +105,7 @@
}
function applyLayerVisibility() {
var show = localStorage.getItem('showEmptyLayers') !== 'false';
var show = localStorage.getItem('showEmptyLayers') === 'true';
document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) {
row.style.display = show ? '' : 'none';
});

View File

@@ -0,0 +1,75 @@
{{ define "image-advisor-results" }}
{{ if eq .Error "upgrade_required" }}
<div class="alert alert-info text-sm">
{{ icon "sparkles" "size-4" }}
<span>AI Image Advisor is a paid feature. <a href="/settings#billing" class="link link-primary font-medium">Upgrade your plan</a> to unlock image analysis.</span>
</div>
{{ else if .Error }}
<div class="alert alert-warning text-sm">
{{ icon "alert-triangle" "size-4" }}
<span>{{ .Error }}</span>
</div>
{{ else if .Suggestions }}
<div class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body p-4 space-y-3">
<h3 class="text-sm font-semibold flex items-center gap-2">
{{ icon "sparkle" "size-4" }}
AI Suggestions ({{ len .Suggestions }})
</h3>
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead>
<tr>
<th>Action</th>
<th>Category</th>
<th>Impact</th>
<th>Effort</th>
<th class="w-1/2">Detail</th>
</tr>
</thead>
<tbody>
{{ range .Suggestions }}
<tr>
<td class="font-medium text-sm">{{ .Action }}</td>
<td>
<span class="badge badge-sm badge-ghost whitespace-nowrap">{{ .Category }}</span>
</td>
<td>
{{ if eq .Impact "high" }}
<span class="badge badge-sm badge-error">high</span>
{{ else if eq .Impact "medium" }}
<span class="badge badge-sm badge-warning">medium</span>
{{ else }}
<span class="badge badge-sm badge-info">low</span>
{{ end }}
</td>
<td>
{{ if eq .Effort "low" }}
<span class="badge badge-sm badge-success">low</span>
{{ else if eq .Effort "medium" }}
<span class="badge badge-sm badge-warning">medium</span>
{{ else }}
<span class="badge badge-sm badge-ghost">high</span>
{{ end }}
</td>
<td class="text-xs max-w-xs">
{{ .Detail }}
{{ if gt .CVEsFixed 0 }}
<span class="badge badge-xs badge-outline badge-error ml-1">{{ .CVEsFixed }} CVEs</span>
{{ end }}
{{ if gt .SizeSavedMB 0 }}
<span class="badge badge-xs badge-outline badge-info ml-1">-{{ .SizeSavedMB }}MB</span>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<p class="text-xs opacity-50">Generated by Claude Haiku. Suggestions are advisory only.</p>
</div>
</div>
{{ else }}
<p class="text-sm opacity-60">No suggestions generated.</p>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,119 @@
{{ define "layers-section" }}
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" class="checkbox checkbox-xs show-empty-layers-cb" onchange="toggleEmptyLayers(this.checked)">
<span>Show empty layers</span>
</label>
</div>
{{ if .Layers }}
<div class="overflow-x-auto">
<table class="table table-xs w-full layers-table">
<thead>
<tr class="text-xs">
<th class="w-8">#</th>
<th>Command</th>
<th class="text-right w-24">Size</th>
</tr>
</thead>
<tbody>
{{ range .Layers }}
<tr data-empty="{{ .EmptyLayer }}" data-no-command="{{ and (not .Command) (not .EmptyLayer) }}">
<td class="font-mono text-xs">{{ .Index }}</td>
<td>
{{ if .Command }}
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
{{ end }}
</td>
<td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<script>
(function() {
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; });
window.toggleEmptyLayers = function(show) {
localStorage.setItem('showEmptyLayers', show);
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = show; });
applyLayerVisibility();
};
function collapseNoHistoryLayers() {
document.querySelectorAll('.layers-table').forEach(function(table) {
var tbody = table.querySelector('tbody');
if (!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr'));
var i = 0;
while (i < rows.length) {
if (rows[i].dataset.noCommand === 'true') {
var start = i;
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
rows[i].classList.add('no-history-row', 'hidden');
i++;
}
var count = i - start;
if (count > 1) {
var totalBytes = 0;
for (var k = start; k < start + count; k++) {
var sizeCell = rows[k].querySelector('td:last-child');
if (sizeCell) {
var txt = sizeCell.textContent.trim();
var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
if (match) {
var val = parseFloat(match[1]);
var unit = match[2].toUpperCase();
var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776};
totalBytes += val * (multipliers[unit] || 1);
}
}
}
var sizeStr = '';
if (totalBytes < 1024) sizeStr = totalBytes + ' B';
else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB';
else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB';
else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB';
var startIdx = rows[start].querySelector('td').textContent.trim();
var endIdx = rows[i - 1].querySelector('td').textContent.trim();
var summary = document.createElement('tr');
summary.className = 'no-history-summary cursor-pointer hover:bg-base-200';
summary.innerHTML = '<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx + ' contain no history <span class="text-xs ml-2">(' + count + ' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>';
summary.onclick = function() {
summary.remove();
for (var j = start; j < start + count; j++) {
rows[j].classList.remove('hidden');
}
};
tbody.insertBefore(summary, rows[start]);
} else {
rows[start].classList.remove('hidden');
}
} else {
i++;
}
}
});
}
function applyLayerVisibility() {
var show = localStorage.getItem('showEmptyLayers') === 'true';
document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) {
row.style.display = show ? '' : 'none';
});
}
collapseNoHistoryLayers();
applyLayerVisibility();
})();
</script>
{{ else }}
<p class="text-base-content">No layer information available</p>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,210 @@
{{ define "repo-tag-section" }}
<div id="tag-content" data-owner="{{ .Owner.Handle }}" data-repo="{{ .Repository.Name }}"{{ if .SelectedTag }} data-digest="{{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"{{ end }}>
{{ if .SelectedTag }}
<!-- Pull Command with Client Switcher -->
{{ template "pull-command-switcher" (dict "RegistryURL" .RegistryURL "OwnerHandle" .Owner.Handle "RepoName" .Repository.Name "Tag" .SelectedTag.Info.Tag.Tag "ArtifactType" .ArtifactType "OciClient" .OciClient "IsLoggedIn" (ne .User nil)) }}
<!-- Stats Cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<!-- Size & Layers -->
<div class="card bg-base-100 border border-base-300 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Image Size</span>
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Layers</span>
</div>
<div class="flex items-center justify-between">
<span class="text-lg font-bold">{{ humanizeBytes .SelectedTag.CompressedSize }}</span>
<span class="text-lg font-bold">{{ .SelectedTag.LayerCount }}</span>
</div>
<div class="text-xs text-base-content/50 mt-1">
Pushed {{ timeAgoShort .SelectedTag.Info.CreatedAt }}
</div>
</div>
<!-- Vulnerabilities (first platform only for summary) -->
<div class="card bg-base-100 border border-base-300 p-4">
<div class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Vulnerabilities</div>
<div id="vuln-summary-card">
{{ $firstPlatform := index .SelectedTag.Info.Platforms 0 }}
<span id="scan-badge-{{ trimPrefix "sha256:" $firstPlatform.Digest }}"></span>
<span id="vuln-loading-text" class="text-sm text-base-content/40">Loading...</span>
</div>
</div>
<!-- Pulls -->
<div class="card bg-base-100 border border-base-300 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Pulls</span>
</div>
<div class="text-lg font-bold">{{ .Stats.PullCount }} <span class="text-sm font-normal text-base-content/50">total</span></div>
<div class="text-xs text-base-content/50 mt-1">
{{ if .Stats.LastPull }}Last pull {{ timeAgoShort (derefTime .Stats.LastPull) }}{{ else }}No pulls yet{{ end }}
</div>
</div>
</div>
<!-- Scan batch triggers for selected tag -->
{{ range .SelectedTag.ScanBatchParams }}
<div hx-get="/api/scan-results?{{ . }}"
hx-trigger="load delay:500ms"
hx-swap="none"
hx-on::after-request="var el=document.getElementById('vuln-loading-text');if(el)el.remove()"
style="display:none"></div>
{{ end }}
{{ else }}
<!-- No tags exist -->
<div class="text-center py-8 text-base-content/60">
<p class="text-lg">No tags yet</p>
<p class="text-sm mt-1">Push an image to get started.</p>
</div>
{{ end }}
<!-- Tab Navigation -->
<div class="border-b border-base-300 mt-6">
<nav class="flex gap-0" role="tablist">
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="overview"
role="tab"
onclick="switchRepoTab('overview')">
Overview
</button>
{{ if .SelectedTag }}
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="layers"
role="tab"
id="layers-tab-btn"
onclick="switchRepoTab('layers')">
Layers
</button>
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="vulns"
role="tab"
id="vulns-tab-btn"
onclick="switchRepoTab('vulns')">
Vulnerabilities
</button>
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="sbom"
role="tab"
id="sbom-tab-btn"
onclick="switchRepoTab('sbom')">
SBOM
</button>
{{ end }}
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
data-tab="artifacts"
role="tab"
id="artifacts-tab-btn"
onclick="switchRepoTab('artifacts')">
Artifacts
</button>
{{ if and .SelectedTag (gt (len .AllTags) 1) }}
<div class="ml-auto flex items-center">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm gap-1">
{{ icon "git-compare" "size-4" }} Diff
</label>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-10 w-56 p-2 shadow max-h-60 overflow-y-auto">
{{ range .AllTags }}
{{ if ne . $.SelectedTag.Info.Tag.Tag }}
<li><a href="/diff/{{ $.Owner.Handle }}/{{ $.Repository.Name }}?from={{ $.SelectedTag.Info.Digest }}&to={{ . }}">{{ . }}</a></li>
{{ end }}
{{ end }}
</ul>
</div>
</div>
{{ end }}
</nav>
</div>
<!-- Tab Panels -->
<div id="tab-overview" class="repo-panel">
<div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
{{ if and .AIAdvisorEnabled .User .IsOwner .SelectedTag }}
<div id="ai-advisor-section">
<button id="ai-advisor-btn" class="btn btn-sm btn-outline gap-1"
hx-get="/api/image-advisor/{{ .Owner.Handle }}/{{ .Repository.Name }}?digest={{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"
hx-target="#ai-advisor-results"
hx-swap="innerHTML"
hx-indicator="#ai-advisor-spinner"
hx-on::after-request="this.disabled=true">
{{ icon "sparkles" "size-4" }}
Analyze Image
</button>
<span id="ai-advisor-spinner" class="htmx-indicator">
{{ icon "loader" "size-4 animate-spin" }}
</span>
<div id="ai-advisor-results" class="mt-4"></div>
</div>
{{ end }}
{{ if and .IsOwner .ReadmeHTML }}
<div class="flex justify-end">
<button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)">
{{ icon "pencil" "size-4" }}
Edit
</button>
</div>
{{ end }}
<div id="overview-rendered" class="prose prose-sm max-w-none">
{{ if .ReadmeHTML }}
{{ .ReadmeHTML }}
{{ else }}
{{ if .IsOwner }}
<div class="text-center py-12">
{{ icon "file-text" "size-12 text-base-content/20 mx-auto" }}
<p class="text-base-content/60 mt-4">No README provided</p>
<p class="text-base-content/40 text-sm mt-1">Add a README to help users understand this image.</p>
<button class="btn btn-primary btn-sm mt-4" onclick="toggleOverviewEditor(true)">
{{ icon "pencil" "size-4" }} Add README
</button>
</div>
{{ else }}
<div class="text-center py-12">
{{ icon "file-text" "size-12 text-base-content/20 mx-auto" }}
<p class="text-base-content/60 mt-4">No README provided</p>
<p class="text-base-content/40 text-sm mt-1">Image metadata is shown above.</p>
</div>
{{ end }}
{{ end }}
</div>
</div>
</div>
{{ if .SelectedTag }}
<div id="tab-layers" class="repo-panel hidden">
<div id="layers-content">
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
<div id="tab-vulns" class="repo-panel hidden">
<div id="vulns-content">
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
<div id="tab-sbom" class="repo-panel hidden">
<div id="sbom-content">
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
{{ end }}
<div id="tab-artifacts" class="repo-panel hidden">
<div id="artifacts-content">
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,9 @@
{{ define "sbom-section" }}
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
{{ if .SbomData }}
{{ template "sbom-details" .SbomData }}
{{ else }}
<p class="text-base-content">No SBOM data available</p>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,9 @@
{{ define "vulns-section" }}
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
{{ if .VulnData }}
{{ template "vuln-details" .VulnData }}
{{ else }}
<p class="text-base-content">No vulnerability scan data available</p>
{{ end }}
</div>
{{ end }}

View File

@@ -222,6 +222,13 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
return licenses.ParseLicenses(licensesStr)
},
"derefTime": func(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
},
"sub": func(a, b int) int {
return a - b
},

View File

@@ -1843,6 +1843,338 @@ func (t *StatsRecord) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
func (t *DailyStatsRecord) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
}
cw := cbg.NewCborWriter(w)
if _, err := cw.Write([]byte{167}); err != nil {
return err
}
// t.Date (string) (string)
if len("date") > 8192 {
return xerrors.Errorf("Value in field \"date\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("date"))); err != nil {
return err
}
if _, err := cw.WriteString(string("date")); err != nil {
return err
}
if len(t.Date) > 8192 {
return xerrors.Errorf("Value in field t.Date was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Date))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Date)); 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.OwnerDID (string) (string)
if len("ownerDid") > 8192 {
return xerrors.Errorf("Value in field \"ownerDid\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("ownerDid"))); err != nil {
return err
}
if _, err := cw.WriteString(string("ownerDid")); err != nil {
return err
}
if len(t.OwnerDID) > 8192 {
return xerrors.Errorf("Value in field t.OwnerDID was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.OwnerDID))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.OwnerDID)); err != nil {
return err
}
// t.PullCount (int64) (int64)
if len("pullCount") > 8192 {
return xerrors.Errorf("Value in field \"pullCount\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullCount"))); err != nil {
return err
}
if _, err := cw.WriteString(string("pullCount")); err != nil {
return err
}
if t.PullCount >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullCount)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullCount-1)); err != nil {
return err
}
}
// t.PushCount (int64) (int64)
if len("pushCount") > 8192 {
return xerrors.Errorf("Value in field \"pushCount\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pushCount"))); err != nil {
return err
}
if _, err := cw.WriteString(string("pushCount")); err != nil {
return err
}
if t.PushCount >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PushCount)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PushCount-1)); err != nil {
return err
}
}
// t.UpdatedAt (string) (string)
if len("updatedAt") > 8192 {
return xerrors.Errorf("Value in field \"updatedAt\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updatedAt"))); err != nil {
return err
}
if _, err := cw.WriteString(string("updatedAt")); err != nil {
return err
}
if len(t.UpdatedAt) > 8192 {
return xerrors.Errorf("Value in field t.UpdatedAt was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UpdatedAt))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.UpdatedAt)); err != nil {
return err
}
// t.Repository (string) (string)
if len("repository") > 8192 {
return xerrors.Errorf("Value in field \"repository\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
return err
}
if _, err := cw.WriteString(string("repository")); err != nil {
return err
}
if len(t.Repository) > 8192 {
return xerrors.Errorf("Value in field t.Repository was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Repository)); err != nil {
return err
}
return nil
}
func (t *DailyStatsRecord) UnmarshalCBOR(r io.Reader) (err error) {
*t = DailyStatsRecord{}
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("DailyStatsRecord: 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.Date (string) (string)
case "date":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Date = string(sval)
}
// t.Type (string) (string)
case "$type":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Type = string(sval)
}
// t.OwnerDID (string) (string)
case "ownerDid":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.OwnerDID = string(sval)
}
// t.PullCount (int64) (int64)
case "pullCount":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.PullCount = int64(extraI)
}
// t.PushCount (int64) (int64)
case "pushCount":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.PushCount = int64(extraI)
}
// t.UpdatedAt (string) (string)
case "updatedAt":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.UpdatedAt = string(sval)
}
// t.Repository (string) (string)
case "repository":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Repository = 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
}
func (t *ScanRecord) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)

View File

@@ -32,6 +32,7 @@ func main() {
atproto.LayerRecord{},
atproto.TangledProfileRecord{},
atproto.StatsRecord{},
atproto.DailyStatsRecord{},
atproto.ScanRecord{},
atproto.ImageConfigRecord{},
); err != nil {

View File

@@ -44,6 +44,10 @@ const (
// Stored in hold's embedded PDS to track pull/push counts per owner+repo
StatsCollection = "io.atcr.hold.stats"
// DailyStatsCollection is the collection name for daily repository statistics
// Stored in hold's embedded PDS to track daily pull/push counts per owner+repo+date
DailyStatsCollection = "io.atcr.hold.stats.daily"
// ScanCollection is the collection name for vulnerability scan results
// Stored in hold's embedded PDS to track scan results per manifest
ScanCollection = "io.atcr.hold.scan"
@@ -353,6 +357,10 @@ type SailorProfileRecord struct {
// Defaults to "docker" if empty.
OciClient string `json:"ociClient,omitempty"`
// AIAdvisorEnabled controls whether the AI Image Advisor feature is active for this user.
// nil = default (enabled if user has billing access), false = explicitly disabled.
AIAdvisorEnabled *bool `json:"aiAdvisorEnabled,omitempty"`
// CreatedAt timestamp
CreatedAt time.Time `json:"createdAt"`
@@ -775,6 +783,42 @@ func StatsRecordKey(ownerDID, repository string) string {
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
}
// DailyStatsRecord represents daily repository statistics stored in the hold's PDS
// Collection: io.atcr.hold.stats.daily
// Stored in the hold's embedded PDS for tracking daily pull/push counts
// Uses CBOR encoding for efficient storage in hold's carstore
// RKey is deterministic: base32(sha256(ownerDID + "/" + repository + "/" + date)[:16])
type DailyStatsRecord struct {
Type string `json:"$type" cborgen:"$type"`
OwnerDID string `json:"ownerDid" cborgen:"ownerDid"` // DID of the image owner
Repository string `json:"repository" cborgen:"repository"` // Repository name
Date string `json:"date" cborgen:"date"` // YYYY-MM-DD format
PullCount int64 `json:"pullCount" cborgen:"pullCount"` // Number of manifest downloads on this date
PushCount int64 `json:"pushCount" cborgen:"pushCount"` // Number of manifest uploads on this date
UpdatedAt string `json:"updatedAt" cborgen:"updatedAt"` // RFC3339 timestamp
}
// NewDailyStatsRecord creates a new daily stats record
func NewDailyStatsRecord(ownerDID, repository, date string) *DailyStatsRecord {
return &DailyStatsRecord{
Type: DailyStatsCollection,
OwnerDID: ownerDID,
Repository: repository,
Date: date,
PullCount: 0,
PushCount: 0,
UpdatedAt: time.Now().Format(time.RFC3339),
}
}
// DailyStatsRecordKey generates a deterministic record key for daily stats
// Uses base32 encoding of first 16 bytes of SHA-256 hash of "ownerDID/repository/date"
func DailyStatsRecordKey(ownerDID, repository, date string) string {
combined := ownerDID + "/" + repository + "/" + date
hash := sha256.Sum256([]byte(combined))
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
}
// CrewRecordKey generates a deterministic rkey from member DID
// Uses same pattern as StatsRecordKey for consistency
// This enables O(1) crew membership lookups via getRecord instead of O(n) pagination

View File

@@ -139,6 +139,29 @@ func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
}
// HasAIAdvisor returns whether a user has access to the AI Image Advisor based on their subscription tier.
// Hold captains always have access.
func (m *Manager) HasAIAdvisor(userDID string) bool {
if m.isCaptain(userDID) {
return true
}
if !m.Enabled() {
return false
}
info, err := m.GetSubscriptionInfo(userDID)
if err != nil || info == nil {
return m.cfg.Tiers[0].AIAdvisor
}
rank := info.TierRank
if rank >= 0 && rank < len(m.cfg.Tiers) {
return m.cfg.Tiers[rank].AIAdvisor
}
return m.cfg.Tiers[0].AIAdvisor
}
// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription.
// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise.
// Hold captains get a "Captain" badge.
@@ -203,6 +226,7 @@ func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error)
// Dynamic features: hold-derived first, then webhook limits, then static config
features := m.aggregateHoldFeatures(i)
features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...)
features = append(features, aiAdvisorFeatures(tier.AIAdvisor)...)
if tier.SupporterBadge {
features = append(features, "Supporter badge")
}
@@ -689,6 +713,14 @@ func webhookFeatures(maxWebhooks int, allTriggers bool) []string {
return features
}
// aiAdvisorFeatures generates feature bullet strings for AI advisor access.
func aiAdvisorFeatures(enabled bool) []string {
if enabled {
return []string{"AI Image Advisor"}
}
return nil
}
// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
func formatBytes(b int64) string {
const unit = 1024

View File

@@ -36,6 +36,15 @@ func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
return 1, false
}
// HasAIAdvisor returns whether a user has access to the AI Image Advisor.
// Hold captains always have access. Default is false when billing is not compiled in.
func (m *Manager) HasAIAdvisor(userDID string) bool {
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
return true
}
return false
}
// GetSubscriptionInfo returns an error when billing is not compiled in.
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
return nil, ErrBillingDisabled

View File

@@ -51,6 +51,9 @@ type BillingTierConfig struct {
// Whether all webhook trigger types are available (not just first-scan).
WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."`
// Whether AI Image Advisor is available for this tier.
AIAdvisor bool `yaml:"ai_advisor" comment:"Enable AI Image Advisor for this tier."`
// Whether this tier earns a supporter badge on user profiles.
SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."`
}

View File

@@ -7,6 +7,7 @@
<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="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol>
<symbol id="book-open" viewBox="0 0 24 24"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></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>
@@ -18,13 +19,16 @@
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
<symbol id="cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol>
<symbol id="credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></symbol>
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol>
<symbol id="file-text" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol>
<symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
<symbol id="fingerprint" viewBox="0 0 24 24"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></symbol>
<symbol id="git-compare" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></symbol>
<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>
@@ -50,9 +54,12 @@
<symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="ship" viewBox="0 0 24 24"><path d="M12 10.189V14"/><path d="M12 2v3"/><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"/><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></symbol>
<symbol id="sparkle" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/></symbol>
<symbol id="sparkles" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></symbol>
<symbol id="star" viewBox="0 0 24 24"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></symbol>
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></symbol>
<symbol id="sun-moon" viewBox="0 0 24 24"><path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/></symbol>
<symbol id="tag" viewBox="0 0 24 24"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/></symbol>
<symbol id="terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol>
<symbol id="trash-2" viewBox="0 0 24 24"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
<symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -227,6 +227,11 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
Architecture string `json:"architecture"`
} `json:"platform"`
} `json:"manifests"`
Subject *struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"mediaType"`
} `json:"subject"`
} `json:"manifest"`
}
@@ -401,8 +406,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
}
}
// Enqueue scan job if scanner is connected (skip manifest lists — children get their own jobs)
if h.scanBroadcaster != nil && !isMultiArch {
// Enqueue scan job if scanner is connected (skip manifest lists and attestations — no scannable content)
if h.scanBroadcaster != nil && !isMultiArch && req.Manifest.Subject == nil {
tier := "deckhand"
if stats != nil && stats.Tier != "" {
tier = stats.Tier
@@ -456,6 +461,11 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
statsUpdated = true
}
// Also increment daily stats for trend tracking
if err := h.pds.IncrementDailyStats(ctx, req.UserDID, req.Repository, operation); err != nil {
slog.Warn("Failed to increment daily stats", "operation", operation, "error", err)
}
// Return response
resp := map[string]any{
"success": statsUpdated || layersCreated > 0 || postCreated,

View File

@@ -41,13 +41,16 @@ type ScanBroadcaster struct {
rescanInterval time.Duration // Minimum interval between re-scans (0 = disabled)
stopCh chan struct{} // Signal to stop background goroutines
wg sync.WaitGroup // Wait for background goroutines to finish
userIdx int // Round-robin index through DIDs for proactive scanning
predecessorCache map[string]bool // holdDID → "has this hold been migrated (has successor)?"
relayEndpoint string // Relay URL for listReposByCollection
// Relay-based manifest DID discovery
relayEndpoint string // Relay URL for listReposByCollection
manifestDIDs []string // Cached list of DIDs with manifest records
manifestDIDsMu sync.RWMutex // Protects manifestDIDs
// Work queues for proactive scanning (populated by discovery/stale goroutines)
unscannedQueue chan *scanCandidate // Medium priority: manifests with no scan record
staleQueue chan *scanCandidate // Low priority: scan records older than rescanInterval
inflight map[string]struct{} // Manifest digests currently queued or being scanned
inflightMu sync.Mutex
completionSignal chan struct{} // Signaled when a scan job completes (wakes dispatchLoop)
discoverNow chan struct{} // Signaled to trigger an early discovery pass
}
// ScanSubscriber represents a connected scanner WebSocket client
@@ -139,6 +142,11 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
stopCh: make(chan struct{}),
predecessorCache: make(map[string]bool),
relayEndpoint: relayEndpoint,
unscannedQueue: make(chan *scanCandidate, 500),
staleQueue: make(chan *scanCandidate, 200),
inflight: make(map[string]struct{}),
completionSignal: make(chan struct{}, 1),
discoverNow: make(chan struct{}, 1),
}
if err := sb.initSchema(); err != nil {
@@ -149,11 +157,12 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
sb.wg.Add(1)
go sb.reDispatchLoop()
// Start proactive scan loop if rescan interval is configured
// Start proactive scan loops if rescan interval is configured
if rescanInterval > 0 {
sb.wg.Add(2)
go sb.proactiveScanLoop()
go sb.refreshManifestDIDsLoop()
sb.wg.Add(3)
go sb.discoveryLoop()
go sb.staleScanLoop()
go sb.dispatchLoop()
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
}
@@ -181,6 +190,11 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
stopCh: make(chan struct{}),
predecessorCache: make(map[string]bool),
relayEndpoint: relayEndpoint,
unscannedQueue: make(chan *scanCandidate, 500),
staleQueue: make(chan *scanCandidate, 200),
inflight: make(map[string]struct{}),
completionSignal: make(chan struct{}, 1),
discoverNow: make(chan struct{}, 1),
}
if err := sb.initSchema(); err != nil {
@@ -190,9 +204,10 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
go sb.reDispatchLoop()
if rescanInterval > 0 {
sb.wg.Add(2)
go sb.proactiveScanLoop()
go sb.refreshManifestDIDsLoop()
sb.wg.Add(3)
go sb.discoveryLoop()
go sb.staleScanLoop()
go sb.dispatchLoop()
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
}
@@ -238,17 +253,22 @@ func (sb *ScanBroadcaster) Enqueue(job *ScanJobEvent) error {
job.HoldDID = sb.holdDID
job.HoldEndpoint = sb.holdEndpoint
// Track in-flight to prevent duplicate proactive scans
sb.addInflight(job.ManifestDigest)
// Insert into database
result, err := sb.db.Exec(`
INSERT INTO scan_jobs (manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
`, job.ManifestDigest, job.Repository, job.Tag, job.UserDID, job.UserHandle, job.HoldDID, job.HoldEndpoint, job.Tier, string(job.Config), string(job.Layers))
if err != nil {
sb.removeInflight(job.ManifestDigest)
return fmt.Errorf("failed to insert scan job: %w", err)
}
seq, err := result.LastInsertId()
if err != nil {
sb.removeInflight(job.ManifestDigest)
return fmt.Errorf("failed to get job seq: %w", err)
}
job.Seq = seq
@@ -294,6 +314,9 @@ func (sb *ScanBroadcaster) Subscribe(conn *websocket.Conn, cursor int64) *ScanSu
// Drain pending and timed-out jobs from database
go sb.drainPendingJobs(sub, cursor)
// Trigger early discovery pass so missed scans are found quickly
sb.triggerDiscovery()
return sub
}
@@ -309,10 +332,11 @@ func (sb *ScanBroadcaster) Unsubscribe(sub *ScanSubscriber) {
}
}
// Mark assigned jobs as pending again so they can be re-dispatched
// Mark assigned/processing jobs as pending again so they can be re-dispatched.
// Including 'processing' handles scanner crashes mid-scan.
_, err := sb.db.Exec(`
UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL
WHERE assigned_to = ? AND status IN ('pending', 'assigned')
WHERE assigned_to = ? AND status IN ('pending', 'assigned', 'processing')
`, sub.id)
if err != nil {
slog.Error("Failed to unassign jobs from disconnected scanner",
@@ -542,6 +566,10 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
"error", err)
}
// Remove from in-flight tracking and wake dispatch loop
sb.removeInflight(manifestDigest)
sb.signalCompletion()
slog.Info("Scan job completed",
"seq", msg.Seq,
"repository", repository,
@@ -592,6 +620,10 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage)
"error", err)
}
// Remove from in-flight tracking and wake dispatch loop
sb.removeInflight(manifestDigest)
sb.signalCompletion()
slog.Warn("Scan job failed",
"seq", msg.Seq,
"subscriberId", sub.id,
@@ -768,52 +800,89 @@ func (sb *ScanBroadcaster) ValidateScannerSecret(secret string) bool {
return sb.secret != "" && secret == sb.secret
}
// refreshManifestDIDsLoop periodically queries the relay to discover all DIDs
// with io.atcr.manifest records. The cached list is used by the proactive scan loop.
func (sb *ScanBroadcaster) refreshManifestDIDsLoop() {
// scanCandidate is a manifest that needs scanning, with its scan freshness.
type scanCandidate struct {
manifest atproto.ManifestRecord // May be zero-value for stale candidates (resolved lazily)
manifestDigest string // Always set; used for dedup and lazy resolution
userDID string
userHandle string
scannedAt time.Time // zero value = never scanned
}
// discoveryLoop fetches DIDs from the relay, walks each user's PDS to find manifests
// with no scan record, and pushes them to the unscannedQueue. Runs on startup (after
// settle), then every 4 hours. Scanner reconnect triggers an early pass via discoverNow.
func (sb *ScanBroadcaster) discoveryLoop() {
defer sb.wg.Done()
// Wait for the system to settle before first refresh
// Wait for system to settle
select {
case <-sb.stopCh:
return
case <-time.After(30 * time.Second):
case <-time.After(45 * time.Second):
}
// Initial refresh
sb.refreshManifestDIDs()
slog.Info("Discovery loop started")
sb.runDiscoveryPass()
ticker := time.NewTicker(30 * time.Minute)
ticker := time.NewTicker(4 * time.Hour)
defer ticker.Stop()
for {
select {
case <-sb.stopCh:
slog.Info("Manifest DID refresh loop stopped")
slog.Info("Discovery loop stopped")
return
case <-ticker.C:
sb.refreshManifestDIDs()
sb.runDiscoveryPass()
case <-sb.discoverNow:
slog.Info("Discovery loop: early pass triggered (scanner reconnect)")
sb.runDiscoveryPass()
}
}
}
// refreshManifestDIDs queries the relay for all DIDs that have io.atcr.manifest records.
// On success, atomically replaces the cached DID list. On failure, retains the previous list.
func (sb *ScanBroadcaster) refreshManifestDIDs() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
// runDiscoveryPass fetches the DID list from relay and walks each user's PDS.
func (sb *ScanBroadcaster) runDiscoveryPass() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
client := atproto.NewClient(sb.relayEndpoint, "", "")
// Fetch DID list from relay
userDIDs := sb.fetchManifestDIDs(ctx)
if len(userDIDs) == 0 {
slog.Debug("Discovery: no manifest DIDs from relay")
return
}
slog.Info("Discovery: starting pass", "users", len(userDIDs))
found := 0
for _, userDID := range userDIDs {
select {
case <-sb.stopCh:
return
default:
}
n := sb.discoverUnscannedForUser(ctx, userDID)
found += n
}
slog.Info("Discovery: pass complete", "users", len(userDIDs), "unscannedFound", found)
}
// fetchManifestDIDs queries the relay for all DIDs with io.atcr.manifest records.
func (sb *ScanBroadcaster) fetchManifestDIDs(ctx context.Context) []string {
client := atproto.NewClient(sb.relayEndpoint, "", "")
var allDIDs []string
var cursor string
for {
result, err := client.ListReposByCollection(ctx, atproto.ManifestCollection, 1000, cursor)
if err != nil {
slog.Warn("Proactive scan: failed to list repos from relay",
slog.Warn("Discovery: failed to list repos from relay",
"relay", sb.relayEndpoint, "error", err)
return // Keep existing cached list
return allDIDs // Return what we have so far
}
for _, repo := range result.Repos {
@@ -826,115 +895,28 @@ func (sb *ScanBroadcaster) refreshManifestDIDs() {
cursor = result.Cursor
}
sb.manifestDIDsMu.Lock()
sb.manifestDIDs = allDIDs
sb.manifestDIDsMu.Unlock()
slog.Info("Proactive scan: refreshed manifest DID list from relay",
"count", len(allDIDs), "relay", sb.relayEndpoint)
return allDIDs
}
// proactiveScanLoop periodically finds manifests needing scanning and enqueues jobs.
// It fetches manifest records from users' PDS (the source of truth) and creates scan
// jobs for manifests that haven't been scanned recently.
func (sb *ScanBroadcaster) proactiveScanLoop() {
defer sb.wg.Done()
// Wait for the system to settle and DID list to populate
select {
case <-sb.stopCh:
return
case <-time.After(45 * time.Second):
}
// Run immediately on startup, then every 60s
slog.Info("Proactive scan loop started")
sb.tryEnqueueProactiveScan()
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-sb.stopCh:
slog.Info("Proactive scan loop stopped")
return
case <-ticker.C:
sb.tryEnqueueProactiveScan()
}
}
}
// tryEnqueueProactiveScan finds the next manifest needing a scan and enqueues it.
// Only enqueues one job per call to avoid flooding the scanner.
// Uses the cached DID list from the relay (refreshed by refreshManifestDIDsLoop).
func (sb *ScanBroadcaster) tryEnqueueProactiveScan() {
if !sb.hasConnectedScanners() {
slog.Debug("Proactive scan: no scanners connected, skipping")
return
}
if sb.hasActiveJobs() {
slog.Debug("Proactive scan: active jobs in queue, skipping")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Read cached DID list from relay discovery
sb.manifestDIDsMu.RLock()
userDIDs := sb.manifestDIDs
sb.manifestDIDsMu.RUnlock()
if len(userDIDs) == 0 {
slog.Debug("Proactive scan: no manifest DIDs cached from relay, skipping")
return
}
// Round-robin through DIDs, trying each until we find work or exhaust the list
for attempts := 0; attempts < len(userDIDs); attempts++ {
idx := sb.userIdx % len(userDIDs)
sb.userIdx++
userDID := userDIDs[idx]
if sb.tryEnqueueForUser(ctx, userDID) {
return // Enqueued one job, done for this tick
}
}
}
// scanCandidate is a manifest that needs scanning, with its scan freshness.
type scanCandidate struct {
manifest atproto.ManifestRecord
userDID string
userHandle string
scannedAt time.Time // zero value = never scanned
}
// tryEnqueueForUser fetches manifests from a user's PDS and enqueues a scan for the
// one that most needs it: never-scanned manifests first, then the stalest scan.
// Returns true if a job was enqueued.
func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string) bool {
// Resolve user DID to PDS endpoint and handle
// discoverUnscannedForUser fetches manifests from a user's PDS and pushes any
// without scan records to the unscannedQueue. Returns count of candidates found.
func (sb *ScanBroadcaster) discoverUnscannedForUser(ctx context.Context, userDID string) int {
did, userHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, userDID)
if err != nil {
slog.Debug("Proactive scan: failed to resolve user identity",
slog.Debug("Discovery: failed to resolve user identity",
"userDID", userDID, "error", err)
return false
return 0
}
// Collect all scannable manifests with their scan age
var unscanned []scanCandidate
var oldest *scanCandidate
found := 0
client := atproto.NewClient(pdsEndpoint, did, "")
var cursor string
for {
records, nextCursor, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, cursor)
if err != nil {
slog.Debug("Proactive scan: failed to list manifest records",
slog.Debug("Discovery: failed to list manifest records",
"userDID", did, "pds", pdsEndpoint, "error", err)
return false
return found
}
for _, record := range records {
@@ -943,7 +925,7 @@ func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string
continue
}
// Check if this manifest belongs to us (directly or via successor)
// Check if this manifest belongs to us
holdDID := manifest.HoldDID
if holdDID == "" {
holdDID = manifest.HoldEndpoint // Legacy field
@@ -952,52 +934,38 @@ func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string
continue
}
// Skip manifest lists (no layers to scan)
if len(manifest.Layers) == 0 {
// Skip manifest lists and attestations (no scannable content)
if len(manifest.Layers) == 0 || manifest.Subject != nil || manifest.Config == nil {
continue
}
// Skip if config is nil
if manifest.Config == nil {
// Check if already in-flight
if !sb.addInflight(manifest.Digest) {
continue
}
// Check scan status
_, scanRecord, err := sb.pds.GetScanRecord(ctx, manifest.Digest)
if err != nil {
// No scan record — never scanned
unscanned = append(unscanned, scanCandidate{
manifest: manifest,
userDID: did,
userHandle: userHandle,
})
// Check scan status — only interested in never-scanned
_, _, err := sb.pds.GetScanRecord(ctx, manifest.Digest)
if err == nil {
// Has a scan record — not our concern (stale loop handles rescans)
sb.removeInflight(manifest.Digest)
continue
}
scannedAt, err := time.Parse(time.RFC3339, scanRecord.ScannedAt)
if err != nil {
// Can't parse timestamp — treat as never scanned
unscanned = append(unscanned, scanCandidate{
manifest: manifest,
userDID: did,
userHandle: userHandle,
})
continue
// Never scanned — push to queue
candidate := &scanCandidate{
manifest: manifest,
manifestDigest: manifest.Digest,
userDID: did,
userHandle: userHandle,
}
// Skip if scanned recently
if time.Since(scannedAt) < sb.rescanInterval {
continue
}
// Stale scan — track the oldest
if oldest == nil || scannedAt.Before(oldest.scannedAt) {
oldest = &scanCandidate{
manifest: manifest,
userDID: did,
userHandle: userHandle,
scannedAt: scannedAt,
}
select {
case sb.unscannedQueue <- candidate:
found++
case <-sb.stopCh:
sb.removeInflight(manifest.Digest)
return found
}
}
@@ -1007,46 +975,302 @@ func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string
cursor = nextCursor
}
// Prefer never-scanned, then oldest stale scan
var pick *scanCandidate
if len(unscanned) > 0 {
pick = &unscanned[0]
} else if oldest != nil {
pick = oldest
return found
}
// staleScanLoop walks local scan records to find stale scans (older than rescanInterval)
// and pushes them to the staleQueue. No PDS calls needed — all data is local.
// After a full pass, sleeps for rescanInterval/2 before repeating.
func (sb *ScanBroadcaster) staleScanLoop() {
defer sb.wg.Done()
// Short initial delay to let system settle
select {
case <-sb.stopCh:
return
case <-time.After(10 * time.Second):
}
if pick == nil {
return false
slog.Info("Stale scan loop started")
for {
sb.runStalePass()
sleepDuration := sb.rescanInterval / 2
if sleepDuration < 1*time.Hour {
sleepDuration = 1 * time.Hour
}
select {
case <-sb.stopCh:
slog.Info("Stale scan loop stopped")
return
case <-time.After(sleepDuration):
}
}
}
// runStalePass walks all scan records and pushes stale ones to the staleQueue.
func (sb *ScanBroadcaster) runStalePass() {
ri := sb.pds.RecordsIndex()
if ri == nil {
slog.Debug("Stale scan: no records index available")
return
}
configJSON, _ := json.Marshal(pick.manifest.Config)
layersJSON, _ := json.Marshal(pick.manifest.Layers)
ctx := context.Background()
found := 0
var cursor string
for {
records, nextCursor, err := ri.ListRecords(atproto.ScanCollection, 100, cursor, true) // oldest first
if err != nil {
slog.Error("Stale scan: failed to list scan records", "error", err)
return
}
for _, record := range records {
select {
case <-sb.stopCh:
return
default:
}
// The rkey is the manifest digest (without sha256: prefix)
manifestDigest := "sha256:" + record.Rkey
// Check if already in-flight
if !sb.addInflight(manifestDigest) {
continue
}
// Fetch the actual scan record to check staleness
_, scanRecord, err := sb.pds.GetScanRecord(ctx, manifestDigest)
if err != nil {
sb.removeInflight(manifestDigest)
continue
}
scannedAt, err := time.Parse(time.RFC3339, scanRecord.ScannedAt)
if err != nil {
sb.removeInflight(manifestDigest)
continue
}
// Skip if scanned recently
if time.Since(scannedAt) < sb.rescanInterval {
sb.removeInflight(manifestDigest)
continue
}
candidate := &scanCandidate{
manifestDigest: manifestDigest,
userDID: scanRecord.UserDID,
scannedAt: scannedAt,
}
select {
case sb.staleQueue <- candidate:
found++
case <-sb.stopCh:
sb.removeInflight(manifestDigest)
return
}
}
if nextCursor == "" || len(records) == 0 {
break
}
cursor = nextCursor
}
if found > 0 {
slog.Info("Stale scan: pass complete", "staleCandidates", found)
}
}
// dispatchLoop pops candidates from the work queues with strict priority
// (unscanned before stale) and enqueues them as scan jobs. Throttled to one
// proactive job at a time via hasActiveJobs().
func (sb *ScanBroadcaster) dispatchLoop() {
defer sb.wg.Done()
// Wait for system to settle
select {
case <-sb.stopCh:
return
case <-time.After(15 * time.Second):
}
slog.Info("Dispatch loop started")
for {
select {
case <-sb.stopCh:
slog.Info("Dispatch loop stopped")
return
default:
}
// Wait until there's capacity (no active proactive jobs)
if !sb.waitForCapacity() {
return // stopCh closed
}
// Wait until at least one scanner is connected
if !sb.hasConnectedScanners() {
select {
case <-sb.stopCh:
return
case <-time.After(5 * time.Second):
}
continue
}
// Pop from highest-priority non-empty queue
var candidate *scanCandidate
select {
case c := <-sb.unscannedQueue:
candidate = c
default:
// No unscanned; try stale
select {
case c := <-sb.staleQueue:
candidate = c
default:
// Both queues empty — wait for completion signal or timeout
select {
case <-sb.stopCh:
return
case <-sb.completionSignal:
case <-time.After(30 * time.Second):
}
continue
}
}
sb.dispatchCandidate(candidate)
}
}
// waitForCapacity blocks until there are no active proactive scan jobs.
// Returns false if stopCh is closed.
func (sb *ScanBroadcaster) waitForCapacity() bool {
for {
if !sb.hasActiveJobs() {
return true
}
select {
case <-sb.stopCh:
return false
case <-sb.completionSignal:
// Job completed, check again
case <-time.After(5 * time.Second):
// Periodic check as fallback
}
}
}
// dispatchCandidate resolves manifest details if needed and enqueues a scan job.
func (sb *ScanBroadcaster) dispatchCandidate(candidate *scanCandidate) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// For stale candidates, verify the scan is still stale (may have been
// scanned by a push-triggered job while sitting in the queue)
if !candidate.scannedAt.IsZero() {
_, scanRecord, err := sb.pds.GetScanRecord(ctx, candidate.manifestDigest)
if err == nil {
scannedAt, parseErr := time.Parse(time.RFC3339, scanRecord.ScannedAt)
if parseErr == nil && time.Since(scannedAt) < sb.rescanInterval {
// Recently scanned, skip
sb.removeInflight(candidate.manifestDigest)
return
}
}
}
// Resolve manifest details if not already present (stale candidates lack Config/Layers)
if candidate.manifest.Config == nil {
if !sb.resolveManifestForCandidate(ctx, candidate) {
sb.removeInflight(candidate.manifestDigest)
return
}
}
configJSON, _ := json.Marshal(candidate.manifest.Config)
layersJSON, _ := json.Marshal(candidate.manifest.Layers)
reason := "never scanned"
if !pick.scannedAt.IsZero() {
reason = fmt.Sprintf("last scanned %s ago", time.Since(pick.scannedAt).Truncate(time.Minute))
if !candidate.scannedAt.IsZero() {
reason = fmt.Sprintf("last scanned %s ago", time.Since(candidate.scannedAt).Truncate(time.Minute))
}
slog.Info("Enqueuing proactive scan",
"manifestDigest", pick.manifest.Digest,
"repository", pick.manifest.Repository,
"userDID", pick.userDID,
slog.Info("Dispatching proactive scan",
"manifestDigest", candidate.manifestDigest,
"repository", candidate.manifest.Repository,
"userDID", candidate.userDID,
"reason", reason)
if err := sb.Enqueue(&ScanJobEvent{
ManifestDigest: pick.manifest.Digest,
Repository: pick.manifest.Repository,
UserDID: pick.userDID,
UserHandle: pick.userHandle,
ManifestDigest: candidate.manifestDigest,
Repository: candidate.manifest.Repository,
UserDID: candidate.userDID,
UserHandle: candidate.userHandle,
Tier: "deckhand",
Config: configJSON,
Layers: layersJSON,
}); err != nil {
slog.Error("Proactive scan: failed to enqueue",
"manifest", pick.manifest.Digest, "error", err)
slog.Error("Dispatch: failed to enqueue",
"manifest", candidate.manifestDigest, "error", err)
// removeInflight not needed — Enqueue already cleans up on error
}
}
// resolveManifestForCandidate fetches the manifest from the user's PDS to populate
// Config and Layers fields needed for the scan job.
func (sb *ScanBroadcaster) resolveManifestForCandidate(ctx context.Context, candidate *scanCandidate) bool {
did, userHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, candidate.userDID)
if err != nil {
slog.Debug("Dispatch: failed to resolve user identity",
"userDID", candidate.userDID, "error", err)
return false
}
return true
if candidate.userHandle == "" {
candidate.userHandle = userHandle
}
client := atproto.NewClient(pdsEndpoint, did, "")
var cursor string
for {
records, nextCursor, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, cursor)
if err != nil {
return false
}
for _, record := range records {
var manifest atproto.ManifestRecord
if err := json.Unmarshal(record.Value, &manifest); err != nil {
continue
}
if manifest.Digest == candidate.manifestDigest {
candidate.manifest = manifest
return true
}
}
if nextCursor == "" || len(records) == 0 {
break
}
cursor = nextCursor
}
slog.Debug("Dispatch: manifest not found on user's PDS",
"manifestDigest", candidate.manifestDigest,
"userDID", candidate.userDID)
return false
}
// isOurManifest checks if a manifest's holdDID matches this hold directly,
@@ -1161,3 +1385,37 @@ func generateSubscriberID() string {
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// addInflight marks a manifest digest as in-flight. Returns false if already present.
func (sb *ScanBroadcaster) addInflight(digest string) bool {
sb.inflightMu.Lock()
defer sb.inflightMu.Unlock()
if _, ok := sb.inflight[digest]; ok {
return false
}
sb.inflight[digest] = struct{}{}
return true
}
// removeInflight removes a manifest digest from the in-flight set.
func (sb *ScanBroadcaster) removeInflight(digest string) {
sb.inflightMu.Lock()
defer sb.inflightMu.Unlock()
delete(sb.inflight, digest)
}
// signalCompletion non-blocking signal to wake the dispatch loop.
func (sb *ScanBroadcaster) signalCompletion() {
select {
case sb.completionSignal <- struct{}{}:
default:
}
}
// triggerDiscovery non-blocking signal to trigger an early discovery pass.
func (sb *ScanBroadcaster) triggerDiscovery() {
select {
case sb.discoverNow <- struct{}{}:
default:
}
}

View File

@@ -31,6 +31,7 @@ func init() {
lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
lexutil.RegisterType(atproto.DailyStatsCollection, &atproto.DailyStatsRecord{})
lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{})
}

View File

@@ -75,6 +75,215 @@ func (p *HoldPDS) IncrementStats(ctx context.Context, ownerDID, repository, oper
return nil
}
// IncrementDailyStats increments the daily pull or push count for a repository
// Creates a new daily record if none exists for the current date, updates existing otherwise
// On first daily record for a repo, seeds it with existing cumulative stats so trend charts
// have a starting data point for pre-daily-tracking history
func (p *HoldPDS) IncrementDailyStats(ctx context.Context, ownerDID, repository, operation string) error {
if operation != "pull" && operation != "push" {
return fmt.Errorf("invalid operation: %s (must be 'pull' or 'push')", operation)
}
date := time.Now().UTC().Format("2006-01-02")
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date)
now := time.Now().Format(time.RFC3339)
// Try to get existing daily record for today
_, existing, err := p.GetDailyStats(ctx, ownerDID, repository, date)
if err != nil {
// No daily record for today — check if we need to seed from cumulative stats
seedPull, seedPush := p.getSeedCounts(ctx, ownerDID, repository)
record := atproto.NewDailyStatsRecord(ownerDID, repository, date)
if operation == "pull" {
record.PullCount = 1
} else {
record.PushCount = 1
}
record.UpdatedAt = now
// If there are existing cumulative counts but no daily records yet,
// create a seed record for the previous day with the historical totals
if seedPull > 0 || seedPush > 0 {
if err := p.seedDailyStats(ctx, ownerDID, repository, date, seedPull, seedPush); err != nil {
slog.Warn("Failed to seed daily stats from cumulative",
"ownerDID", ownerDID,
"repository", repository,
"error", err)
}
}
_, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record)
if err != nil {
return fmt.Errorf("failed to create daily stats record: %w", err)
}
slog.Debug("Created daily stats record",
"ownerDID", ownerDID,
"repository", repository,
"date", date,
"operation", operation)
return nil
}
// Record exists — increment
if operation == "pull" {
existing.PullCount++
} else {
existing.PushCount++
}
existing.UpdatedAt = now
_, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, existing)
if err != nil {
return fmt.Errorf("failed to update daily stats record: %w", err)
}
slog.Debug("Updated daily stats record",
"ownerDID", ownerDID,
"repository", repository,
"date", date,
"operation", operation,
"pullCount", existing.PullCount,
"pushCount", existing.PushCount)
return nil
}
// getSeedCounts returns the cumulative pull/push counts from io.atcr.hold.stats
// minus any already-tracked daily counts. Returns (0, 0) if no seeding is needed.
func (p *HoldPDS) getSeedCounts(ctx context.Context, ownerDID, repository string) (int64, int64) {
// Check if any daily records already exist for this repo
dailyStats, err := p.ListDailyStatsForRepo(ctx, ownerDID, repository)
if err == nil && len(dailyStats) > 0 {
// Daily records already exist — no seeding needed
return 0, 0
}
// Get cumulative stats
_, cumulative, err := p.GetStats(ctx, ownerDID, repository)
if err != nil || cumulative == nil {
return 0, 0
}
return cumulative.PullCount, cumulative.PushCount
}
// seedDailyStats creates a seed daily record with historical cumulative totals
// dated to the day before the first real daily record
func (p *HoldPDS) seedDailyStats(ctx context.Context, ownerDID, repository, currentDate string, pullCount, pushCount int64) error {
// Parse current date and go back one day for the seed record
t, err := time.Parse("2006-01-02", currentDate)
if err != nil {
return fmt.Errorf("failed to parse date: %w", err)
}
seedDate := t.AddDate(0, 0, -1).Format("2006-01-02")
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, seedDate)
record := atproto.NewDailyStatsRecord(ownerDID, repository, seedDate)
record.PullCount = pullCount
record.PushCount = pushCount
record.UpdatedAt = time.Now().Format(time.RFC3339)
_, _, err = p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record)
if err != nil {
return fmt.Errorf("failed to create seed daily stats record: %w", err)
}
slog.Info("Seeded daily stats from cumulative totals",
"ownerDID", ownerDID,
"repository", repository,
"seedDate", seedDate,
"pullCount", pullCount,
"pushCount", pushCount)
return nil
}
// GetDailyStats retrieves the daily stats record for a repository on a specific date
func (p *HoldPDS) GetDailyStats(ctx context.Context, ownerDID, repository, date string) (cid.Cid, *atproto.DailyStatsRecord, error) {
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date)
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, cid.Undef)
if err != nil {
return cid.Undef, nil, err
}
dailyRecord, ok := val.(*atproto.DailyStatsRecord)
if !ok {
return cid.Undef, nil, fmt.Errorf("unexpected type for daily stats record: %T", val)
}
return recordCID, dailyRecord, nil
}
// ListDailyStatsForRepo returns all daily stats records for a specific owner+repo
func (p *HoldPDS) ListDailyStatsForRepo(ctx context.Context, ownerDID, repository string) ([]*atproto.DailyStatsRecord, error) {
session, err := p.carstore.ReadOnlySession(p.uid)
if err != nil {
return nil, fmt.Errorf("failed to get read-only 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.DailyStatsRecord{}, nil
}
r, err := repo.OpenRepo(ctx, session, head)
if err != nil {
return nil, fmt.Errorf("failed to open repo: %w", err)
}
var stats []*atproto.DailyStatsRecord
err = r.ForEach(ctx, atproto.DailyStatsCollection, func(k string, v cid.Cid) error {
parts := strings.Split(k, "/")
if len(parts) < 2 {
return nil
}
actualCollection := strings.Join(parts[:len(parts)-1], "/")
if actualCollection != atproto.DailyStatsCollection {
return repo.ErrDoneIterating
}
_, recBytes, err := r.GetRecordBytes(ctx, k)
if err != nil {
slog.Warn("Failed to get daily stats record bytes", "key", k, "error", err)
return nil
}
if recBytes == nil {
return nil
}
var dailyRecord atproto.DailyStatsRecord
if err := dailyRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
slog.Warn("Failed to unmarshal daily stats record", "key", k, "error", err)
return nil
}
if dailyRecord.OwnerDID == ownerDID && dailyRecord.Repository == repository {
stats = append(stats, &dailyRecord)
}
return nil
})
if err != nil {
if errors.Is(err, repo.ErrDoneIterating) {
// Expected
} else if strings.Contains(err.Error(), "not found") {
return []*atproto.DailyStatsRecord{}, nil
} else {
return nil, fmt.Errorf("failed to iterate daily stats records: %w", err)
}
}
return stats, nil
}
// GetStats retrieves the stats record for a repository
// Returns nil, nil if no stats record exists
func (p *HoldPDS) GetStats(ctx context.Context, ownerDID, repository string) (cid.Cid, *atproto.StatsRecord, error) {

View File

@@ -122,6 +122,8 @@ func (wp *WorkerPool) worker(ctx context.Context, id int) {
// container images so Syft/Grype can't analyze their layers.
var unscannableConfigTypes = map[string]bool{
"application/vnd.cncf.helm.config.v1+json": true, // Helm charts
"application/vnd.in-toto+json": true, // In-toto attestations
"application/vnd.dsse.envelope.v1+json": true, // DSSE envelopes (SLSA)
}
func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) {

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
set -e
goat account login -u evan.jarrett.net -p "${APP_PASSWORD}"
goat account login -u "${TANGLED_REPO_DID}" -p "${APP_PASSWORD}"
TAG_HASH=$(git rev-parse "$TAG") &&
TAG_BYTES=$(echo -n "$TAG_HASH" | xxd -r -p | base64 | tr -d '=') &&
BLOB_OUTPUT=$(goat blob upload "$ARTIFACT_PATH") &&