From fd5bfc3c50cad45aa1dd24a67add7e4236d15e59 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Fri, 3 Apr 2026 16:48:21 -0500 Subject: [PATCH] ui fixes for repo page, fix scanner priority, cleanup goreleaser scripts --- .goreleaser.yaml | 1 + .../workflows/release-credential-helper.yml | 158 +--- config-appview.example.yaml | 35 +- lexicons/io/atcr/hold/stats/daily.json | 47 ++ pkg/appview/config.go | 19 +- .../0019_create_advisor_suggestions.yaml | 7 + .../migrations/0020_add_subject_digest.yaml | 13 + .../0021_create_repository_stats_daily.yaml | 12 + pkg/appview/db/models.go | 10 + pkg/appview/db/queries.go | 152 +++- pkg/appview/db/schema.sql | 19 + pkg/appview/handlers/base.go | 11 +- pkg/appview/handlers/common.go | 30 +- pkg/appview/handlers/diff.go | 26 +- pkg/appview/handlers/diff_test.go | 4 +- pkg/appview/handlers/digest_content.go | 27 +- pkg/appview/handlers/image_advisor.go | 754 ++++++++++++++++++ pkg/appview/handlers/repository.go | 170 +++- pkg/appview/handlers/settings.go | 43 + pkg/appview/handlers/subscription.go | 2 +- pkg/appview/handlers/upgrade_banner.go | 5 +- pkg/appview/jetstream/backfill.go | 1 + pkg/appview/jetstream/processor.go | 42 + pkg/appview/jetstream/processor_test.go | 1 + pkg/appview/public/icons.svg | 7 + pkg/appview/routes/routes.go | 6 + pkg/appview/server.go | 4 + .../components/pull-command-switcher.html | 80 ++ pkg/appview/templates/pages/digest.html | 2 + pkg/appview/templates/pages/repository.html | 721 +++++++++-------- pkg/appview/templates/pages/settings.html | 40 +- .../templates/partials/digest-content.html | 4 +- .../partials/image-advisor-results.html | 75 ++ .../templates/partials/layers-section.html | 119 +++ .../templates/partials/repo-tag-section.html | 210 +++++ .../templates/partials/sbom-section.html | 9 + .../templates/partials/vulns-section.html | 9 + pkg/appview/ui.go | 7 + pkg/atproto/cbor_gen.go | 332 ++++++++ pkg/atproto/generate.go | 1 + pkg/atproto/lexicon.go | 44 + pkg/billing/billing.go | 32 + pkg/billing/billing_stub.go | 9 + pkg/billing/config.go | 3 + pkg/hold/admin/public/icons.svg | 7 + pkg/hold/oci/xrpc.go | 14 +- pkg/hold/pds/scan_broadcaster.go | 630 ++++++++++----- pkg/hold/pds/server.go | 1 + pkg/hold/pds/stats.go | 209 +++++ scanner/internal/scan/worker.go | 2 + scripts/publish-artifact.sh | 2 +- 51 files changed, 3424 insertions(+), 744 deletions(-) create mode 100644 lexicons/io/atcr/hold/stats/daily.json create mode 100644 pkg/appview/db/migrations/0019_create_advisor_suggestions.yaml create mode 100644 pkg/appview/db/migrations/0020_add_subject_digest.yaml create mode 100644 pkg/appview/db/migrations/0021_create_repository_stats_daily.yaml create mode 100644 pkg/appview/handlers/image_advisor.go create mode 100644 pkg/appview/templates/components/pull-command-switcher.html create mode 100644 pkg/appview/templates/partials/image-advisor-results.html create mode 100644 pkg/appview/templates/partials/layers-section.html create mode 100644 pkg/appview/templates/partials/repo-tag-section.html create mode 100644 pkg/appview/templates/partials/sbom-section.html create mode 100644 pkg/appview/templates/partials/vulns-section.html diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8c8088d..1fd298b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 }} diff --git a/.tangled/workflows/release-credential-helper.yml b/.tangled/workflows/release-credential-helper.yml index 14d1ca6..d10c977 100644 --- a/.tangled/workflows/release-credential-helper.yml +++ b/.tangled/workflows/release-credential-helper.yml @@ -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 /docker-credential-atcr___.tar.gz | tar xz" - echo " sudo mv docker-credential-atcr /usr/local/bin/" + goreleaser release --clean diff --git a/config-appview.example.yaml b/config-appview.example.yaml index cd29815..69ea4c3 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -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 diff --git a/lexicons/io/atcr/hold/stats/daily.json b/lexicons/io/atcr/hold/stats/daily.json new file mode 100644 index 0000000..8a559cc --- /dev/null +++ b/lexicons/io/atcr/hold/stats/daily.json @@ -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" + } + } + } + } + } +} diff --git a/pkg/appview/config.go b/pkg/appview/config.go index 64fe75a..5e2ebb8 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -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)") diff --git a/pkg/appview/db/migrations/0019_create_advisor_suggestions.yaml b/pkg/appview/db/migrations/0019_create_advisor_suggestions.yaml new file mode 100644 index 0000000..bf7a0fb --- /dev/null +++ b/pkg/appview/db/migrations/0019_create_advisor_suggestions.yaml @@ -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 + ); diff --git a/pkg/appview/db/migrations/0020_add_subject_digest.yaml b/pkg/appview/db/migrations/0020_add_subject_digest.yaml new file mode 100644 index 0000000..297b571 --- /dev/null +++ b/pkg/appview/db/migrations/0020_add_subject_digest.yaml @@ -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 + ); diff --git a/pkg/appview/db/migrations/0021_create_repository_stats_daily.yaml b/pkg/appview/db/migrations/0021_create_repository_stats_daily.yaml new file mode 100644 index 0000000..ece13fb --- /dev/null +++ b/pkg/appview/db/migrations/0021_create_repository_stats_daily.yaml @@ -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); diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index d4a8188..0a31944 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -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 diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 3a1bb87..550495c 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -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() +} diff --git a/pkg/appview/db/schema.sql b/pkg/appview/db/schema.sql index 70875f6..9b33db5 100644 --- a/pkg/appview/db/schema.sql +++ b/pkg/appview/db/schema.sql @@ -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 +); diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index 18bc070..8df3b8f 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -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 } diff --git a/pkg/appview/handlers/common.go b/pkg/appview/handlers/common.go index 228b705..18f2759 100644 --- a/pkg/appview/handlers/common.go +++ b/pkg/appview/handlers/common.go @@ -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, } } diff --git a/pkg/appview/handlers/diff.go b/pkg/appview/handlers/diff.go index c38899a..c2895b9 100644 --- a/pkg/appview/handlers/diff.go +++ b/pkg/appview/handlers/diff.go @@ -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 diff --git a/pkg/appview/handlers/diff_test.go b/pkg/appview/handlers/diff_test.go index 6e56f35..be31051 100644 --- a/pkg/appview/handlers/diff_test.go +++ b/pkg/appview/handlers/diff_test.go @@ -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 { diff --git a/pkg/appview/handlers/digest_content.go b/pkg/appview/handlers/digest_content.go index 97cef72..9537c18 100644 --- a/pkg/appview/handlers/digest_content.go +++ b/pkg/appview/handlers/digest_content.go @@ -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) + } } } diff --git a/pkg/appview/handlers/image_advisor.go b/pkg/appview/handlers/image_advisor.go new file mode 100644 index 0000000..8238d55 --- /dev/null +++ b/pkg/appview/handlers/image_advisor.go @@ -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 "" + } +} diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 3e437b3..8cedcbe 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -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 diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 7206942..17201bd 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -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. diff --git a/pkg/appview/handlers/subscription.go b/pkg/appview/handlers/subscription.go index d0153f5..115620b 100644 --- a/pkg/appview/handlers/subscription.go +++ b/pkg/appview/handlers/subscription.go @@ -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 { diff --git a/pkg/appview/handlers/upgrade_banner.go b/pkg/appview/handlers/upgrade_banner.go index 2859f8b..368abaf 100644 --- a/pkg/appview/handlers/upgrade_banner.go +++ b/pkg/appview/handlers/upgrade_banner.go @@ -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) diff --git a/pkg/appview/jetstream/backfill.go b/pkg/appview/jetstream/backfill.go index 1c4b03f..88dd22a 100644 --- a/pkg/appview/jetstream/backfill.go +++ b/pkg/appview/jetstream/backfill.go @@ -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) diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index 409938e..88873da 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -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 diff --git a/pkg/appview/jetstream/processor_test.go b/pkg/appview/jetstream/processor_test.go index 3171ba6..31ab479 100644 --- a/pkg/appview/jetstream/processor_test.go +++ b/pkg/appview/jetstream/processor_test.go @@ -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) ); diff --git a/pkg/appview/public/icons.svg b/pkg/appview/public/icons.svg index 2f9ef61..ea32d1a 100644 --- a/pkg/appview/public/icons.svg +++ b/pkg/appview/public/icons.svg @@ -7,6 +7,7 @@ + @@ -18,13 +19,16 @@ + + + @@ -50,9 +54,12 @@ + + + diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index ab117a8..28c1c86 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -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) diff --git a/pkg/appview/server.go b/pkg/appview/server.go index f6e5976..6313272 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -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, diff --git a/pkg/appview/templates/components/pull-command-switcher.html b/pkg/appview/templates/components/pull-command-switcher.html new file mode 100644 index 0000000..e560c10 --- /dev/null +++ b/pkg/appview/templates/components/pull-command-switcher.html @@ -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" }} +
+

Pull this chart

+ {{ 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 }} +
+{{ else }} +
+

Pull this image

+
+ +
+ {{ 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 }} +
+
+
+ +{{ end }} +{{ end }} diff --git a/pkg/appview/templates/pages/digest.html b/pkg/appview/templates/pages/digest.html index 360e858..fc1a537 100644 --- a/pkg/appview/templates/pages/digest.html +++ b/pkg/appview/templates/pages/digest.html @@ -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'); }); + + } {{ end }} diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 5c71576..ca05cff 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -9,9 +9,9 @@ {{ template "nav" . }}
-
- -
+
+ +
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
@@ -26,16 +26,25 @@
- -
-
- {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }} - {{ template "pull-count" (dict "PullCount" .PullCount) }} + +
+
+ {{ 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 }} + + {{ icon "tag" "size-4" }} {{ .TagCount }} + + {{ end }} + {{ if .Stats.LastPush }} + + Updated {{ timeAgoShort (derefTime .Stats.LastPush) }} + + {{ end }}
- {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }} -
+
{{ if .Repository.Version }} {{ .Repository.Version }} @@ -55,309 +64,134 @@ {{ end }} {{ end }} {{ if .Repository.SourceURL }} - - Source + + {{ icon "external-link" "size-3" }} Source {{ end }} {{ if .Repository.DocumentationURL }} - - Documentation + + {{ icon "book-open" "size-3" }} Docs {{ end }}
{{ end }}
+
-
- - -
- {{ if eq .ArtifactType "helm-chart" }} -

Pull this chart

- {{ 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 }} -

Pull this image

- {{ 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 }} + + {{ if .SelectedTag }} +
+
+ {{ icon "tag" "size-4 text-base-content/60" }} +
-
- - -
- -
- - - -
- -
- {{ if .IsOwner }} -
- -
- {{ end }} -
- {{ if .ReadmeHTML }} - {{ .ReadmeHTML }} - {{ else }} -

No description available

+ {{ if .SelectedTag.Info.IsMultiArch }} +
+ {{ range .SelectedTag.Info.Platforms }} + {{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }} {{ end }}
-
- - - {{ if .IsOwner }} - + {{ end }} - -
@@ -412,73 +246,292 @@ + {{ if .IsOwner }} + {{ end }} + + diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index 8b5f676..838f298 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -22,6 +22,9 @@ + @@ -41,6 +44,7 @@