A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+3410 -730
+1
.goreleaser.yaml
··· 63 63 cmd: ./scripts/publish-artifact.sh 64 64 env: 65 65 - APP_PASSWORD={{ .Env.APP_PASSWORD }} 66 + - TANGLED_REPO_DID={{ .Env.TANGLED_REPO_DID }} 66 67 - REPO_URL={{ .Env.REPO_URL }} 67 68 - TAG={{ .Tag }} 68 69 - ARTIFACT_PATH={{ abs .ArtifactPath }}
+19 -137
.tangled/workflows/release-credential-helper.yml
··· 1 1 # Tangled Workflow: Release Credential Helper 2 2 # 3 - # This workflow builds cross-platform binaries for the credential helper. 4 - # Creates tarballs for curl/bash installation and provides instructions 5 - # for updating the Homebrew formula. 3 + # Builds cross-platform binaries using GoReleaser and publishes 4 + # artifacts to the repo owner's PDS as sh.tangled.repo.artifact records. 6 5 # 7 6 # Triggers on version tags (v*) pushed to the repository. 7 + # 8 + # Required secrets: PUBLISH_APP_PASSWORD (ATProto app password for artifact publishing) 8 9 9 10 when: 10 11 - event: ["manual"] 11 12 tag: ["v*"] 12 13 13 - engine: "nixery" 14 - 15 - dependencies: 16 - nixpkgs: 17 - - go_1_24 # Go 1.24+ for building 18 - - goreleaser # For building multi-platform binaries 19 - - curl # Required by go generate for downloading vendor assets 20 - - gnugrep # Required for tag detection 21 - - gnutar # Required for creating tarballs 22 - - gzip # Required for compressing tarballs 23 - - coreutils # Required for sha256sum 14 + engine: kubernetes 15 + image: golang:1.25-trixie 16 + architecture: amd64 24 17 25 18 environment: 26 - CGO_ENABLED: "0" # Build static binaries 19 + CGO_ENABLED: "0" 20 + REPO_RKEY: "3m2pjukohu322" 27 21 28 22 steps: 29 - - name: Get tag for current commit 30 - command: | 31 - # Fetch tags (shallow clone doesn't include them by default) 32 - git fetch --tags 33 - 34 - # Find the tag that points to the current commit 35 - TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1) 36 - 37 - if [ -z "$TAG" ]; then 38 - echo "Error: No version tag found for current commit" 39 - echo "Available tags:" 40 - git tag 41 - echo "Current commit:" 42 - git rev-parse HEAD 43 - exit 1 44 - fi 45 - 46 - echo "Building version: $TAG" 47 - echo "$TAG" > .version 48 - 49 - # Also get the commit hash for reference 50 - COMMIT_HASH=$(git rev-parse HEAD) 51 - echo "Commit: $COMMIT_HASH" 52 - 53 - - name: Build binaries with GoReleaser 23 + - name: Install tools 54 24 command: | 55 - VERSION=$(cat .version) 56 - export VERSION 57 - 58 - # Build for all platforms using GoReleaser 59 - goreleaser build --clean --snapshot --config .goreleaser.yaml 60 - 61 - # List what was built 62 - echo "Built artifacts:" 63 - if [ -d "dist" ]; then 64 - ls -lh dist/ 65 - else 66 - echo "Error: dist/ directory was not created by GoReleaser" 67 - exit 1 68 - fi 69 - 70 - - name: Package artifacts 71 - command: | 72 - VERSION=$(cat .version) 73 - VERSION_NO_V=${VERSION#v} # Remove 'v' prefix for filenames 74 - 75 - cd dist 76 - 77 - # Create tarballs for each platform 78 - # GoReleaser creates directories like: credential-helper_{os}_{arch}_v{goversion} 79 - 80 - # Darwin x86_64 81 - if [ -d "credential-helper_darwin_amd64_v1" ]; then 82 - tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz" \ 83 - -C credential-helper_darwin_amd64_v1 docker-credential-atcr 84 - echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz" 85 - fi 86 - 87 - # Darwin arm64 88 - for dir in credential-helper_darwin_arm64*; do 89 - if [ -d "$dir" ]; then 90 - tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" \ 91 - -C "$dir" docker-credential-atcr 92 - echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" 93 - break 94 - fi 95 - done 25 + go install github.com/bluesky-social/goat@latest 26 + go install github.com/goreleaser/goreleaser/v2@latest 27 + apt-get update && apt-get install -y jq xxd 96 28 97 - # Linux x86_64 98 - if [ -d "credential-helper_linux_amd64_v1" ]; then 99 - tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz" \ 100 - -C credential-helper_linux_amd64_v1 docker-credential-atcr 101 - echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz" 102 - fi 103 - 104 - # Linux arm64 105 - for dir in credential-helper_linux_arm64*; do 106 - if [ -d "$dir" ]; then 107 - tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" \ 108 - -C "$dir" docker-credential-atcr 109 - echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" 110 - break 111 - fi 112 - done 113 - 114 - echo "" 115 - echo "Tarballs ready:" 116 - ls -lh *.tar.gz 2>/dev/null || echo "Warning: No tarballs created" 117 - 118 - - name: Generate checksums 29 + - name: Build and publish release 119 30 command: | 120 - VERSION=$(cat .version) 121 - VERSION_NO_V=${VERSION#v} 122 - 123 - cd dist 124 - 125 - echo "" 126 - echo "==========================================" 127 - echo "SHA256 Checksums" 128 - echo "==========================================" 129 - echo "" 31 + git fetch --tags 130 32 131 - # Generate checksums file 132 - sha256sum docker-credential-atcr_${VERSION_NO_V}_*.tar.gz 2>/dev/null | tee checksums.txt || echo "No checksums generated" 33 + # REPO_URL is built from Tangled-provided env vars 34 + export REPO_URL="at://${TANGLED_REPO_DID}/sh.tangled.repo/${REPO_RKEY}" 35 + export APP_PASSWORD="${PUBLISH_APP_PASSWORD}" 133 36 134 - - name: Next steps 135 - command: | 136 - VERSION=$(cat .version) 137 - 138 - echo "" 139 - echo "==========================================" 140 - echo "Release $VERSION is ready!" 141 - echo "==========================================" 142 - echo "" 143 - echo "Distribution tarballs are in: dist/" 144 - echo "" 145 - echo "Next steps:" 146 - echo "" 147 - echo "1. Upload tarballs to your hosting/CDN (or GitHub releases)" 148 - echo "" 149 - echo "2. For Homebrew users, update the formula:" 150 - echo " ./scripts/update-homebrew-formula.sh $VERSION" 151 - echo " # Then update Formula/docker-credential-atcr.rb and push to homebrew-tap" 152 - echo "" 153 - echo "3. For curl/bash installation, users can download directly:" 154 - echo " curl -L <your-cdn>/docker-credential-atcr_<version>_<os>_<arch>.tar.gz | tar xz" 155 - echo " sudo mv docker-credential-atcr /usr/local/bin/" 37 + goreleaser release --clean
+18 -17
config-appview.example.yaml
··· 91 91 company_name: "" 92 92 # Governing law jurisdiction for legal terms. 93 93 jurisdiction: "" 94 + # AI-powered image advisor settings. 95 + ai: 96 + # Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback. 97 + api_key: "" 94 98 # Stripe billing integration (requires -tags billing build). 95 99 billing: 96 100 # Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set. ··· 100 104 # ISO 4217 currency code (e.g. "usd"). 101 105 currency: usd 102 106 # Redirect URL after successful checkout. Use {base_url} placeholder. 103 - success_url: '{base_url}/settings#storage' 107 + success_url: '{base_url}/settings#billing' 104 108 # Redirect URL after cancelled checkout. Use {base_url} placeholder. 105 - cancel_url: '{base_url}/settings#storage' 109 + cancel_url: '{base_url}/settings#billing' 106 110 # Subscription tiers ordered by rank (lowest to highest). 107 111 tiers: 108 112 - # Tier name. Position in list determines rank (0-based). ··· 119 123 max_webhooks: 1 120 124 # Allow all webhook trigger types (not just first-scan). 121 125 webhook_all_triggers: false 126 + # Enable AI Image Advisor for this tier. 127 + ai_advisor: false 128 + # Show supporter badge on user profiles for subscribers at this tier. 122 129 supporter_badge: false 123 130 - # Tier name. Position in list determines rank (0-based). 124 131 name: Supporter ··· 133 140 # Maximum webhooks for this tier (-1 = unlimited). 134 141 max_webhooks: 1 135 142 # Allow all webhook trigger types (not just first-scan). 136 - webhook_all_triggers: false 143 + webhook_all_triggers: true 144 + # Enable AI Image Advisor for this tier. 145 + ai_advisor: true 146 + # Show supporter badge on user profiles for subscribers at this tier. 137 147 supporter_badge: true 138 148 - # Tier name. Position in list determines rank (0-based). 139 149 name: bosun ··· 149 159 max_webhooks: 10 150 160 # Allow all webhook trigger types (not just first-scan). 151 161 webhook_all_triggers: true 162 + # Enable AI Image Advisor for this tier. 163 + ai_advisor: true 164 + # Show supporter badge on user profiles for subscribers at this tier. 152 165 supporter_badge: true 153 - # - # Tier name. Position in list determines rank (0-based). 154 - # name: quartermaster 155 - # # Short description shown on the plan card. 156 - # description: Maximum storage for power users 157 - # # List of features included in this tier. 158 - # features: [] 159 - # # Stripe price ID for monthly billing. Empty = free tier. 160 - # stripe_price_monthly: price_xxx 161 - # # Stripe price ID for yearly billing. 162 - # stripe_price_yearly: price_yyy 163 - # # Maximum webhooks for this tier (-1 = unlimited). 164 - # max_webhooks: -1 165 - # # Allow all webhook trigger types (not just first-scan). 166 - # webhook_all_triggers: true 166 + # Show supporter badge on hold owner profiles. 167 + owner_badge: true
+47
lexicons/io/atcr/hold/stats/daily.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.stats.daily", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "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.", 9 + "record": { 10 + "type": "object", 11 + "required": ["ownerDid", "repository", "date", "pullCount", "pushCount", "updatedAt"], 12 + "properties": { 13 + "ownerDid": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "DID of the image owner (e.g., did:plc:xyz123)" 17 + }, 18 + "repository": { 19 + "type": "string", 20 + "description": "Repository name (e.g., myapp)", 21 + "maxLength": 256 22 + }, 23 + "date": { 24 + "type": "string", 25 + "description": "Date in YYYY-MM-DD format (UTC)", 26 + "maxLength": 10 27 + }, 28 + "pullCount": { 29 + "type": "integer", 30 + "minimum": 0, 31 + "description": "Number of manifest downloads on this date" 32 + }, 33 + "pushCount": { 34 + "type": "integer", 35 + "minimum": 0, 36 + "description": "Number of manifest uploads on this date" 37 + }, 38 + "updatedAt": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "RFC3339 timestamp of when this record was last updated" 42 + } 43 + } 44 + } 45 + } 46 + } 47 + }
+17 -2
pkg/appview/config.go
··· 32 32 Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` 33 33 CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."` 34 34 Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` 35 + AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."` 35 36 Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` 36 37 Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility 37 38 } ··· 140 141 Jurisdiction string `yaml:"jurisdiction" comment:"Governing law jurisdiction for legal terms."` 141 142 } 142 143 144 + // AIConfig defines AI-powered image advisor settings 145 + type AIConfig struct { 146 + // Anthropic API key for the AI Image Advisor feature. 147 + APIKey string `yaml:"api_key" comment:"Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback."` 148 + } 149 + 143 150 // setDefaults registers all default values on the given Viper instance. 144 151 func setDefaults(v *viper.Viper) { 145 152 v.SetDefault("version", "0.1") ··· 188 195 // Log shipper defaults 189 196 v.SetDefault("log_shipper.batch_size", 100) 190 197 v.SetDefault("log_shipper.flush_interval", "5s") 198 + 199 + // AI defaults 200 + v.SetDefault("ai.api_key", "") 191 201 192 202 // Legal defaults 193 203 v.SetDefault("legal.company_name", "") ··· 213 223 214 224 // Populate example billing tiers so operators see the structure 215 225 cfg.Billing.Currency = "usd" 216 - cfg.Billing.SuccessURL = "{base_url}/settings#storage" 217 - cfg.Billing.CancelURL = "{base_url}/settings#storage" 226 + cfg.Billing.SuccessURL = "{base_url}/settings#billing" 227 + cfg.Billing.CancelURL = "{base_url}/settings#billing" 218 228 cfg.Billing.OwnerBadge = true 219 229 cfg.Billing.Tiers = []billing.BillingTierConfig{ 220 230 {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1}, ··· 253 263 // Post-load: CompanyName defaults to ClientName 254 264 if cfg.Legal.CompanyName == "" { 255 265 cfg.Legal.CompanyName = cfg.Server.ClientName 266 + } 267 + 268 + // Post-load: AI API key fallback to CLAUDE_API_KEY env 269 + if cfg.AI.APIKey == "" { 270 + cfg.AI.APIKey = os.Getenv("CLAUDE_API_KEY") 256 271 } 257 272 258 273 // Validation
+7
pkg/appview/db/migrations/0019_create_advisor_suggestions.yaml
··· 1 + description: Cache AI image advisor suggestions per manifest digest 2 + query: | 3 + CREATE TABLE IF NOT EXISTS advisor_suggestions ( 4 + manifest_digest TEXT PRIMARY KEY, 5 + suggestions_json TEXT NOT NULL, 6 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 7 + );
+13
pkg/appview/db/migrations/0020_add_subject_digest.yaml
··· 1 + description: Add subject_digest column to manifests for tracking OCI referrers (attestations, signatures) 2 + query: | 3 + ALTER TABLE manifests ADD COLUMN subject_digest TEXT; 4 + CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest); 5 + UPDATE manifests SET subject_digest = 'backfill' 6 + WHERE artifact_type = 'unknown' 7 + AND media_type NOT LIKE '%index%' 8 + AND media_type NOT LIKE '%manifest.list%' 9 + AND id IN ( 10 + SELECT m.id FROM manifests m 11 + JOIN manifest_references mr ON mr.digest = m.digest 12 + WHERE mr.is_attestation = 1 13 + );
+12
pkg/appview/db/migrations/0021_create_repository_stats_daily.yaml
··· 1 + description: Add daily repository stats table for pull/push trend tracking 2 + query: | 3 + CREATE TABLE IF NOT EXISTS repository_stats_daily ( 4 + did TEXT NOT NULL, 5 + repository TEXT NOT NULL, 6 + date TEXT NOT NULL, 7 + pull_count INTEGER NOT NULL DEFAULT 0, 8 + push_count INTEGER NOT NULL DEFAULT 0, 9 + PRIMARY KEY(did, repository, date), 10 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 11 + ); 12 + CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);
+10
pkg/appview/db/models.go
··· 25 25 ConfigDigest string 26 26 ConfigSize int64 27 27 ArtifactType string // container-image, helm-chart, unknown 28 + SubjectDigest string // digest of the parent manifest (for attestations/referrers) 28 29 CreatedAt time.Time 29 30 // Annotations removed - now stored in repository_annotations table 30 31 } ··· 90 91 LastPull *time.Time `json:"last_pull,omitempty"` 91 92 PushCount int `json:"push_count"` 92 93 LastPush *time.Time `json:"last_push,omitempty"` 94 + } 95 + 96 + // DailyStats represents daily pull/push statistics for a repository 97 + type DailyStats struct { 98 + DID string `json:"did"` 99 + Repository string `json:"repository"` 100 + Date string `json:"date"` 101 + PullCount int `json:"pull_count"` 102 + PushCount int `json:"push_count"` 93 103 } 94 104 95 105 // RepositoryWithStats combines repository data with statistics
+141 -11
pkg/appview/db/queries.go
··· 627 627 _, err := db.Exec(` 628 628 INSERT INTO manifests 629 629 (did, repository, digest, hold_endpoint, schema_version, media_type, 630 - config_digest, config_size, artifact_type, created_at) 631 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 630 + config_digest, config_size, artifact_type, subject_digest, created_at) 631 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 632 632 ON CONFLICT(did, repository, digest) DO UPDATE SET 633 633 hold_endpoint = excluded.hold_endpoint, 634 634 schema_version = excluded.schema_version, 635 635 media_type = excluded.media_type, 636 636 config_digest = excluded.config_digest, 637 637 config_size = excluded.config_size, 638 - artifact_type = excluded.artifact_type 638 + artifact_type = excluded.artifact_type, 639 + subject_digest = excluded.subject_digest 639 640 WHERE excluded.hold_endpoint != manifests.hold_endpoint 640 641 OR excluded.schema_version != manifests.schema_version 641 642 OR excluded.media_type != manifests.media_type 642 643 OR excluded.config_digest IS NOT manifests.config_digest 643 644 OR excluded.config_size IS NOT manifests.config_size 644 645 OR excluded.artifact_type != manifests.artifact_type 646 + OR excluded.subject_digest IS NOT manifests.subject_digest 645 647 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 646 648 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 647 - manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt) 649 + manifest.ConfigSize, manifest.ArtifactType, 650 + sql.NullString{String: manifest.SubjectDigest, Valid: manifest.SubjectDigest != ""}, 651 + manifest.CreatedAt) 648 652 649 653 if err != nil { 650 654 return 0, err ··· 776 780 // Single-arch tags will have empty Platforms slice (platform is obvious for single-arch) 777 781 // Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations 778 782 func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) { 779 - rows, err := db.Query(` 783 + return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset) 784 + } 785 + 786 + // getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName. 787 + // If tagName is non-empty, only that specific tag is returned. 788 + func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int) ([]TagWithPlatforms, error) { 789 + var tagFilter string 790 + var args []any 791 + if tagName != "" { 792 + tagFilter = "AND tag = ?" 793 + args = append(args, did, repository, tagName, limit, offset) 794 + } else { 795 + args = append(args, did, repository, limit, offset) 796 + } 797 + 798 + query := ` 780 799 WITH paged_tags AS ( 781 800 SELECT id, did, repository, tag, digest, created_at 782 801 FROM tags 783 802 WHERE did = ? AND repository = ? 803 + ` + tagFilter + ` 784 804 ORDER BY created_at DESC 785 805 LIMIT ? OFFSET ? 786 806 ) ··· 806 826 JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository 807 827 LEFT JOIN manifest_references mr ON m.id = mr.manifest_id 808 828 LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository 809 - ORDER BY t.created_at DESC, mr.reference_index 810 - `, did, repository, limit, offset) 829 + ORDER BY t.created_at DESC, mr.reference_index` 811 830 831 + rows, err := db.Query(query, args...) 812 832 if err != nil { 813 833 return nil, err 814 834 } ··· 878 898 return result, nil 879 899 } 880 900 881 - // DeleteManifest deletes a manifest and its associated layers 882 - // If repository is empty, deletes all manifests matching did and digest 901 + // DeleteManifest deletes a manifest and its associated layers. 902 + // Also deletes any attestation manifests that reference this manifest via subject_digest. 903 + // If repository is empty, deletes all manifests matching did and digest. 883 904 func DeleteManifest(db DBTX, did, repository, digest string) error { 884 905 var err error 885 906 if repository == "" { 886 - // Delete by DID + digest only (used when repository is unknown, e.g., Jetstream DELETE events) 907 + // Delete attestation children first, then the manifest itself 908 + _, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND subject_digest = ?`, did, digest) 887 909 _, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND digest = ?`, did, digest) 888 910 } else { 889 - // Delete specific manifest 911 + _, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND subject_digest = ?`, did, repository, digest) 890 912 _, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?`, did, repository, digest) 891 913 } 892 914 return err ··· 1114 1136 LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository 1115 1137 LEFT JOIN manifest_references mr ON m.id = mr.manifest_id 1116 1138 WHERE m.did = ? AND m.repository = ? 1139 + AND m.subject_digest IS NULL 1140 + AND m.artifact_type != 'unknown' 1117 1141 AND ( 1118 1142 -- Include manifest lists 1119 1143 m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' ··· 1414 1438 FROM manifests m 1415 1439 LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository 1416 1440 WHERE m.did = ? AND m.repository = ? 1441 + AND m.subject_digest IS NULL 1417 1442 AND ( 1418 1443 m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' 1419 1444 OR ··· 2435 2460 } 2436 2461 return true, nil 2437 2462 } 2463 + 2464 + // GetTagByName returns a single tag with platform information by tag name. 2465 + // Returns nil, nil if the tag doesn't exist. 2466 + func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) { 2467 + tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0) 2468 + if err != nil { 2469 + return nil, err 2470 + } 2471 + if len(tags) == 0 { 2472 + return nil, nil 2473 + } 2474 + return &tags[0], nil 2475 + } 2476 + 2477 + // GetAllTagNames returns all tag names for a repository, ordered by most recent first. 2478 + func GetAllTagNames(db DBTX, did, repository string) ([]string, error) { 2479 + rows, err := db.Query(` 2480 + SELECT tag FROM tags 2481 + WHERE did = ? AND repository = ? 2482 + ORDER BY created_at DESC 2483 + `, did, repository) 2484 + if err != nil { 2485 + return nil, err 2486 + } 2487 + defer rows.Close() 2488 + 2489 + var names []string 2490 + for rows.Next() { 2491 + var name string 2492 + if err := rows.Scan(&name); err != nil { 2493 + return nil, err 2494 + } 2495 + names = append(names, name) 2496 + } 2497 + return names, rows.Err() 2498 + } 2499 + 2500 + // GetLayerCountForManifest returns the number of layers for a manifest identified by digest. 2501 + func GetLayerCountForManifest(db DBTX, did, repository, digest string) (int, error) { 2502 + var count int 2503 + err := db.QueryRow(` 2504 + SELECT COUNT(*) FROM layers l 2505 + JOIN manifests m ON l.manifest_id = m.id 2506 + WHERE m.did = ? AND m.repository = ? AND m.digest = ? 2507 + `, did, repository, digest).Scan(&count) 2508 + return count, err 2509 + } 2510 + 2511 + // GetAdvisorSuggestions returns cached AI advisor suggestions for a manifest digest. 2512 + // Returns sql.ErrNoRows if no cached suggestions exist. 2513 + func GetAdvisorSuggestions(db DBTX, manifestDigest string) (suggestionsJSON string, createdAt time.Time, err error) { 2514 + err = db.QueryRow( 2515 + `SELECT suggestions_json, created_at FROM advisor_suggestions WHERE manifest_digest = ?`, 2516 + manifestDigest, 2517 + ).Scan(&suggestionsJSON, &createdAt) 2518 + return 2519 + } 2520 + 2521 + // UpsertAdvisorSuggestions caches AI advisor suggestions for a manifest digest. 2522 + func UpsertAdvisorSuggestions(db DBTX, manifestDigest, suggestionsJSON string) error { 2523 + _, err := db.Exec( 2524 + `INSERT OR REPLACE INTO advisor_suggestions (manifest_digest, suggestions_json, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, 2525 + manifestDigest, suggestionsJSON, 2526 + ) 2527 + return err 2528 + } 2529 + 2530 + // UpsertDailyStats inserts or updates daily repository stats 2531 + func UpsertDailyStats(db DBTX, stats *DailyStats) error { 2532 + _, err := db.Exec(` 2533 + INSERT INTO repository_stats_daily (did, repository, date, pull_count, push_count) 2534 + VALUES (?, ?, ?, ?, ?) 2535 + ON CONFLICT(did, repository, date) DO UPDATE SET 2536 + pull_count = excluded.pull_count, 2537 + push_count = excluded.push_count 2538 + WHERE excluded.pull_count != repository_stats_daily.pull_count 2539 + OR excluded.push_count != repository_stats_daily.push_count 2540 + `, stats.DID, stats.Repository, stats.Date, stats.PullCount, stats.PushCount) 2541 + return err 2542 + } 2543 + 2544 + // GetDailyStats retrieves daily stats for a repository within a date range 2545 + // startDate and endDate should be in YYYY-MM-DD format 2546 + func GetDailyStats(db DBTX, did, repository, startDate, endDate string) ([]DailyStats, error) { 2547 + rows, err := db.Query(` 2548 + SELECT did, repository, date, pull_count, push_count 2549 + FROM repository_stats_daily 2550 + WHERE did = ? AND repository = ? AND date >= ? AND date <= ? 2551 + ORDER BY date ASC 2552 + `, did, repository, startDate, endDate) 2553 + if err != nil { 2554 + return nil, err 2555 + } 2556 + defer rows.Close() 2557 + 2558 + var stats []DailyStats 2559 + for rows.Next() { 2560 + var s DailyStats 2561 + if err := rows.Scan(&s.DID, &s.Repository, &s.Date, &s.PullCount, &s.PushCount); err != nil { 2562 + return nil, err 2563 + } 2564 + stats = append(stats, s) 2565 + } 2566 + return stats, rows.Err() 2567 + }
+19
pkg/appview/db/schema.sql
··· 30 30 config_digest TEXT, 31 31 config_size INTEGER, 32 32 artifact_type TEXT NOT NULL DEFAULT 'container-image', -- container-image, helm-chart, unknown 33 + subject_digest TEXT, -- digest of the parent manifest (for attestations/referrers) 33 34 created_at TIMESTAMP NOT NULL, 34 35 UNIQUE(did, repository, digest), 35 36 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ··· 38 39 CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 39 40 CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 40 41 CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type); 42 + CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest); 41 43 42 44 CREATE TABLE IF NOT EXISTS repository_annotations ( 43 45 did TEXT NOT NULL, ··· 167 169 CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did); 168 170 CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC); 169 171 172 + CREATE TABLE IF NOT EXISTS repository_stats_daily ( 173 + did TEXT NOT NULL, 174 + repository TEXT NOT NULL, 175 + date TEXT NOT NULL, 176 + pull_count INTEGER NOT NULL DEFAULT 0, 177 + push_count INTEGER NOT NULL DEFAULT 0, 178 + PRIMARY KEY(did, repository, date), 179 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 180 + ); 181 + CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC); 182 + 170 183 CREATE TABLE IF NOT EXISTS stars ( 171 184 starrer_did TEXT NOT NULL, 172 185 owner_did TEXT NOT NULL, ··· 273 286 PRIMARY KEY(hold_did, manifest_digest) 274 287 ); 275 288 CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did); 289 + 290 + CREATE TABLE IF NOT EXISTS advisor_suggestions ( 291 + manifest_digest TEXT PRIMARY KEY, 292 + suggestions_json TEXT NOT NULL, 293 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 294 + );
+6 -5
pkg/appview/handlers/base.go
··· 40 40 OAuthStore *db.OAuthStore 41 41 42 42 // Config 43 - DefaultHoldDID string 44 - CompanyName string 45 - Jurisdiction string 46 - ClientName string // Full name: "AT Container Registry" 47 - ClientShortName string // Short name: "ATCR" 43 + DefaultHoldDID string 44 + CompanyName string 45 + Jurisdiction string 46 + ClientName string // Full name: "AT Container Registry" 47 + ClientShortName string // Short name: "ATCR" 48 + AIAdvisorEnabled bool // True when Claude API key is configured 48 49 }
+16 -14
pkg/appview/handlers/common.go
··· 10 10 11 11 // PageData contains common fields shared across all page templates 12 12 type PageData struct { 13 - User *db.User // Logged-in user (nil if not logged in) 14 - Query string // Search query from URL parameter 15 - RegistryURL string // Docker registry domain (e.g., "buoy.cr") 16 - SiteURL string // Website domain (e.g., "seamark.dev") 17 - ClientName string // Brand name for templates (e.g., "AT Container Registry") 18 - ClientShortName string // Brand name for templates (e.g., "ATCR") 19 - OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") 13 + User *db.User // Logged-in user (nil if not logged in) 14 + Query string // Search query from URL parameter 15 + RegistryURL string // Docker registry domain (e.g., "buoy.cr") 16 + SiteURL string // Website domain (e.g., "seamark.dev") 17 + ClientName string // Brand name for templates (e.g., "AT Container Registry") 18 + ClientShortName string // Brand name for templates (e.g., "ATCR") 19 + OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") 20 + AIAdvisorEnabled bool // True when AI Image Advisor is available 20 21 } 21 22 22 23 // NewPageData creates a PageData struct with common fields populated from the request ··· 27 28 ociClient = user.OciClient 28 29 } 29 30 return PageData{ 30 - User: user, 31 - Query: r.URL.Query().Get("q"), 32 - RegistryURL: h.RegistryURL, 33 - SiteURL: h.SiteURL, 34 - ClientName: h.ClientName, 35 - ClientShortName: h.ClientShortName, 36 - OciClient: ociClient, 31 + User: user, 32 + Query: r.URL.Query().Get("q"), 33 + RegistryURL: h.RegistryURL, 34 + SiteURL: h.SiteURL, 35 + ClientName: h.ClientName, 36 + ClientShortName: h.ClientShortName, 37 + OciClient: ociClient, 38 + AIAdvisorEnabled: h.AIAdvisorEnabled, 37 39 } 38 40 } 39 41
+23 -3
pkg/appview/handlers/diff.go
··· 210 210 return 211 211 } 212 212 213 - fromDigest := r.URL.Query().Get("from") 214 - toDigest := r.URL.Query().Get("to") 215 - if fromDigest == "" || toDigest == "" { 213 + fromParam := r.URL.Query().Get("from") 214 + toParam := r.URL.Query().Get("to") 215 + if fromParam == "" || toParam == "" { 216 216 RenderNotFound(w, r, &h.BaseUIHandler) 217 217 return 218 218 } ··· 232 232 if owner.Handle != resolvedHandle { 233 233 _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) 234 234 owner.Handle = resolvedHandle 235 + } 236 + 237 + // Resolve from/to params — accept either digests (sha256:...) or tag names 238 + fromDigest := fromParam 239 + toDigest := toParam 240 + if !strings.HasPrefix(fromDigest, "sha256:") { 241 + tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, fromParam) 242 + if err != nil || tag == nil { 243 + RenderNotFound(w, r, &h.BaseUIHandler) 244 + return 245 + } 246 + fromDigest = tag.Digest 247 + } 248 + if !strings.HasPrefix(toDigest, "sha256:") { 249 + tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, toParam) 250 + if err != nil || tag == nil { 251 + RenderNotFound(w, r, &h.BaseUIHandler) 252 + return 253 + } 254 + toDigest = tag.Digest 235 255 } 236 256 237 257 // Fetch both manifests
+2 -2
pkg/appview/handlers/diff_test.go
··· 45 45 digest string 46 46 }{ 47 47 {"shared", "sha256:base"}, 48 - {"removed", "sha256:old"}, // no command, different digest → -/+ 48 + {"removed", "sha256:old"}, // no command, different digest → -/+ 49 49 {"added", "sha256:new1"}, 50 - {"added", "sha256:new2"}, // extra layer in to 50 + {"added", "sha256:new2"}, // extra layer in to 51 51 } 52 52 53 53 for i, e := range expected {
+24 -3
pkg/appview/handlers/digest_content.go
··· 91 91 } 92 92 93 93 w.Header().Set("Content-Type", "text/html") 94 - if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil { 95 - slog.Warn("Failed to render digest content", "error", err) 96 - http.Error(w, err.Error(), http.StatusInternalServerError) 94 + 95 + // Support rendering individual sections for repo page tabs 96 + section := r.URL.Query().Get("section") 97 + switch section { 98 + case "layers": 99 + if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil { 100 + slog.Warn("Failed to render layers section", "error", err) 101 + http.Error(w, err.Error(), http.StatusInternalServerError) 102 + } 103 + case "vulns": 104 + if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil { 105 + slog.Warn("Failed to render vulns section", "error", err) 106 + http.Error(w, err.Error(), http.StatusInternalServerError) 107 + } 108 + case "sbom": 109 + if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil { 110 + slog.Warn("Failed to render sbom section", "error", err) 111 + http.Error(w, err.Error(), http.StatusInternalServerError) 112 + } 113 + default: 114 + if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil { 115 + slog.Warn("Failed to render digest content", "error", err) 116 + http.Error(w, err.Error(), http.StatusInternalServerError) 117 + } 97 118 } 98 119 }
+754
pkg/appview/handlers/image_advisor.go
··· 1 + package handlers 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "sort" 13 + "strings" 14 + "time" 15 + 16 + "atcr.io/pkg/appview/db" 17 + "atcr.io/pkg/appview/middleware" 18 + "atcr.io/pkg/appview/storage" 19 + "atcr.io/pkg/atproto" 20 + "github.com/go-chi/chi/v5" 21 + ) 22 + 23 + // ImageAdvisorHandler returns AI-powered suggestions for improving a container image. 24 + // Returns an HTML fragment (image-advisor-results partial) via HTMX. 25 + type ImageAdvisorHandler struct { 26 + BaseUIHandler 27 + ClaudeAPIKey string 28 + } 29 + 30 + type advisorSuggestion struct { 31 + Action string `json:"action"` 32 + Category string `json:"category"` 33 + Impact string `json:"impact"` 34 + Effort string `json:"effort"` 35 + CVEsFixed int `json:"cves_fixed"` 36 + SizeSavedMB int `json:"size_saved_mb"` 37 + Detail string `json:"detail"` 38 + } 39 + 40 + type imageAdvisorData struct { 41 + Suggestions []advisorSuggestion 42 + Error string 43 + } 44 + 45 + // OCI config types for full image config parsing 46 + type advisorOCIConfig struct { 47 + Architecture string `json:"architecture"` 48 + OS string `json:"os"` 49 + Config advisorOCIContainerConfig `json:"config"` 50 + History []advisorOCIHistory `json:"history"` 51 + } 52 + 53 + type advisorOCIContainerConfig struct { 54 + Env []string `json:"Env"` 55 + Cmd []string `json:"Cmd"` 56 + Entrypoint []string `json:"Entrypoint"` 57 + WorkingDir string `json:"WorkingDir"` 58 + ExposedPorts map[string]struct{} `json:"ExposedPorts"` 59 + Labels map[string]string `json:"Labels"` 60 + User string `json:"User"` 61 + } 62 + 63 + type advisorOCIHistory struct { 64 + CreatedBy string `json:"created_by"` 65 + EmptyLayer bool `json:"empty_layer"` 66 + } 67 + 68 + // SPDX types for SBOM parsing 69 + type advisorSPDX struct { 70 + Packages []advisorSPDXPackage `json:"packages"` 71 + } 72 + 73 + type advisorSPDXPackage struct { 74 + SPDXID string `json:"SPDXID"` 75 + Name string `json:"name"` 76 + VersionInfo string `json:"versionInfo"` 77 + Supplier string `json:"supplier"` 78 + } 79 + 80 + // advisorReportData holds all fetched data for prompt generation 81 + type advisorReportData struct { 82 + Handle string 83 + Repository string 84 + Digest string 85 + Platform string 86 + 87 + Config *advisorOCIConfig 88 + Layers []db.Layer 89 + ScanRecord *atproto.ScanRecord 90 + VulnReport *grypeReport 91 + SBOM *advisorSPDX 92 + } 93 + 94 + func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 95 + if h.ClaudeAPIKey == "" { 96 + h.renderResults(w, imageAdvisorData{Error: "AI advisor is not configured"}) 97 + return 98 + } 99 + 100 + identifier := chi.URLParam(r, "handle") 101 + wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/") 102 + digest := r.URL.Query().Get("digest") 103 + 104 + if wildcard == "" || digest == "" { 105 + h.renderResults(w, imageAdvisorData{Error: "Missing required parameters"}) 106 + return 107 + } 108 + 109 + // Verify the logged-in user owns this image 110 + user := middleware.GetUser(r) 111 + if user == nil { 112 + h.renderResults(w, imageAdvisorData{Error: "Login required"}) 113 + return 114 + } 115 + 116 + // Check billing access 117 + if h.BillingManager != nil && !h.BillingManager.HasAIAdvisor(user.DID) { 118 + h.renderResults(w, imageAdvisorData{Error: "upgrade_required"}) 119 + return 120 + } 121 + 122 + // Check user preference 123 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 124 + profile, err := storage.GetProfile(r.Context(), client) 125 + if err == nil && profile != nil && profile.AIAdvisorEnabled != nil && !*profile.AIAdvisorEnabled { 126 + h.renderResults(w, imageAdvisorData{Error: "AI advisor is disabled in your settings"}) 127 + return 128 + } 129 + 130 + // Resolve identity 131 + did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier) 132 + if err != nil { 133 + h.renderResults(w, imageAdvisorData{Error: "Could not resolve identity"}) 134 + return 135 + } 136 + 137 + if user.DID != did { 138 + h.renderResults(w, imageAdvisorData{Error: "You can only generate suggestions for your own images"}) 139 + return 140 + } 141 + 142 + // Fetch manifest 143 + manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, digest) 144 + if err != nil { 145 + h.renderResults(w, imageAdvisorData{Error: "Manifest not found"}) 146 + return 147 + } 148 + 149 + // For manifest lists, the caller should pass a platform-specific child digest. 150 + // If they somehow pass the list digest itself, resolve to the first platform. 151 + if manifest.IsManifestList { 152 + if len(manifest.Platforms) == 0 { 153 + h.renderResults(w, imageAdvisorData{Error: "No platforms found in manifest list"}) 154 + return 155 + } 156 + childDigest := manifest.Platforms[0].Digest 157 + childManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, childDigest) 158 + if err != nil { 159 + h.renderResults(w, imageAdvisorData{Error: "Could not resolve platform manifest"}) 160 + return 161 + } 162 + manifest = childManifest 163 + digest = childDigest 164 + } 165 + 166 + // Check cache first 167 + if cachedJSON, _, err := db.GetAdvisorSuggestions(h.ReadOnlyDB, digest); err == nil { 168 + suggestions, err := parseAdvisorResponse(cachedJSON) 169 + if err == nil { 170 + slog.Debug("Serving cached advisor suggestions", "digest", digest) 171 + h.renderResults(w, imageAdvisorData{Suggestions: suggestions}) 172 + return 173 + } 174 + slog.Debug("Cached advisor data unparseable, fetching fresh", "digest", digest) 175 + } 176 + 177 + // Resolve hold 178 + hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint) 179 + if err != nil { 180 + h.renderResults(w, imageAdvisorData{Error: "Could not resolve hold endpoint"}) 181 + return 182 + } 183 + 184 + // Build report data 185 + report := &advisorReportData{ 186 + Handle: resolvedHandle, 187 + Repository: wildcard, 188 + Digest: digest, 189 + } 190 + 191 + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) 192 + defer cancel() 193 + 194 + // Fetch full OCI image config 195 + config, err := fetchAdvisorImageConfig(ctx, hold.URL, digest) 196 + if err != nil { 197 + slog.Debug("Failed to fetch image config for advisor", "error", err) 198 + } else { 199 + report.Config = config 200 + report.Platform = config.OS + "/" + config.Architecture 201 + } 202 + 203 + // Fetch layers for size info 204 + dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID) 205 + if err != nil { 206 + slog.Debug("Failed to fetch layers for advisor", "error", err) 207 + } 208 + report.Layers = dbLayers 209 + 210 + // Fetch scan data (scan record + vuln blob + SBOM blob) 211 + scanRecord, vulnReport, sbom := fetchAdvisorScanData(ctx, hold.URL, hold.DID, digest) 212 + report.ScanRecord = scanRecord 213 + report.VulnReport = vulnReport 214 + report.SBOM = sbom 215 + 216 + // Generate prompt 217 + var promptBuf strings.Builder 218 + generateAdvisorPrompt(&promptBuf, report) 219 + 220 + // Call Claude API 221 + responseText, err := callClaudeAPI(ctx, h.ClaudeAPIKey, promptBuf.String()) 222 + if err != nil { 223 + slog.Warn("Claude API call failed", "error", err) 224 + h.renderResults(w, imageAdvisorData{Error: "AI service request failed: " + err.Error()}) 225 + return 226 + } 227 + 228 + // Parse JSON response 229 + suggestions, err := parseAdvisorResponse(responseText) 230 + if err != nil { 231 + slog.Warn("Failed to parse advisor response", "error", err, "response", responseText) 232 + h.renderResults(w, imageAdvisorData{Error: "Failed to parse AI response"}) 233 + return 234 + } 235 + 236 + // Cache the response 237 + if err := db.UpsertAdvisorSuggestions(h.DB, digest, responseText); err != nil { 238 + slog.Warn("Failed to cache advisor suggestions", "error", err) 239 + } 240 + 241 + h.renderResults(w, imageAdvisorData{Suggestions: suggestions}) 242 + } 243 + 244 + func (h *ImageAdvisorHandler) renderResults(w http.ResponseWriter, data imageAdvisorData) { 245 + w.Header().Set("Content-Type", "text/html") 246 + if err := h.Templates.ExecuteTemplate(w, "image-advisor-results", data); err != nil { 247 + slog.Warn("Failed to render image advisor results", "error", err) 248 + } 249 + } 250 + 251 + // fetchAdvisorImageConfig fetches the full OCI image config from the hold. 252 + func fetchAdvisorImageConfig(ctx context.Context, holdURL, manifestDigest string) (*advisorOCIConfig, error) { 253 + reqURL := fmt.Sprintf("%s%s?digest=%s", 254 + strings.TrimSuffix(holdURL, "/"), 255 + atproto.HoldGetImageConfig, 256 + url.QueryEscape(manifestDigest), 257 + ) 258 + 259 + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 260 + if err != nil { 261 + return nil, err 262 + } 263 + 264 + resp, err := http.DefaultClient.Do(req) 265 + if err != nil { 266 + return nil, err 267 + } 268 + defer resp.Body.Close() 269 + 270 + if resp.StatusCode != http.StatusOK { 271 + return nil, fmt.Errorf("hold returned %d", resp.StatusCode) 272 + } 273 + 274 + var record struct { 275 + ConfigJSON string `json:"configJson"` 276 + } 277 + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { 278 + return nil, err 279 + } 280 + 281 + var config advisorOCIConfig 282 + if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil { 283 + return nil, err 284 + } 285 + return &config, nil 286 + } 287 + 288 + // fetchAdvisorScanData fetches the scan record plus vuln and SBOM blobs. 289 + func fetchAdvisorScanData(ctx context.Context, holdURL, holdDID, digest string) (*atproto.ScanRecord, *grypeReport, *advisorSPDX) { 290 + rkey := strings.TrimPrefix(digest, "sha256:") 291 + 292 + scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 293 + strings.TrimSuffix(holdURL, "/"), 294 + url.QueryEscape(holdDID), 295 + url.QueryEscape(atproto.ScanCollection), 296 + url.QueryEscape(rkey), 297 + ) 298 + 299 + req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil) 300 + if err != nil { 301 + return nil, nil, nil 302 + } 303 + 304 + resp, err := http.DefaultClient.Do(req) 305 + if err != nil { 306 + return nil, nil, nil 307 + } 308 + defer resp.Body.Close() 309 + 310 + if resp.StatusCode != http.StatusOK { 311 + return nil, nil, nil 312 + } 313 + 314 + var envelope struct { 315 + Value json.RawMessage `json:"value"` 316 + } 317 + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 318 + return nil, nil, nil 319 + } 320 + 321 + var scanRecord atproto.ScanRecord 322 + if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil { 323 + return nil, nil, nil 324 + } 325 + 326 + // Fetch vuln report blob 327 + var vulnReport *grypeReport 328 + if scanRecord.VulnReportBlob != nil && scanRecord.VulnReportBlob.Ref.String() != "" { 329 + vulnReport = fetchAdvisorBlob[grypeReport](ctx, holdURL, holdDID, scanRecord.VulnReportBlob.Ref.String()) 330 + } 331 + 332 + // Fetch SBOM blob 333 + var sbom *advisorSPDX 334 + if scanRecord.SbomBlob != nil && scanRecord.SbomBlob.Ref.String() != "" { 335 + sbom = fetchAdvisorBlob[advisorSPDX](ctx, holdURL, holdDID, scanRecord.SbomBlob.Ref.String()) 336 + } 337 + 338 + return &scanRecord, vulnReport, sbom 339 + } 340 + 341 + // fetchAdvisorBlob fetches and JSON-decodes a blob from the hold. 342 + func fetchAdvisorBlob[T any](ctx context.Context, holdURL, holdDID, cid string) *T { 343 + blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 344 + strings.TrimSuffix(holdURL, "/"), 345 + url.QueryEscape(holdDID), 346 + url.QueryEscape(cid), 347 + ) 348 + 349 + req, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil) 350 + if err != nil { 351 + return nil 352 + } 353 + 354 + resp, err := http.DefaultClient.Do(req) 355 + if err != nil { 356 + return nil 357 + } 358 + defer resp.Body.Close() 359 + 360 + if resp.StatusCode != http.StatusOK { 361 + return nil 362 + } 363 + 364 + var result T 365 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 366 + return nil 367 + } 368 + return &result 369 + } 370 + 371 + // generateAdvisorPrompt writes the system+data prompt for the AI advisor. 372 + func generateAdvisorPrompt(w io.Writer, r *advisorReportData) { 373 + // Data block 374 + ref := r.Handle + "/" + r.Repository 375 + totalSize := int64(0) 376 + for _, l := range r.Layers { 377 + totalSize += l.Size 378 + } 379 + 380 + fmt.Fprintf(w, "image: %s\ndigest: %s\n", ref, r.Digest) 381 + if r.Platform != "" { 382 + fmt.Fprintf(w, "platform: %s\n", r.Platform) 383 + } 384 + fmt.Fprintf(w, "total_size: %s\nlayers: %d\n", advisorHumanSize(totalSize), len(r.Layers)) 385 + 386 + if r.Config != nil { 387 + c := r.Config.Config 388 + user := c.User 389 + if user == "" { 390 + user = "root" 391 + } 392 + fmt.Fprintf(w, "user: %s\n", user) 393 + if c.WorkingDir != "" { 394 + fmt.Fprintf(w, "workdir: %s\n", c.WorkingDir) 395 + } 396 + if len(c.Entrypoint) > 0 { 397 + fmt.Fprintf(w, "entrypoint: %s\n", strings.Join(c.Entrypoint, " ")) 398 + } 399 + if len(c.Cmd) > 0 { 400 + fmt.Fprintf(w, "cmd: %s\n", strings.Join(c.Cmd, " ")) 401 + } 402 + if len(c.ExposedPorts) > 0 { 403 + ports := make([]string, 0, len(c.ExposedPorts)) 404 + for p := range c.ExposedPorts { 405 + ports = append(ports, p) 406 + } 407 + fmt.Fprintf(w, "ports: %s\n", strings.Join(ports, ",")) 408 + } 409 + if len(c.Env) > 0 { 410 + fmt.Fprintln(w, "env:") 411 + for _, env := range c.Env { 412 + parts := strings.SplitN(env, "=", 2) 413 + if advisorShouldRedact(parts[0]) { 414 + fmt.Fprintf(w, " - %s=[REDACTED]\n", parts[0]) 415 + } else { 416 + fmt.Fprintf(w, " - %s\n", env) 417 + } 418 + } 419 + } 420 + if len(c.Labels) > 0 { 421 + fmt.Fprintln(w, "labels:") 422 + keys := make([]string, 0, len(c.Labels)) 423 + for k := range c.Labels { 424 + keys = append(keys, k) 425 + } 426 + sort.Strings(keys) 427 + for _, k := range keys { 428 + v := c.Labels[k] 429 + if len(v) > 80 { 430 + v = v[:77] + "..." 431 + } 432 + fmt.Fprintf(w, " %s: %s\n", k, v) 433 + } 434 + } 435 + 436 + // History with layer sizes 437 + fmt.Fprintln(w, "history:") 438 + layerIdx := 0 439 + for _, h := range r.Config.History { 440 + cmd := advisorCleanCommand(h.CreatedBy) 441 + if len(cmd) > 100 { 442 + cmd = cmd[:97] + "..." 443 + } 444 + if !h.EmptyLayer && layerIdx < len(r.Layers) { 445 + fmt.Fprintf(w, " - [%s] %s\n", advisorHumanSize(r.Layers[layerIdx].Size), cmd) 446 + layerIdx++ 447 + } else { 448 + fmt.Fprintf(w, " - %s\n", cmd) 449 + } 450 + } 451 + } 452 + 453 + // Vulnerability summary 454 + if r.ScanRecord != nil { 455 + sr := r.ScanRecord 456 + fmt.Fprintf(w, "vulns: {critical: %d, high: %d, medium: %d, low: %d, total: %d}\n", 457 + sr.Critical, sr.High, sr.Medium, sr.Low, sr.Total) 458 + } 459 + 460 + // Fixable critical/high vulns 461 + if r.VulnReport != nil { 462 + type pkgInfo struct { 463 + version string 464 + typ string 465 + fixes map[string]bool 466 + cves []string 467 + maxSev int 468 + } 469 + pkgs := map[string]*pkgInfo{} 470 + 471 + for _, m := range r.VulnReport.Matches { 472 + sev := m.Vulnerability.Metadata.Severity 473 + if sev != "Critical" && sev != "High" { 474 + continue 475 + } 476 + key := m.Package.Name 477 + p, ok := pkgs[key] 478 + if !ok { 479 + p = &pkgInfo{version: m.Package.Version, typ: m.Package.Type, fixes: map[string]bool{}, maxSev: 5} 480 + pkgs[key] = p 481 + } 482 + p.cves = append(p.cves, m.Vulnerability.ID) 483 + for _, f := range m.Vulnerability.Fix.Versions { 484 + p.fixes[f] = true 485 + } 486 + if s := advisorSeverityRank(sev); s < p.maxSev { 487 + p.maxSev = s 488 + } 489 + } 490 + 491 + if len(pkgs) > 0 { 492 + fmt.Fprintln(w, "fixable_critical_high:") 493 + type entry struct { 494 + name string 495 + info *pkgInfo 496 + } 497 + sorted := make([]entry, 0, len(pkgs)) 498 + for n, p := range pkgs { 499 + sorted = append(sorted, entry{n, p}) 500 + } 501 + sort.Slice(sorted, func(i, j int) bool { 502 + if sorted[i].info.maxSev != sorted[j].info.maxSev { 503 + return sorted[i].info.maxSev < sorted[j].info.maxSev 504 + } 505 + return len(sorted[i].info.cves) > len(sorted[j].info.cves) 506 + }) 507 + 508 + for _, e := range sorted { 509 + fixes := make([]string, 0, len(e.info.fixes)) 510 + for f := range e.info.fixes { 511 + fixes = append(fixes, f) 512 + } 513 + sort.Strings(fixes) 514 + fmt.Fprintf(w, " - pkg: %s@%s (%s) cves: %d fix: %s\n", 515 + e.name, e.info.version, e.info.typ, len(e.info.cves), strings.Join(fixes, ",")) 516 + } 517 + } 518 + 519 + // Unfixable counts 520 + unfixable := map[string]int{} 521 + for _, m := range r.VulnReport.Matches { 522 + if len(m.Vulnerability.Fix.Versions) == 0 { 523 + unfixable[m.Vulnerability.Metadata.Severity]++ 524 + } 525 + } 526 + if len(unfixable) > 0 { 527 + fmt.Fprintf(w, "unfixable:") 528 + for _, sev := range []string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"} { 529 + if c, ok := unfixable[sev]; ok { 530 + fmt.Fprintf(w, " %s=%d", strings.ToLower(sev), c) 531 + } 532 + } 533 + fmt.Fprintln(w) 534 + } 535 + } 536 + 537 + // SBOM summary 538 + if r.SBOM != nil { 539 + typeCounts := map[string]int{} 540 + total := 0 541 + for _, p := range r.SBOM.Packages { 542 + if strings.HasPrefix(p.SPDXID, "SPDXRef-DocumentRoot") || p.SPDXID == "SPDXRef-DOCUMENT" { 543 + continue 544 + } 545 + total++ 546 + pkgType := advisorExtractPackageType(p.Supplier) 547 + if pkgType == "" { 548 + pkgType = "other" 549 + } 550 + typeCounts[pkgType]++ 551 + } 552 + fmt.Fprintf(w, "sbom_packages: %d", total) 553 + for t, c := range typeCounts { 554 + fmt.Fprintf(w, " %s=%d", t, c) 555 + } 556 + fmt.Fprintln(w) 557 + 558 + if r.VulnReport != nil { 559 + vulnPkgs := map[string]int{} 560 + for _, m := range r.VulnReport.Matches { 561 + vulnPkgs[m.Package.Name]++ 562 + } 563 + type pv struct { 564 + name string 565 + count int 566 + } 567 + sorted := make([]pv, 0, len(vulnPkgs)) 568 + for n, c := range vulnPkgs { 569 + sorted = append(sorted, pv{n, c}) 570 + } 571 + sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) 572 + if len(sorted) > 10 { 573 + sorted = sorted[:10] 574 + } 575 + fmt.Fprintln(w, "top_vulnerable_packages:") 576 + for _, p := range sorted { 577 + fmt.Fprintf(w, " - %s: %d\n", p.name, p.count) 578 + } 579 + } 580 + } 581 + } 582 + 583 + // callClaudeAPI sends the prompt to Claude Haiku using tool use and returns the structured JSON. 584 + func callClaudeAPI(ctx context.Context, apiKey, prompt string) (string, error) { 585 + reqBody := map[string]any{ 586 + "model": "claude-haiku-4-5-20251001", 587 + "max_tokens": 2048, 588 + "system": "Analyze the container image data. Provide actionable suggestions sorted by impact (highest first).", 589 + "tools": []map[string]any{{ 590 + "name": "suggest_fixes", 591 + "description": "Return actionable suggestions for improving a container image, sorted by impact.", 592 + "input_schema": map[string]any{ 593 + "type": "object", 594 + "properties": map[string]any{ 595 + "suggestions": map[string]any{ 596 + "type": "array", 597 + "items": map[string]any{ 598 + "type": "object", 599 + "properties": map[string]any{ 600 + "action": map[string]any{"type": "string", "description": "Specific actionable step"}, 601 + "category": map[string]any{"type": "string", "enum": []string{"vulnerability", "size", "cache", "security", "best-practice"}}, 602 + "impact": map[string]any{"type": "string", "enum": []string{"high", "medium", "low"}}, 603 + "effort": map[string]any{"type": "string", "enum": []string{"low", "medium", "high"}}, 604 + "cves_fixed": map[string]any{"type": "integer", "description": "Number of CVEs fixed, or 0"}, 605 + "size_saved_mb": map[string]any{"type": "integer", "description": "Estimated MB saved, or 0"}, 606 + "detail": map[string]any{"type": "string", "description": "One sentence with specific package names, versions, or commands"}, 607 + }, 608 + "required": []string{"action", "category", "impact", "effort", "cves_fixed", "size_saved_mb", "detail"}, 609 + }, 610 + }, 611 + }, 612 + "required": []string{"suggestions"}, 613 + }, 614 + }}, 615 + "tool_choice": map[string]any{"type": "tool", "name": "suggest_fixes"}, 616 + "messages": []map[string]string{ 617 + {"role": "user", "content": prompt}, 618 + }, 619 + } 620 + 621 + bodyBytes, err := json.Marshal(reqBody) 622 + if err != nil { 623 + return "", fmt.Errorf("marshal request: %w", err) 624 + } 625 + 626 + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(bodyBytes)) 627 + if err != nil { 628 + return "", fmt.Errorf("build request: %w", err) 629 + } 630 + req.Header.Set("x-api-key", apiKey) 631 + req.Header.Set("anthropic-version", "2023-06-01") 632 + req.Header.Set("content-type", "application/json") 633 + 634 + resp, err := http.DefaultClient.Do(req) 635 + if err != nil { 636 + return "", fmt.Errorf("API request: %w", err) 637 + } 638 + defer resp.Body.Close() 639 + 640 + if resp.StatusCode != http.StatusOK { 641 + body, _ := io.ReadAll(resp.Body) 642 + return "", fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body)) 643 + } 644 + 645 + var apiResp struct { 646 + Content []struct { 647 + Type string `json:"type"` 648 + Name string `json:"name"` 649 + Input json.RawMessage `json:"input"` 650 + } `json:"content"` 651 + } 652 + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { 653 + return "", fmt.Errorf("parse response: %w", err) 654 + } 655 + 656 + for _, c := range apiResp.Content { 657 + if c.Type == "tool_use" && c.Name == "suggest_fixes" { 658 + return string(c.Input), nil 659 + } 660 + } 661 + return "", fmt.Errorf("no tool_use content in response") 662 + } 663 + 664 + // parseAdvisorResponse parses the JSON suggestions (from API or cache) into suggestions. 665 + func parseAdvisorResponse(jsonStr string) ([]advisorSuggestion, error) { 666 + var result struct { 667 + Suggestions []advisorSuggestion `json:"suggestions"` 668 + } 669 + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { 670 + return nil, err 671 + } 672 + return result.Suggestions, nil 673 + } 674 + 675 + // Prompt helper functions 676 + 677 + func advisorHumanSize(bytes int64) string { 678 + const ( 679 + KB = 1024 680 + MB = 1024 * KB 681 + GB = 1024 * MB 682 + ) 683 + switch { 684 + case bytes >= GB: 685 + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB)) 686 + case bytes >= MB: 687 + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) 688 + case bytes >= KB: 689 + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) 690 + default: 691 + return fmt.Sprintf("%d B", bytes) 692 + } 693 + } 694 + 695 + func advisorCleanCommand(cmd string) string { 696 + cmd = strings.TrimPrefix(cmd, "/bin/sh -c ") 697 + cmd = strings.TrimPrefix(cmd, "#(nop) ") 698 + return strings.TrimSpace(cmd) 699 + } 700 + 701 + func advisorShouldRedact(envName string) bool { 702 + upper := strings.ToUpper(envName) 703 + for _, suffix := range []string{"_KEY", "_SECRET", "_PASSWORD", "_TOKEN", "_CREDENTIALS", "_API_KEY"} { 704 + if strings.HasSuffix(upper, suffix) { 705 + return true 706 + } 707 + } 708 + return false 709 + } 710 + 711 + func advisorSeverityRank(s string) int { 712 + switch s { 713 + case "Critical": 714 + return 0 715 + case "High": 716 + return 1 717 + case "Medium": 718 + return 2 719 + case "Low": 720 + return 3 721 + case "Negligible": 722 + return 4 723 + default: 724 + return 5 725 + } 726 + } 727 + 728 + func advisorExtractPackageType(supplier string) string { 729 + s := strings.ToLower(supplier) 730 + switch { 731 + case strings.Contains(s, "npmjs") || strings.Contains(s, "npm"): 732 + return "npm" 733 + case strings.Contains(s, "pypi") || strings.Contains(s, "python"): 734 + return "python" 735 + case strings.Contains(s, "rubygems"): 736 + return "gem" 737 + case strings.Contains(s, "golang") || strings.Contains(s, "go"): 738 + return "go" 739 + case strings.Contains(s, "debian") || strings.Contains(s, "ubuntu"): 740 + return "deb" 741 + case strings.Contains(s, "alpine"): 742 + return "apk" 743 + case strings.Contains(s, "redhat") || strings.Contains(s, "fedora") || strings.Contains(s, "centos"): 744 + return "rpm" 745 + case strings.Contains(s, "maven") || strings.Contains(s, "java"): 746 + return "java" 747 + case strings.Contains(s, "nuget") || strings.Contains(s, ".net"): 748 + return "nuget" 749 + case strings.Contains(s, "cargo") || strings.Contains(s, "rust"): 750 + return "rust" 751 + default: 752 + return "" 753 + } 754 + }
+153 -17
pkg/appview/handlers/repository.go
··· 12 12 "time" 13 13 14 14 "atcr.io/pkg/appview/db" 15 + "atcr.io/pkg/appview/holdclient" 15 16 "atcr.io/pkg/appview/middleware" 16 17 "atcr.io/pkg/appview/readme" 18 + "atcr.io/pkg/appview/storage" 17 19 "atcr.io/pkg/atproto" 18 20 "github.com/go-chi/chi/v5" 19 21 ) 22 + 23 + // SelectedTagData holds all data for the currently selected tag on the repo page. 24 + type SelectedTagData struct { 25 + Info *db.TagWithPlatforms 26 + LayerCount int 27 + CompressedSize int64 // total across all platforms 28 + ScanBatchParams []template.HTML 29 + } 20 30 21 31 // RepositoryPageHandler handles the public repository page 22 32 type RepositoryPageHandler struct { ··· 62 72 return 63 73 } 64 74 65 - // Fetch latest tag for pull command 66 - latestTag, err := db.GetLatestTag(h.ReadOnlyDB, owner.DID, repository) 75 + // Fetch all tag names for the selector dropdown 76 + allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository) 67 77 if err != nil { 68 - http.Error(w, err.Error(), http.StatusInternalServerError) 69 - return 78 + slog.Warn("Failed to fetch tag names", "error", err) 70 79 } 71 80 72 - // Determine artifact type from latest tag 73 - artifactType := "container-image" 74 - latestTagName := "" 75 - if latestTag != nil { 76 - latestTagName = latestTag.Tag 77 - artifactType = latestTag.ArtifactType 81 + // Determine which tag to show 82 + selectedTagName := r.URL.Query().Get("tag") 83 + if selectedTagName == "" { 84 + // Default: "latest" if it exists, otherwise most recent 85 + for _, t := range allTags { 86 + if t == "latest" { 87 + selectedTagName = "latest" 88 + break 89 + } 90 + } 91 + if selectedTagName == "" && len(allTags) > 0 { 92 + selectedTagName = allTags[0] // most recent (already sorted DESC) 93 + } 94 + } 95 + 96 + // Fetch the selected tag's full data 97 + var selectedTag *SelectedTagData 98 + var artifactType = "container-image" 99 + if selectedTagName != "" { 100 + tagData, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repository, selectedTagName) 101 + if err != nil { 102 + slog.Warn("Failed to fetch selected tag", "error", err, "tag", selectedTagName) 103 + } 104 + if tagData != nil { 105 + artifactType = tagData.ArtifactType 106 + 107 + // Ensure single-arch tags have a one-element Platforms slice 108 + platforms := tagData.Platforms 109 + if len(platforms) == 0 { 110 + platforms = []db.PlatformInfo{{ 111 + Digest: tagData.Digest, 112 + HoldEndpoint: tagData.HoldEndpoint, 113 + CompressedSize: tagData.CompressedSize, 114 + }} 115 + } 116 + tagData.Platforms = platforms 117 + 118 + // Compute total compressed size across all platforms 119 + var totalSize int64 120 + if tagData.IsMultiArch { 121 + for _, p := range platforms { 122 + totalSize += p.CompressedSize 123 + } 124 + } else { 125 + totalSize = tagData.CompressedSize 126 + } 127 + 128 + // Get layer count from image config (includes empty layers) 129 + layerCountDigest := tagData.Digest 130 + if tagData.IsMultiArch && len(platforms) > 0 && platforms[0].Digest != "" { 131 + layerCountDigest = platforms[0].Digest 132 + } 133 + var layerCount int 134 + holdEndpoint := platforms[0].HoldEndpoint 135 + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint) 136 + if holdErr == nil { 137 + config, cfgErr := holdclient.FetchImageConfig(r.Context(), hold.URL, layerCountDigest) 138 + if cfgErr == nil { 139 + layerCount = len(config.History) 140 + } else { 141 + slog.Warn("Failed to fetch image config for layer count", "error", cfgErr) 142 + } 143 + } 144 + // Fall back to DB count (non-empty layers only) if hold unavailable 145 + if layerCount == 0 { 146 + layerCount, err = db.GetLayerCountForManifest(h.ReadOnlyDB, owner.DID, repository, layerCountDigest) 147 + if err != nil { 148 + slog.Warn("Failed to fetch layer count", "error", err) 149 + } 150 + } 151 + 152 + // Build scan batch params for first platform only (summary card) 153 + scanBatchParams := buildScanBatchParams(platforms[:1]) 154 + 155 + selectedTag = &SelectedTagData{ 156 + Info: tagData, 157 + LayerCount: layerCount, 158 + CompressedSize: totalSize, 159 + ScanBatchParams: scanBatchParams, 160 + } 161 + } 78 162 } 79 163 80 164 // Create repository summary ··· 97 181 repo.Version = metadata["org.opencontainers.image.version"] 98 182 } 99 183 100 - // Fetch star count 184 + // Fetch stats 101 185 stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository) 102 186 if err != nil { 103 187 slog.Warn("Failed to fetch repository stats", "error", err) 104 188 stats = &db.RepositoryStats{StarCount: 0} 189 + } 190 + 191 + tagCount, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository) 192 + if err != nil { 193 + slog.Warn("Failed to fetch tag count", "error", err) 105 194 } 106 195 107 196 // Check if current user has starred this repo ··· 193 282 Meta *PageMeta 194 283 Owner *db.User 195 284 Repository *db.Repository 196 - LatestTag string 197 - StarCount int 198 - PullCount int 285 + AllTags []string 286 + SelectedTag *SelectedTagData 287 + Stats *db.RepositoryStats 288 + TagCount int 199 289 IsStarred bool 200 290 IsOwner bool 201 291 ReadmeHTML template.HTML ··· 206 296 Meta: meta, 207 297 Owner: owner, 208 298 Repository: repo, 209 - LatestTag: latestTagName, 210 - StarCount: stats.StarCount, 211 - PullCount: stats.PullCount, 299 + AllTags: allTags, 300 + SelectedTag: selectedTag, 301 + Stats: stats, 302 + TagCount: tagCount, 212 303 IsStarred: isStarred, 213 304 IsOwner: isOwner, 214 305 ReadmeHTML: readmeHTML, ··· 216 307 ArtifactType: artifactType, 217 308 } 218 309 310 + // If the owner has disabled AI advisor in their profile, hide the button 311 + if isOwner && data.AIAdvisorEnabled && user != nil { 312 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 313 + if profile, err := storage.GetProfile(r.Context(), client); err == nil && profile != nil { 314 + if profile.AIAdvisorEnabled != nil && !*profile.AIAdvisorEnabled { 315 + data.AIAdvisorEnabled = false 316 + } 317 + } 318 + } 319 + 320 + // If this is an HTMX request for the tag section, render just the partial 321 + if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("tag") != "" { 322 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 323 + if err := h.Templates.ExecuteTemplate(w, "repo-tag-section", data); err != nil { 324 + http.Error(w, err.Error(), http.StatusInternalServerError) 325 + } 326 + return 327 + } 328 + 219 329 if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { 220 330 http.Error(w, err.Error(), http.StatusInternalServerError) 221 331 return 222 332 } 333 + } 334 + 335 + // buildScanBatchParams builds HTMX query params for batch scan-result requests 336 + // from a list of platform infos. 337 + func buildScanBatchParams(platforms []db.PlatformInfo) []template.HTML { 338 + holdDigests := make(map[string][]string) 339 + seen := make(map[string]bool) 340 + for _, p := range platforms { 341 + if p.Digest != "" && p.HoldEndpoint != "" && !seen[p.Digest] { 342 + seen[p.Digest] = true 343 + hex := strings.TrimPrefix(p.Digest, "sha256:") 344 + holdDigests[p.HoldEndpoint] = append(holdDigests[p.HoldEndpoint], hex) 345 + } 346 + } 347 + var params []template.HTML 348 + for hold, digests := range holdDigests { 349 + for i := 0; i < len(digests); i += 50 { 350 + end := i + 50 351 + if end > len(digests) { 352 + end = len(digests) 353 + } 354 + params = append(params, template.HTML( 355 + "holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests[i:end], ","))) 356 + } 357 + } 358 + return params 223 359 } 224 360 225 361 // RepositoryTagsHandler returns the tags+manifests HTMX partial for a repository
+43
pkg/appview/handlers/settings.go
··· 138 138 DefaultHold string 139 139 AutoRemoveUntagged bool 140 140 OciClient string 141 + AIAdvisorEnabled bool 142 + HasAIAdvisorAccess bool // billing tier grants access 141 143 } 142 144 ActiveHold *HoldDisplay 143 145 OtherHolds []HoldDisplay ··· 160 162 data.Profile.DefaultHold = profile.DefaultHold 161 163 data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged 162 164 data.Profile.OciClient = profile.OciClient 165 + data.Profile.AIAdvisorEnabled = profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled 166 + if h.BillingManager != nil { 167 + data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID) 168 + } 163 169 164 170 if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil { 165 171 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 472 478 // Cache locally 473 479 if h.DB != nil { 474 480 _ = db.UpdateUserOciClient(h.DB, user.DID, ociClient) 481 + } 482 + 483 + w.WriteHeader(http.StatusNoContent) 484 + } 485 + 486 + // UpdateAIAdvisorHandler handles toggling the AI Image Advisor setting 487 + type UpdateAIAdvisorHandler struct { 488 + BaseUIHandler 489 + } 490 + 491 + func (h *UpdateAIAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 492 + user := middleware.GetUser(r) 493 + if user == nil { 494 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 495 + return 496 + } 497 + 498 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 499 + 500 + profile, err := storage.GetProfile(r.Context(), client) 501 + if err != nil || profile == nil { 502 + http.Error(w, "Failed to fetch profile", http.StatusInternalServerError) 503 + return 504 + } 505 + 506 + // Toggle: nil/true → false, false → nil (default enabled) 507 + if profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled { 508 + f := false 509 + profile.AIAdvisorEnabled = &f 510 + } else { 511 + profile.AIAdvisorEnabled = nil 512 + } 513 + profile.UpdatedAt = time.Now() 514 + 515 + if err := storage.UpdateProfile(r.Context(), client, profile); err != nil { 516 + http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 517 + return 475 518 } 476 519 477 520 w.WriteHeader(http.StatusNoContent)
+1 -1
pkg/appview/handlers/subscription.go
··· 94 94 if r.TLS == nil { 95 95 scheme = "http" 96 96 } 97 - returnURL := scheme + "://" + h.SiteURL + "/settings#storage" 97 + returnURL := scheme + "://" + h.SiteURL + "/settings#billing" 98 98 99 99 resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL) 100 100 if err != nil {
+2 -3
pkg/appview/handlers/upgrade_banner.go
··· 129 129 w.WriteHeader(http.StatusNoContent) 130 130 return 131 131 } 132 - } else if currentManifest.IsManifestList || newerManifest.IsManifestList { 133 - // One is multi-arch, the other isn't — can't compare meaningfully 134 - // Still show a basic banner without layer/vuln details 135 132 } 133 + // If one is multi-arch and the other isn't, we can't match platforms — 134 + // fall through and show a basic banner without layer/vuln details. 136 135 137 136 // Fetch layers for both 138 137 currentDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, currentManifestForLayers.ID)
+1
pkg/appview/jetstream/backfill.go
··· 83 83 atproto.SailorProfileCollection, // io.atcr.sailor.profile 84 84 atproto.RepoPageCollection, // io.atcr.repo.page 85 85 atproto.StatsCollection, // io.atcr.hold.stats (from holds) 86 + atproto.DailyStatsCollection, // io.atcr.hold.stats.daily (from holds) 86 87 atproto.CaptainCollection, // io.atcr.hold.captain (from holds) 87 88 atproto.CrewCollection, // io.atcr.hold.crew (from holds) 88 89 atproto.ScanCollection, // io.atcr.hold.scan (from holds)
+42
pkg/appview/jetstream/processor.go
··· 289 289 case atproto.StatsCollection: 290 290 return p.ProcessStats(ctx, did, data, isDelete) 291 291 292 + case atproto.DailyStatsCollection: 293 + return p.ProcessDailyStats(ctx, did, data, isDelete) 294 + 292 295 case atproto.CaptainCollection: 293 296 if isDelete { 294 297 return db.DeleteCaptainRecord(p.db, did) ··· 353 356 if !isManifestList && manifestRecord.Config != nil { 354 357 manifest.ConfigDigest = manifestRecord.Config.Digest 355 358 manifest.ConfigSize = manifestRecord.Config.Size 359 + } 360 + 361 + // Track subject digest for attestation/referrer manifests 362 + if manifestRecord.Subject != nil { 363 + manifest.SubjectDigest = manifestRecord.Subject.Digest 356 364 } 357 365 358 366 // Insert manifest ··· 822 830 PushCount: int(totalPush), 823 831 LastPull: latestPull, 824 832 LastPush: latestPush, 833 + }) 834 + } 835 + 836 + // ProcessDailyStats handles daily stats record events from hold PDSes 837 + // This is called when Jetstream receives a daily stats create/update/delete event from a hold 838 + func (p *Processor) ProcessDailyStats(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error { 839 + var record atproto.DailyStatsRecord 840 + if err := json.Unmarshal(recordData, &record); err != nil { 841 + return fmt.Errorf("failed to unmarshal daily stats record: %w", err) 842 + } 843 + 844 + if isDelete { 845 + // Daily stats deletions are rare — just log and skip 846 + slog.Debug("Daily stats record deleted", 847 + "holdDID", holdDID, 848 + "ownerDID", record.OwnerDID, 849 + "repository", record.Repository, 850 + "date", record.Date) 851 + return nil 852 + } 853 + 854 + // Ensure the owner user exists (FK constraint) 855 + if record.OwnerDID != "" { 856 + if err := p.EnsureUserExists(ctx, record.OwnerDID); err != nil { 857 + return fmt.Errorf("failed to ensure daily stats owner user exists: %w", err) 858 + } 859 + } 860 + 861 + return db.UpsertDailyStats(p.db, &db.DailyStats{ 862 + DID: record.OwnerDID, 863 + Repository: record.Repository, 864 + Date: record.Date, 865 + PullCount: int(record.PullCount), 866 + PushCount: int(record.PushCount), 825 867 }) 826 868 } 827 869
+1
pkg/appview/jetstream/processor_test.go
··· 57 57 config_digest TEXT, 58 58 config_size INTEGER, 59 59 artifact_type TEXT NOT NULL DEFAULT 'container-image', 60 + subject_digest TEXT, 60 61 created_at TIMESTAMP NOT NULL, 61 62 UNIQUE(did, repository, digest) 62 63 );
+7
pkg/appview/public/icons.svg
··· 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 9 <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> 10 + <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> 10 11 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 11 12 <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> 12 13 <symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol> ··· 18 19 <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> 19 20 <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> 20 21 <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> 22 + <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> 21 23 <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> 22 24 <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> 23 25 <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> 24 26 <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> 25 27 <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> 28 + <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> 26 29 <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> 27 30 <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> 31 + <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> 28 32 <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> 29 33 <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> 30 34 <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 54 <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> 51 55 <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> 52 56 <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> 57 + <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> 58 + <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> 53 59 <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> 54 60 <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> 55 61 <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> 62 + <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> 56 63 <symbol id="terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol> 57 64 <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> 58 65 <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>
+6
pkg/appview/routes/routes.go
··· 44 44 ClientShortName string // Short name: "ATCR" 45 45 BillingManager *billing.Manager // Stripe billing manager (nil if not configured) 46 46 WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured) 47 + ClaudeAPIKey string // Anthropic API key for AI advisor (empty = disabled) 47 48 } 48 49 49 50 // RegisterUIRoutes registers all web UI and API routes on the provided router ··· 78 79 Jurisdiction: deps.LegalConfig.Jurisdiction, 79 80 ClientName: deps.ClientName, 80 81 ClientShortName: deps.ClientShortName, 82 + AIAdvisorEnabled: deps.ClaudeAPIKey != "", 81 83 } 82 84 83 85 // OAuth login routes (public) ··· 158 160 159 161 router.Get("/api/digest-content/{handle}/*", (&uihandlers.DigestContentHandler{BaseUIHandler: base}).ServeHTTP) 160 162 router.Get("/api/upgrade-banner/{handle}/*", (&uihandlers.UpgradeBannerHandler{BaseUIHandler: base}).ServeHTTP) 163 + router.Get("/api/image-advisor/{handle}/*", middleware.RequireAuth(deps.SessionStore, deps.Database)( 164 + &uihandlers.ImageAdvisorHandler{BaseUIHandler: base, ClaudeAPIKey: deps.ClaudeAPIKey}, 165 + ).ServeHTTP) 161 166 162 167 // Diff page: /diff/{handle}/{repo}?from=...&to=... 163 168 router.Get("/diff/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( ··· 177 182 r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) 178 183 r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP) 179 184 r.Post("/api/profile/oci-client", (&uihandlers.UpdateOciClientHandler{BaseUIHandler: base}).ServeHTTP) 185 + r.Post("/api/profile/ai-advisor", (&uihandlers.UpdateAIAdvisorHandler{BaseUIHandler: base}).ServeHTTP) 180 186 181 187 // Subscription management 182 188 r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP)
+4
pkg/appview/server.go
··· 144 144 }) 145 145 146 146 slog.Info("Configuration loaded successfully from environment") 147 + if cfg.AI.APIKey != "" { 148 + slog.Info("AI Image Advisor enabled") 149 + } 147 150 148 151 s := &AppViewServer{ 149 152 Config: cfg, ··· 330 333 ClientShortName: cfg.Server.ClientShortName, 331 334 BillingManager: s.BillingManager, 332 335 WebhookDispatcher: s.WebhookDispatcher, 336 + ClaudeAPIKey: cfg.AI.APIKey, 333 337 LegalConfig: routes.LegalConfig{ 334 338 CompanyName: cfg.Legal.CompanyName, 335 339 Jurisdiction: cfg.Legal.Jurisdiction,
+80
pkg/appview/templates/components/pull-command-switcher.html
··· 1 + {{ define "pull-command-switcher" }} 2 + {{/* 3 + Pull command with inline OCI client switcher. 4 + Expects dict with: RegistryURL, OwnerHandle, RepoName, Tag, ArtifactType, OciClient, IsLoggedIn 5 + 6 + For helm charts, shows helm command only (no switcher). 7 + For container images, shows a client dropdown that updates the command. 8 + Logged-in users: saves to profile via HTMX POST. 9 + Anonymous users: saves to localStorage. 10 + */}} 11 + {{ if eq .ArtifactType "helm-chart" }} 12 + <div class="space-y-2"> 13 + <p class="text-sm font-medium text-base-content/70">Pull this chart</p> 14 + {{ if .Tag }} 15 + {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Tag) }} 16 + {{ else }} 17 + {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName) }} 18 + {{ end }} 19 + </div> 20 + {{ else }} 21 + <div class="space-y-2" id="pull-cmd-container"> 22 + <p class="text-sm font-medium text-base-content/70">Pull this image</p> 23 + <div class="flex items-center gap-2"> 24 + <select id="oci-client-switcher" class="select select-xs select-bordered w-auto" 25 + onchange="updatePullCommand(this.value)"> 26 + <option value="docker"{{ if or (eq .OciClient "") (eq .OciClient "docker") }} selected{{ end }}>docker</option> 27 + <option value="podman"{{ if eq .OciClient "podman" }} selected{{ end }}>podman</option> 28 + <option value="nerdctl"{{ if eq .OciClient "nerdctl" }} selected{{ end }}>nerdctl</option> 29 + <option value="buildah"{{ if eq .OciClient "buildah" }} selected{{ end }}>buildah</option> 30 + <option value="crane"{{ if eq .OciClient "crane" }} selected{{ end }}>crane</option> 31 + </select> 32 + <div id="pull-cmd-display" class="flex-1 min-w-0"> 33 + {{ if .Tag }} 34 + {{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Tag) }} 35 + {{ else }} 36 + {{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":latest") }} 37 + {{ end }} 38 + </div> 39 + </div> 40 + </div> 41 + <script> 42 + (function() { 43 + var registryURL = {{ .RegistryURL }}; 44 + var ownerHandle = {{ .OwnerHandle }}; 45 + var repoName = {{ .RepoName }}; 46 + var tag = {{ if .Tag }}{{ .Tag }}{{ else }}"latest"{{ end }}; 47 + var isLoggedIn = {{ .IsLoggedIn }}; 48 + 49 + // Restore from localStorage for anonymous users 50 + if (!isLoggedIn) { 51 + var saved = localStorage.getItem('oci-client'); 52 + if (saved) { 53 + var sel = document.getElementById('oci-client-switcher'); 54 + if (sel) { 55 + sel.value = saved; 56 + updatePullCommand(saved); 57 + } 58 + } 59 + } 60 + 61 + window.updatePullCommand = function(client) { 62 + var cmd = client + ' pull ' + registryURL + '/' + ownerHandle + '/' + repoName + ':' + tag; 63 + var container = document.getElementById('pull-cmd-display'); 64 + if (!container) return; 65 + var code = container.querySelector('code'); 66 + if (code) code.textContent = cmd; 67 + var btn = container.querySelector('[data-cmd]'); 68 + if (btn) btn.dataset.cmd = cmd; 69 + 70 + // Persist preference 71 + if (isLoggedIn) { 72 + htmx.ajax('POST', '/api/profile/oci-client', {values: {oci_client: client}, swap: 'none'}); 73 + } else { 74 + localStorage.setItem('oci-client', client); 75 + } 76 + }; 77 + })(); 78 + </script> 79 + {{ end }} 80 + {{ end }}
+2
pkg/appview/templates/pages/digest.html
··· 72 72 htmx.ajax('GET', '/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest=' + encodeURIComponent(digest), {target: target, swap: 'innerHTML'}).then(function() { 73 73 loading.classList.add('hidden'); 74 74 }); 75 + 76 + 75 77 } 76 78 </script> 77 79 {{ end }}
+383 -330
pkg/appview/templates/pages/repository.html
··· 9 9 {{ template "nav" . }} 10 10 11 11 <main class="container mx-auto px-4 py-8"> 12 - <div class="space-y-8"> 13 - <!-- Repository Header --> 14 - <div class="card bg-base-100 shadow-sm p-6 space-y-6 w-full"> 12 + <div class="space-y-6"> 13 + <!-- Static Header: Identity + Metadata (does not change with tag) --> 14 + <div class="card bg-base-100 shadow-sm p-6 space-y-4 w-full"> 15 15 <div class="flex gap-4 items-start"> 16 16 {{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }} 17 17 <div class="flex-1 min-w-0"> ··· 26 26 </div> 27 27 </div> 28 28 29 - <!-- Star Button, Pull Count and Metadata Row --> 30 - <div class="flex flex-wrap items-center justify-between gap-4"> 31 - <div class="flex items-center gap-4"> 32 - {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }} 33 - {{ template "pull-count" (dict "PullCount" .PullCount) }} 29 + <!-- Metadata Row --> 30 + <div class="flex flex-wrap items-center gap-3"> 31 + <div class="flex items-center gap-3"> 32 + {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .Stats.StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }} 33 + {{ template "pull-count" (dict "PullCount" .Stats.PullCount) }} 34 + {{ if .TagCount }} 35 + <span class="flex items-center gap-1 text-sm text-base-content/70" title="{{ .TagCount }} tags"> 36 + {{ icon "tag" "size-4" }} {{ .TagCount }} 37 + </span> 38 + {{ end }} 39 + {{ if .Stats.LastPush }} 40 + <span class="text-sm text-base-content/50" title="Last pushed {{ (derefTime .Stats.LastPush).Format "2006-01-02T15:04:05Z07:00" }}"> 41 + Updated {{ timeAgoShort (derefTime .Stats.LastPush) }} 42 + </span> 43 + {{ end }} 34 44 </div> 35 45 36 - <!-- Metadata Section --> 37 46 {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }} 38 - <div class="flex flex-wrap items-center gap-2"> 47 + <div class="flex flex-wrap items-center gap-2 ml-auto"> 39 48 {{ if .Repository.Version }} 40 49 <span class="badge badge-md badge-primary badge-outline" title="Version"> 41 50 {{ .Repository.Version }} ··· 55 64 {{ end }} 56 65 {{ end }} 57 66 {{ if .Repository.SourceURL }} 58 - <a href="{{ .Repository.SourceURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View source code (opens in new tab)"> 59 - Source 67 + <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)"> 68 + {{ icon "external-link" "size-3" }} Source 60 69 </a> 61 70 {{ end }} 62 71 {{ if .Repository.DocumentationURL }} 63 - <a href="{{ .Repository.DocumentationURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View documentation (opens in new tab)"> 64 - Documentation 72 + <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)"> 73 + {{ icon "book-open" "size-3" }} Docs 65 74 </a> 66 75 {{ end }} 67 76 </div> 68 77 {{ end }} 69 78 </div> 70 - 71 - <div class="divider my-2"></div> 79 + </div> 72 80 73 - <!-- Pull Command --> 74 - <div class="space-y-2"> 75 - {{ if eq .ArtifactType "helm-chart" }} 76 - <p class="font-semibold">Pull this chart</p> 77 - {{ if .LatestTag }} 78 - {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .LatestTag) }} 79 - {{ else }} 80 - {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name) }} 81 + <!-- Tag Selector (stays in DOM, never swapped) --> 82 + {{ if .SelectedTag }} 83 + <div class="flex flex-wrap items-center gap-3"> 84 + <div class="flex items-center gap-2"> 85 + {{ icon "tag" "size-4 text-base-content/60" }} 86 + <select id="tag-selector" class="select select-sm select-bordered font-mono" 87 + hx-get="/r/{{ .Owner.Handle }}/{{ .Repository.Name }}" 88 + hx-target="#tag-content" 89 + hx-swap="outerHTML" 90 + hx-push-url="true" 91 + hx-include="this" 92 + name="tag"> 93 + {{ range .AllTags }} 94 + <option value="{{ . }}"{{ if eq . $.SelectedTag.Info.Tag.Tag }} selected{{ end }}>{{ . }}</option> 95 + {{ end }} 96 + </select> 97 + </div> 98 + {{ if .SelectedTag.Info.IsMultiArch }} 99 + <div id="platform-badges" class="flex flex-wrap items-center gap-1"> 100 + {{ range .SelectedTag.Info.Platforms }} 101 + <span class="badge badge-sm badge-outline font-mono">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 102 + {{ end }} 103 + </div> 104 + {{ else }} 105 + {{ $p := index .SelectedTag.Info.Platforms 0 }} 106 + {{ if $p.OS }} 107 + <div id="platform-badges"> 108 + <span class="badge badge-sm badge-outline font-mono">{{ $p.OS }}/{{ $p.Architecture }}{{ if $p.Variant }}/{{ $p.Variant }}{{ end }}</span> 109 + </div> 81 110 {{ end }} 82 - {{ else }} 83 - <p class="font-semibold">Pull this image</p> 84 - {{ if .LatestTag }} 85 - {{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }} 86 - {{ else }} 87 - {{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }} 88 - {{ end }} 89 - {{ end }} 90 - </div> 111 + {{ end }} 112 + {{ if .SelectedTag.Info.HasAttestations }} 113 + <button class="badge badge-sm badge-soft badge-success cursor-pointer hover:opacity-80" 114 + hx-get="/api/attestation-details?digest={{ .SelectedTag.Info.Digest | urlquery }}&did={{ .Owner.DID | urlquery }}&repo={{ .Repository.Name | urlquery }}" 115 + hx-target="#attestation-modal-body" 116 + hx-swap="innerHTML" 117 + onclick="document.getElementById('attestation-detail-modal').showModal()"> 118 + {{ icon "shield-check" "size-3" }} Attested 119 + </button> 120 + {{ end }} 91 121 </div> 122 + {{ end }} 92 123 93 - <!-- Tab Navigation --> 94 - <div class="border-b border-base-300"> 95 - <nav class="flex gap-0" role="tablist"> 96 - <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer" 97 - data-tab="overview" 98 - role="tab" 99 - onclick="switchRepoTab('overview')"> 100 - Overview 101 - </button> 102 - <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer" 103 - data-tab="artifacts" 104 - role="tab" 105 - id="artifacts-tab-btn" 106 - onclick="switchRepoTab('artifacts')"> 107 - Artifacts 108 - </button> 109 - </nav> 110 - </div> 124 + <!-- Tag-Scoped Content (swapped via HTMX on tag change) --> 125 + {{ template "repo-tag-section" . }} 111 126 112 - <!-- Tab Panels --> 113 - <!-- Overview Panel --> 114 - <div id="tab-overview" class="repo-panel"> 115 - <!-- View mode --> 116 - <div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0"> 117 - {{ if .IsOwner }} 118 - <div class="flex justify-end"> 119 - <button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)"> 120 - {{ icon "pencil" "size-4" }} 121 - Edit 127 + <!-- Inline Editor (hidden, owner only) — outside HTMX-swapped section so edits survive tag changes --> 128 + {{ if .IsOwner }} 129 + <div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden"> 130 + <!-- Write/Preview tabs --> 131 + <div class="border-b border-base-300 mb-4"> 132 + <nav class="flex gap-0" role="tablist"> 133 + <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary" 134 + data-tab="write" onclick="switchEditorTab('write')"> 135 + Write 136 + </button> 137 + <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60" 138 + data-tab="preview" onclick="switchEditorTab('preview')"> 139 + Preview 122 140 </button> 123 - </div> 124 - {{ end }} 125 - <div id="overview-rendered" class="prose prose-sm max-w-none"> 126 - {{ if .ReadmeHTML }} 127 - {{ .ReadmeHTML }} 128 - {{ else }} 129 - <p class="text-base-content/60">No description available</p> 130 - {{ end }} 131 - </div> 141 + </nav> 132 142 </div> 133 143 134 - <!-- Edit mode (hidden, owner only) --> 135 - {{ if .IsOwner }} 136 - <div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden"> 137 - <!-- Write/Preview tabs --> 138 - <div class="border-b border-base-300 mb-4"> 139 - <nav class="flex gap-0" role="tablist"> 140 - <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary" 141 - data-tab="write" onclick="switchEditorTab('write')"> 142 - Write 143 - </button> 144 - <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60" 145 - data-tab="preview" onclick="switchEditorTab('preview')"> 146 - Preview 147 - </button> 148 - </nav> 144 + <!-- Write panel --> 145 + <div id="editor-write" class="editor-panel"> 146 + <!-- Toolbar --> 147 + <div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg"> 148 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading"> 149 + {{ icon "heading" "size-4" }} 150 + </button> 151 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold"> 152 + {{ icon "bold" "size-4" }} 153 + </button> 154 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic"> 155 + {{ icon "italic" "size-4" }} 156 + </button> 157 + <div class="divider divider-horizontal mx-0"></div> 158 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link"> 159 + {{ icon "link" "size-4" }} 160 + </button> 161 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image"> 162 + {{ icon "image" "size-4" }} 163 + </button> 164 + <div class="divider divider-horizontal mx-0"></div> 165 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list"> 166 + {{ icon "list" "size-4" }} 167 + </button> 168 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list"> 169 + {{ icon "list-ordered" "size-4" }} 170 + </button> 171 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code"> 172 + {{ icon "code" "size-4" }} 173 + </button> 149 174 </div> 175 + <textarea id="md-editor" 176 + class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed" 177 + rows="20" 178 + placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea> 179 + </div> 150 180 151 - <!-- Write panel --> 152 - <div id="editor-write" class="editor-panel"> 153 - <!-- Toolbar --> 154 - <div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg"> 155 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading"> 156 - {{ icon "heading" "size-4" }} 157 - </button> 158 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold"> 159 - {{ icon "bold" "size-4" }} 160 - </button> 161 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic"> 162 - {{ icon "italic" "size-4" }} 163 - </button> 164 - <div class="divider divider-horizontal mx-0"></div> 165 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link"> 166 - {{ icon "link" "size-4" }} 167 - </button> 168 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image"> 169 - {{ icon "image" "size-4" }} 170 - </button> 171 - <div class="divider divider-horizontal mx-0"></div> 172 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list"> 173 - {{ icon "list" "size-4" }} 174 - </button> 175 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list"> 176 - {{ icon "list-ordered" "size-4" }} 177 - </button> 178 - <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code"> 179 - {{ icon "code" "size-4" }} 180 - </button> 181 - </div> 182 - <textarea id="md-editor" 183 - class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed" 184 - rows="20" 185 - placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea> 186 - </div> 187 - 188 - <!-- Preview panel --> 189 - <div id="editor-preview" class="editor-panel hidden"> 190 - <div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg"> 191 - <p class="text-base-content/60">Nothing to preview</p> 192 - </div> 193 - </div> 194 - 195 - <!-- Actions --> 196 - <div class="flex justify-end gap-2 mt-4"> 197 - <button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button> 198 - <button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button> 181 + <!-- Preview panel --> 182 + <div id="editor-preview" class="editor-panel hidden"> 183 + <div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg"> 184 + <p class="text-base-content/60">Nothing to preview</p> 199 185 </div> 200 186 </div> 201 187 202 - <script> 203 - (function() { 204 - var textarea = document.getElementById('md-editor'); 205 - if (!textarea) return; 206 - 207 - var ownerDID = {{ .Owner.DID }}; 208 - var repoName = {{ .Repository.Name }}; 209 - 210 - window.toggleOverviewEditor = function(show) { 211 - document.getElementById('overview-view').classList.toggle('hidden', show); 212 - document.getElementById('overview-edit').classList.toggle('hidden', !show); 213 - if (show) textarea.focus(); 214 - }; 215 - 216 - window.switchEditorTab = function(tab) { 217 - document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); }); 218 - document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden'); 219 - 220 - document.querySelectorAll('.editor-tab').forEach(function(t) { 221 - var active = t.dataset.tab === tab; 222 - t.classList.toggle('border-primary', active); 223 - t.classList.toggle('text-primary', active); 224 - t.classList.toggle('border-transparent', !active); 225 - t.classList.toggle('text-base-content/60', !active); 226 - }); 227 - 228 - if (tab === 'preview') { 229 - var content = textarea.value; 230 - var previewEl = document.getElementById('preview-content'); 231 - if (!content.trim()) { 232 - previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>'; 233 - return; 234 - } 235 - var form = new FormData(); 236 - form.append('markdown', content); 237 - fetch('/api/repo-page/preview', { method: 'POST', body: form }) 238 - .then(function(r) { return r.text(); }) 239 - .then(function(html) { previewEl.innerHTML = html; }); 240 - } 241 - }; 242 - 243 - window.insertMd = function(type) { 244 - var start = textarea.selectionStart; 245 - var end = textarea.selectionEnd; 246 - var selected = textarea.value.substring(start, end); 247 - var before = textarea.value.substring(0, start); 248 - var after = textarea.value.substring(end); 249 - var insert, cursorStart, cursorEnd; 250 - 251 - switch (type) { 252 - case 'heading': 253 - insert = '## ' + (selected || 'Heading'); 254 - cursorStart = start + 3; 255 - cursorEnd = start + insert.length; 256 - break; 257 - case 'bold': 258 - insert = '**' + (selected || 'bold text') + '**'; 259 - cursorStart = start + 2; 260 - cursorEnd = start + insert.length - 2; 261 - break; 262 - case 'italic': 263 - insert = '_' + (selected || 'italic text') + '_'; 264 - cursorStart = start + 1; 265 - cursorEnd = start + insert.length - 1; 266 - break; 267 - case 'link': 268 - insert = '[' + (selected || 'link text') + '](url)'; 269 - cursorStart = start + insert.length - 4; 270 - cursorEnd = start + insert.length - 1; 271 - break; 272 - case 'image': 273 - insert = '![' + (selected || 'alt text') + '](url)'; 274 - cursorStart = start + insert.length - 4; 275 - cursorEnd = start + insert.length - 1; 276 - break; 277 - case 'ul': 278 - insert = '- ' + (selected || 'list item'); 279 - cursorStart = start + 2; 280 - cursorEnd = start + insert.length; 281 - break; 282 - case 'ol': 283 - insert = '1. ' + (selected || 'list item'); 284 - cursorStart = start + 3; 285 - cursorEnd = start + insert.length; 286 - break; 287 - case 'code': 288 - if (selected && selected.indexOf('\n') !== -1) { 289 - insert = '```\n' + selected + '\n```'; 290 - cursorStart = start + 4; 291 - cursorEnd = start + 4 + selected.length; 292 - } else { 293 - insert = '`' + (selected || 'code') + '`'; 294 - cursorStart = start + 1; 295 - cursorEnd = start + insert.length - 1; 296 - } 297 - break; 298 - default: 299 - return; 300 - } 301 - 302 - textarea.value = before + insert + after; 303 - textarea.focus(); 304 - textarea.selectionStart = cursorStart; 305 - textarea.selectionEnd = cursorEnd; 306 - }; 307 - 308 - window.saveOverview = function() { 309 - var btn = document.getElementById('save-overview-btn'); 310 - btn.classList.add('btn-disabled'); 311 - btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...'; 312 - 313 - var form = new FormData(); 314 - form.append('did', ownerDID); 315 - form.append('repository', repoName); 316 - form.append('description', textarea.value); 317 - 318 - fetch('/api/repo-page', { 319 - method: 'POST', 320 - body: form, 321 - headers: { 'HX-Request': 'true' } 322 - }) 323 - .then(function(r) { 324 - if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); 325 - return r.text(); 326 - }) 327 - .then(function(html) { 328 - document.getElementById('overview-rendered').innerHTML = html; 329 - toggleOverviewEditor(false); 330 - if (typeof showToast === 'function') showToast('Overview saved', 'success'); 331 - }) 332 - .catch(function(err) { 333 - if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error'); 334 - }) 335 - .finally(function() { 336 - btn.classList.remove('btn-disabled'); 337 - btn.innerHTML = 'Save'; 338 - }); 339 - }; 340 - 341 - // Ctrl+S / Cmd+S to save 342 - textarea.addEventListener('keydown', function(e) { 343 - if ((e.ctrlKey || e.metaKey) && e.key === 's') { 344 - e.preventDefault(); 345 - saveOverview(); 346 - } 347 - }); 348 - })(); 349 - </script> 350 - {{ end }} 351 - </div> 352 - 353 - <!-- Tags Panel --> 354 - <div id="tab-artifacts" class="repo-panel hidden"> 355 - <div id="tags-content"> 356 - <div class="flex justify-center py-12"> 357 - <span class="loading loading-spinner loading-lg"></span> 358 - </div> 188 + <!-- Actions --> 189 + <div class="flex justify-end gap-2 mt-4"> 190 + <button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button> 191 + <button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button> 359 192 </div> 360 193 </div> 194 + {{ end }} 361 195 </div> 362 196 </main> 363 197 ··· 412 246 <form method="dialog" class="modal-backdrop"><button>close</button></form> 413 247 </dialog> 414 248 249 + {{ if .IsOwner }} 415 250 <script> 416 251 (function() { 417 - var validTabs = ['overview', 'artifacts']; 418 - var tagsLoading = false; 252 + var textarea = document.getElementById('md-editor'); 253 + if (!textarea) return; 419 254 420 - function loadTags() { 421 - if (tagsLoading) return; 422 - tagsLoading = true; 423 - var target = document.getElementById('tags-content'); 424 - fetch('/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}') 255 + var ownerDID = {{ .Owner.DID }}; 256 + var repoName = {{ .Repository.Name }}; 257 + 258 + window.toggleOverviewEditor = function(show) { 259 + document.getElementById('overview-view').classList.toggle('hidden', show); 260 + document.getElementById('overview-edit').classList.toggle('hidden', !show); 261 + if (show) textarea.focus(); 262 + }; 263 + 264 + window.switchEditorTab = function(tab) { 265 + document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); }); 266 + document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden'); 267 + 268 + document.querySelectorAll('.editor-tab').forEach(function(t) { 269 + var active = t.dataset.tab === tab; 270 + t.classList.toggle('border-primary', active); 271 + t.classList.toggle('text-primary', active); 272 + t.classList.toggle('border-transparent', !active); 273 + t.classList.toggle('text-base-content/60', !active); 274 + }); 275 + 276 + if (tab === 'preview') { 277 + var content = textarea.value; 278 + var previewEl = document.getElementById('preview-content'); 279 + if (!content.trim()) { 280 + previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>'; 281 + return; 282 + } 283 + var form = new FormData(); 284 + form.append('markdown', content); 285 + fetch('/api/repo-page/preview', { method: 'POST', body: form }) 286 + .then(function(r) { return r.text(); }) 287 + .then(function(html) { previewEl.innerHTML = html; }); 288 + } 289 + }; 290 + 291 + window.insertMd = function(type) { 292 + var start = textarea.selectionStart; 293 + var end = textarea.selectionEnd; 294 + var selected = textarea.value.substring(start, end); 295 + var before = textarea.value.substring(0, start); 296 + var after = textarea.value.substring(end); 297 + var insert, cursorStart, cursorEnd; 298 + 299 + switch (type) { 300 + case 'heading': 301 + insert = '## ' + (selected || 'Heading'); 302 + cursorStart = start + 3; 303 + cursorEnd = start + insert.length; 304 + break; 305 + case 'bold': 306 + insert = '**' + (selected || 'bold text') + '**'; 307 + cursorStart = start + 2; 308 + cursorEnd = start + insert.length - 2; 309 + break; 310 + case 'italic': 311 + insert = '_' + (selected || 'italic text') + '_'; 312 + cursorStart = start + 1; 313 + cursorEnd = start + insert.length - 1; 314 + break; 315 + case 'link': 316 + insert = '[' + (selected || 'link text') + '](url)'; 317 + cursorStart = start + insert.length - 4; 318 + cursorEnd = start + insert.length - 1; 319 + break; 320 + case 'image': 321 + insert = '![' + (selected || 'alt text') + '](url)'; 322 + cursorStart = start + insert.length - 4; 323 + cursorEnd = start + insert.length - 1; 324 + break; 325 + case 'ul': 326 + insert = '- ' + (selected || 'list item'); 327 + cursorStart = start + 2; 328 + cursorEnd = start + insert.length; 329 + break; 330 + case 'ol': 331 + insert = '1. ' + (selected || 'list item'); 332 + cursorStart = start + 3; 333 + cursorEnd = start + insert.length; 334 + break; 335 + case 'code': 336 + if (selected && selected.indexOf('\n') !== -1) { 337 + insert = '```\n' + selected + '\n```'; 338 + cursorStart = start + 4; 339 + cursorEnd = start + 4 + selected.length; 340 + } else { 341 + insert = '`' + (selected || 'code') + '`'; 342 + cursorStart = start + 1; 343 + cursorEnd = start + insert.length - 1; 344 + } 345 + break; 346 + default: 347 + return; 348 + } 349 + 350 + textarea.value = before + insert + after; 351 + textarea.focus(); 352 + textarea.selectionStart = cursorStart; 353 + textarea.selectionEnd = cursorEnd; 354 + }; 355 + 356 + window.saveOverview = function() { 357 + var btn = document.getElementById('save-overview-btn'); 358 + btn.classList.add('btn-disabled'); 359 + btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...'; 360 + 361 + var form = new FormData(); 362 + form.append('did', ownerDID); 363 + form.append('repository', repoName); 364 + form.append('description', textarea.value); 365 + 366 + fetch('/api/repo-page', { 367 + method: 'POST', 368 + body: form, 369 + headers: { 'HX-Request': 'true' } 370 + }) 371 + .then(function(r) { 372 + if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); 373 + return r.text(); 374 + }) 375 + .then(function(html) { 376 + document.getElementById('overview-rendered').innerHTML = html; 377 + toggleOverviewEditor(false); 378 + if (typeof showToast === 'function') showToast('Overview saved', 'success'); 379 + }) 380 + .catch(function(err) { 381 + if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error'); 382 + }) 383 + .finally(function() { 384 + btn.classList.remove('btn-disabled'); 385 + btn.innerHTML = 'Save'; 386 + }); 387 + }; 388 + 389 + // Ctrl+S / Cmd+S to save 390 + textarea.addEventListener('keydown', function(e) { 391 + if ((e.ctrlKey || e.metaKey) && e.key === 's') { 392 + e.preventDefault(); 393 + saveOverview(); 394 + } 395 + }); 396 + })(); 397 + </script> 398 + {{ end }} 399 + 400 + <script> 401 + // Global helpers (tags sort/filter) 402 + window.sortTags = function(method) { 403 + var container = document.getElementById('tags-list'); 404 + if (!container) return; 405 + var entries = Array.from(container.querySelectorAll('.artifact-entry')); 406 + entries.sort(function(a, b) { 407 + switch (method) { 408 + case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created); 409 + case 'az': return a.dataset.tag.localeCompare(b.dataset.tag); 410 + case 'za': return b.dataset.tag.localeCompare(a.dataset.tag); 411 + default: return parseInt(b.dataset.created) - parseInt(a.dataset.created); 412 + } 413 + }); 414 + entries.forEach(function(el) { container.appendChild(el); }); 415 + }; 416 + 417 + window.filterTags = function(query) { 418 + var q = query.toLowerCase(); 419 + document.querySelectorAll('#tags-list .artifact-entry').forEach(function(el) { 420 + el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none'; 421 + }); 422 + }; 423 + 424 + // Tab controller — reads config from #tag-content data attributes. 425 + // Re-runs on initial load and after every HTMX swap of #tag-content. 426 + (function() { 427 + var validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts']; 428 + var loaded = {}; 429 + 430 + function lazyLoad(id, url) { 431 + if (loaded[id]) return; 432 + loaded[id] = true; 433 + var target = document.getElementById(id); 434 + if (!target) return; 435 + fetch(url) 425 436 .then(function(r) { return r.text(); }) 426 437 .then(function(html) { 427 438 target.innerHTML = html; 428 - htmx.process(target); 439 + // innerHTML doesn't execute <script> tags — re-create them 440 + target.querySelectorAll('script').forEach(function(old) { 441 + var s = document.createElement('script'); 442 + s.textContent = old.textContent; 443 + old.parentNode.replaceChild(s, old); 444 + }); 445 + if (typeof htmx !== 'undefined') htmx.process(target); 429 446 }); 430 447 } 431 448 449 + function contentUrl(section) { 450 + var el = document.getElementById('tag-content'); 451 + if (!el) return null; 452 + var owner = el.dataset.owner; 453 + var repo = el.dataset.repo; 454 + var digest = el.dataset.digest; 455 + if (!digest) return null; 456 + return '/api/digest-content/' + owner + '/' + repo + '?digest=' + encodeURIComponent(digest) + '&section=' + section; 457 + } 458 + 459 + function tagsUrl() { 460 + var el = document.getElementById('tag-content'); 461 + if (!el) return null; 462 + return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo; 463 + } 464 + 432 465 window.switchRepoTab = function(tabId) { 433 - document.querySelectorAll('.repo-panel').forEach(function(p) { 466 + window._activeRepoTab = tabId; 467 + var section = document.getElementById('tag-content'); 468 + if (!section) return; 469 + 470 + section.querySelectorAll('.repo-panel').forEach(function(p) { 434 471 p.classList.add('hidden'); 435 472 }); 436 473 var panel = document.getElementById('tab-' + tabId); 437 474 if (panel) panel.classList.remove('hidden'); 438 475 439 - document.querySelectorAll('.repo-tab').forEach(function(tab) { 476 + section.querySelectorAll('.repo-tab').forEach(function(tab) { 440 477 if (tab.dataset.tab === tabId) { 441 478 tab.classList.add('border-primary', 'text-primary'); 442 - tab.classList.remove('border-transparent', 'text-base-content/60', 'hover:text-base-content'); 479 + tab.classList.remove('border-transparent', 'text-base-content/60'); 443 480 } else { 444 481 tab.classList.remove('border-primary', 'text-primary'); 445 - tab.classList.add('border-transparent', 'text-base-content/60', 'hover:text-base-content'); 482 + tab.classList.add('border-transparent', 'text-base-content/60'); 446 483 } 447 484 }); 448 485 449 - history.replaceState(null, '', '#' + tabId); 450 - if (tabId === 'artifacts') loadTags(); 486 + var url = new URL(window.location); 487 + url.hash = tabId; 488 + history.replaceState(null, '', url.toString()); 489 + 490 + if (tabId === 'artifacts') { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); } 491 + if (tabId === 'layers') { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); } 492 + if (tabId === 'vulns') { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); } 493 + if (tabId === 'sbom') { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); } 451 494 }; 452 495 453 - window.sortTags = function(method) { 454 - var container = document.getElementById('tags-list'); 455 - if (!container) return; 456 - var entries = Array.from(container.querySelectorAll('.artifact-entry')); 457 - entries.sort(function(a, b) { 458 - switch (method) { 459 - case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created); 460 - case 'az': return a.dataset.tag.localeCompare(b.dataset.tag); 461 - case 'za': return b.dataset.tag.localeCompare(a.dataset.tag); 462 - default: return parseInt(b.dataset.created) - parseInt(a.dataset.created); 463 - } 464 - }); 465 - entries.forEach(function(el) { container.appendChild(el); }); 466 - }; 496 + function initTabs() { 497 + // Reset lazy-load tracking (new tag = new content) 498 + loaded = {}; 499 + 500 + // Prefetch on hover 501 + var tagsBtn = document.getElementById('artifacts-tab-btn'); 502 + if (tagsBtn) tagsBtn.addEventListener('mouseenter', function() { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }, { once: true }); 503 + var layersBtn = document.getElementById('layers-tab-btn'); 504 + if (layersBtn) layersBtn.addEventListener('mouseenter', function() { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }, { once: true }); 505 + var vulnsBtn = document.getElementById('vulns-tab-btn'); 506 + if (vulnsBtn) vulnsBtn.addEventListener('mouseenter', function() { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }, { once: true }); 507 + var sbomBtn = document.getElementById('sbom-tab-btn'); 508 + if (sbomBtn) sbomBtn.addEventListener('mouseenter', function() { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }, { once: true }); 509 + 510 + // Pick tab: persisted > hash > default 511 + var initTab = window._activeRepoTab || window.location.hash.replace('#', '') || 'overview'; 512 + if (validTabs.indexOf(initTab) === -1) initTab = 'overview'; 513 + switchRepoTab(initTab); 514 + } 467 515 468 - window.filterTags = function(query) { 469 - var q = query.toLowerCase(); 470 - document.querySelectorAll('#tags-list .artifact-entry').forEach(function(el) { 471 - el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none'; 472 - }); 473 - }; 516 + // Run on initial page load 517 + initTabs(); 474 518 475 - // Prefetch on hover 476 - document.getElementById('artifacts-tab-btn').addEventListener('mouseenter', loadTags, { once: true }); 519 + // Keyboard shortcuts: first letter of each tab name 520 + document.addEventListener('keydown', function(e) { 521 + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || 522 + e.target.tagName === 'SELECT' || e.target.isContentEditable) return; 523 + if (e.ctrlKey || e.metaKey || e.altKey) return; 524 + var map = { o: 'overview', l: 'layers', v: 'vulns', s: 'sbom', a: 'artifacts' }; 525 + var tab = map[e.key.toLowerCase()]; 526 + if (tab && validTabs.indexOf(tab) !== -1) switchRepoTab(tab); 527 + }); 477 528 478 - // Initialize tab from hash 479 - var hash = window.location.hash.replace('#', '') || 'overview'; 480 - if (validTabs.indexOf(hash) === -1) hash = 'overview'; 481 - switchRepoTab(hash); 529 + // Re-run after HTMX swaps tag section content (tag dropdown change) 530 + document.body.addEventListener('htmx:afterSettle', function(evt) { 531 + if (evt.detail.target && evt.detail.target.id === 'tag-content') { 532 + initTabs(); 533 + } 534 + }); 482 535 })(); 483 536 </script> 484 537
+37 -3
pkg/appview/templates/pages/settings.html
··· 22 22 <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user"> 23 23 {{ icon "user" "size-4" }} User 24 24 </button> 25 + <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="billing"> 26 + {{ icon "credit-card" "size-4" }} Billing 27 + </button> 25 28 <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage"> 26 29 {{ icon "hard-drive" "size-4" }} Storage 27 30 </button> ··· 41 44 <aside class="hidden lg:block w-56 shrink-0"> 42 45 <ul class="menu bg-base-200 rounded-box w-full"> 43 46 <li data-tab="user"><a href="#user">{{ icon "user" "size-4" }} User</a></li> 47 + <li data-tab="billing"><a href="#billing">{{ icon "credit-card" "size-4" }} Billing</a></li> 44 48 <li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li> 45 49 <li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li> 46 50 <li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li> ··· 79 83 <option value="crane"{{ if eq $oci "crane" }} selected{{ end }}>crane</option> 80 84 </select> 81 85 </div> 86 + 87 + <!-- AI Image Advisor Toggle --> 88 + {{ if .AIAdvisorEnabled }} 89 + <div class="divider my-2"></div> 90 + <div class="flex items-start gap-3"> 91 + {{ if .Profile.HasAIAdvisorAccess }} 92 + <label class="flex items-start gap-3 cursor-pointer"> 93 + <input type="checkbox" class="toggle toggle-primary mt-0.5" 94 + hx-post="/api/profile/ai-advisor" 95 + hx-trigger="change" 96 + hx-swap="none" 97 + {{ if .Profile.AIAdvisorEnabled }}checked{{ end }}> 98 + <div> 99 + <span class="font-medium">AI Image Advisor</span> 100 + <p class="text-xs text-base-content/60">Analyze your container images for optimization suggestions using AI.</p> 101 + </div> 102 + </label> 103 + {{ else }} 104 + <div> 105 + <span class="font-medium text-base-content/50">AI Image Advisor</span> 106 + <p class="text-xs text-base-content/50">Analyze your container images for optimization suggestions using AI.</p> 107 + <p class="text-xs text-primary mt-1"> 108 + <a href="/settings#billing" onclick="switchSettingsTab('billing')">Upgrade your plan</a> to enable this feature. 109 + </p> 110 + </div> 111 + {{ end }} 112 + </div> 113 + {{ end }} 82 114 </section> 83 115 </div> 84 116 85 117 <!-- STORAGE TAB --> 86 118 <div id="tab-storage" class="settings-panel hidden space-y-4"> 87 - <!-- Available Plans --> 88 - {{ template "subscription_plans" .Subscription }} 89 - 90 119 <!-- Holds --> 91 120 {{ if .AllHolds }} 92 121 <div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> ··· 130 159 </div> 131 160 </label> 132 161 </section> 162 + </div> 163 + 164 + <!-- BILLING TAB --> 165 + <div id="tab-billing" class="settings-panel hidden space-y-4"> 166 + {{ template "subscription_plans" .Subscription }} 133 167 </div> 134 168 135 169 <!-- DEVICES TAB -->
+2 -2
pkg/appview/templates/partials/digest-content.html
··· 37 37 </div> 38 38 <script> 39 39 (function() { 40 - var showEmpty = localStorage.getItem('showEmptyLayers') !== 'false'; 40 + var showEmpty = localStorage.getItem('showEmptyLayers') === 'true'; 41 41 document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; }); 42 42 43 43 window.toggleEmptyLayers = function(show) { ··· 105 105 } 106 106 107 107 function applyLayerVisibility() { 108 - var show = localStorage.getItem('showEmptyLayers') !== 'false'; 108 + var show = localStorage.getItem('showEmptyLayers') === 'true'; 109 109 document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) { 110 110 row.style.display = show ? '' : 'none'; 111 111 });
+75
pkg/appview/templates/partials/image-advisor-results.html
··· 1 + {{ define "image-advisor-results" }} 2 + {{ if eq .Error "upgrade_required" }} 3 + <div class="alert alert-info text-sm"> 4 + {{ icon "sparkles" "size-4" }} 5 + <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> 6 + </div> 7 + {{ else if .Error }} 8 + <div class="alert alert-warning text-sm"> 9 + {{ icon "alert-triangle" "size-4" }} 10 + <span>{{ .Error }}</span> 11 + </div> 12 + {{ else if .Suggestions }} 13 + <div class="card bg-base-100 shadow-sm border border-base-300"> 14 + <div class="card-body p-4 space-y-3"> 15 + <h3 class="text-sm font-semibold flex items-center gap-2"> 16 + {{ icon "sparkle" "size-4" }} 17 + AI Suggestions ({{ len .Suggestions }}) 18 + </h3> 19 + <div class="overflow-x-auto"> 20 + <table class="table table-xs w-full"> 21 + <thead> 22 + <tr> 23 + <th>Action</th> 24 + <th>Category</th> 25 + <th>Impact</th> 26 + <th>Effort</th> 27 + <th class="w-1/2">Detail</th> 28 + </tr> 29 + </thead> 30 + <tbody> 31 + {{ range .Suggestions }} 32 + <tr> 33 + <td class="font-medium text-sm">{{ .Action }}</td> 34 + <td> 35 + <span class="badge badge-sm badge-ghost whitespace-nowrap">{{ .Category }}</span> 36 + </td> 37 + <td> 38 + {{ if eq .Impact "high" }} 39 + <span class="badge badge-sm badge-error">high</span> 40 + {{ else if eq .Impact "medium" }} 41 + <span class="badge badge-sm badge-warning">medium</span> 42 + {{ else }} 43 + <span class="badge badge-sm badge-info">low</span> 44 + {{ end }} 45 + </td> 46 + <td> 47 + {{ if eq .Effort "low" }} 48 + <span class="badge badge-sm badge-success">low</span> 49 + {{ else if eq .Effort "medium" }} 50 + <span class="badge badge-sm badge-warning">medium</span> 51 + {{ else }} 52 + <span class="badge badge-sm badge-ghost">high</span> 53 + {{ end }} 54 + </td> 55 + <td class="text-xs max-w-xs"> 56 + {{ .Detail }} 57 + {{ if gt .CVEsFixed 0 }} 58 + <span class="badge badge-xs badge-outline badge-error ml-1">{{ .CVEsFixed }} CVEs</span> 59 + {{ end }} 60 + {{ if gt .SizeSavedMB 0 }} 61 + <span class="badge badge-xs badge-outline badge-info ml-1">-{{ .SizeSavedMB }}MB</span> 62 + {{ end }} 63 + </td> 64 + </tr> 65 + {{ end }} 66 + </tbody> 67 + </table> 68 + </div> 69 + <p class="text-xs opacity-50">Generated by Claude Haiku. Suggestions are advisory only.</p> 70 + </div> 71 + </div> 72 + {{ else }} 73 + <p class="text-sm opacity-60">No suggestions generated.</p> 74 + {{ end }} 75 + {{ end }}
+119
pkg/appview/templates/partials/layers-section.html
··· 1 + {{ define "layers-section" }} 2 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 3 + <div class="flex items-center justify-between"> 4 + <h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2> 5 + <label class="flex items-center gap-2 text-sm cursor-pointer"> 6 + <input type="checkbox" class="checkbox checkbox-xs show-empty-layers-cb" onchange="toggleEmptyLayers(this.checked)"> 7 + <span>Show empty layers</span> 8 + </label> 9 + </div> 10 + {{ if .Layers }} 11 + <div class="overflow-x-auto"> 12 + <table class="table table-xs w-full layers-table"> 13 + <thead> 14 + <tr class="text-xs"> 15 + <th class="w-8">#</th> 16 + <th>Command</th> 17 + <th class="text-right w-24">Size</th> 18 + </tr> 19 + </thead> 20 + <tbody> 21 + {{ range .Layers }} 22 + <tr data-empty="{{ .EmptyLayer }}" data-no-command="{{ and (not .Command) (not .EmptyLayer) }}"> 23 + <td class="font-mono text-xs">{{ .Index }}</td> 24 + <td> 25 + {{ if .Command }} 26 + <code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code> 27 + {{ end }} 28 + </td> 29 + <td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td> 30 + </tr> 31 + {{ end }} 32 + </tbody> 33 + </table> 34 + </div> 35 + <script> 36 + (function() { 37 + var showEmpty = localStorage.getItem('showEmptyLayers') === 'true'; 38 + document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; }); 39 + 40 + window.toggleEmptyLayers = function(show) { 41 + localStorage.setItem('showEmptyLayers', show); 42 + document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = show; }); 43 + applyLayerVisibility(); 44 + }; 45 + 46 + function collapseNoHistoryLayers() { 47 + document.querySelectorAll('.layers-table').forEach(function(table) { 48 + var tbody = table.querySelector('tbody'); 49 + if (!tbody) return; 50 + 51 + var rows = Array.from(tbody.querySelectorAll('tr')); 52 + var i = 0; 53 + while (i < rows.length) { 54 + if (rows[i].dataset.noCommand === 'true') { 55 + var start = i; 56 + while (i < rows.length && rows[i].dataset.noCommand === 'true') { 57 + rows[i].classList.add('no-history-row', 'hidden'); 58 + i++; 59 + } 60 + var count = i - start; 61 + if (count > 1) { 62 + var totalBytes = 0; 63 + for (var k = start; k < start + count; k++) { 64 + var sizeCell = rows[k].querySelector('td:last-child'); 65 + if (sizeCell) { 66 + var txt = sizeCell.textContent.trim(); 67 + var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i); 68 + if (match) { 69 + var val = parseFloat(match[1]); 70 + var unit = match[2].toUpperCase(); 71 + var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776}; 72 + totalBytes += val * (multipliers[unit] || 1); 73 + } 74 + } 75 + } 76 + var sizeStr = ''; 77 + if (totalBytes < 1024) sizeStr = totalBytes + ' B'; 78 + else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB'; 79 + else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB'; 80 + else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB'; 81 + 82 + var startIdx = rows[start].querySelector('td').textContent.trim(); 83 + var endIdx = rows[i - 1].querySelector('td').textContent.trim(); 84 + var summary = document.createElement('tr'); 85 + summary.className = 'no-history-summary cursor-pointer hover:bg-base-200'; 86 + 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>'; 87 + summary.onclick = function() { 88 + summary.remove(); 89 + for (var j = start; j < start + count; j++) { 90 + rows[j].classList.remove('hidden'); 91 + } 92 + }; 93 + tbody.insertBefore(summary, rows[start]); 94 + } else { 95 + rows[start].classList.remove('hidden'); 96 + } 97 + } else { 98 + i++; 99 + } 100 + } 101 + }); 102 + } 103 + 104 + function applyLayerVisibility() { 105 + var show = localStorage.getItem('showEmptyLayers') === 'true'; 106 + document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) { 107 + row.style.display = show ? '' : 'none'; 108 + }); 109 + } 110 + 111 + collapseNoHistoryLayers(); 112 + applyLayerVisibility(); 113 + })(); 114 + </script> 115 + {{ else }} 116 + <p class="text-base-content">No layer information available</p> 117 + {{ end }} 118 + </div> 119 + {{ end }}
+210
pkg/appview/templates/partials/repo-tag-section.html
··· 1 + {{ define "repo-tag-section" }} 2 + <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 }}> 3 + {{ if .SelectedTag }} 4 + <!-- Pull Command with Client Switcher --> 5 + {{ 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)) }} 6 + 7 + <!-- Stats Cards --> 8 + <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4"> 9 + <!-- Size & Layers --> 10 + <div class="card bg-base-100 border border-base-300 p-4"> 11 + <div class="flex items-center justify-between mb-2"> 12 + <span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Image Size</span> 13 + <span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Layers</span> 14 + </div> 15 + <div class="flex items-center justify-between"> 16 + <span class="text-lg font-bold">{{ humanizeBytes .SelectedTag.CompressedSize }}</span> 17 + <span class="text-lg font-bold">{{ .SelectedTag.LayerCount }}</span> 18 + </div> 19 + <div class="text-xs text-base-content/50 mt-1"> 20 + Pushed {{ timeAgoShort .SelectedTag.Info.CreatedAt }} 21 + </div> 22 + </div> 23 + 24 + <!-- Vulnerabilities (first platform only for summary) --> 25 + <div class="card bg-base-100 border border-base-300 p-4"> 26 + <div class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Vulnerabilities</div> 27 + <div id="vuln-summary-card"> 28 + {{ $firstPlatform := index .SelectedTag.Info.Platforms 0 }} 29 + <span id="scan-badge-{{ trimPrefix "sha256:" $firstPlatform.Digest }}"></span> 30 + <span id="vuln-loading-text" class="text-sm text-base-content/40">Loading...</span> 31 + </div> 32 + </div> 33 + 34 + <!-- Pulls --> 35 + <div class="card bg-base-100 border border-base-300 p-4"> 36 + <div class="flex items-center justify-between mb-2"> 37 + <span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Pulls</span> 38 + </div> 39 + <div class="text-lg font-bold">{{ .Stats.PullCount }} <span class="text-sm font-normal text-base-content/50">total</span></div> 40 + <div class="text-xs text-base-content/50 mt-1"> 41 + {{ if .Stats.LastPull }}Last pull {{ timeAgoShort (derefTime .Stats.LastPull) }}{{ else }}No pulls yet{{ end }} 42 + </div> 43 + </div> 44 + </div> 45 + 46 + <!-- Scan batch triggers for selected tag --> 47 + {{ range .SelectedTag.ScanBatchParams }} 48 + <div hx-get="/api/scan-results?{{ . }}" 49 + hx-trigger="load delay:500ms" 50 + hx-swap="none" 51 + hx-on::after-request="var el=document.getElementById('vuln-loading-text');if(el)el.remove()" 52 + style="display:none"></div> 53 + {{ end }} 54 + 55 + {{ else }} 56 + <!-- No tags exist --> 57 + <div class="text-center py-8 text-base-content/60"> 58 + <p class="text-lg">No tags yet</p> 59 + <p class="text-sm mt-1">Push an image to get started.</p> 60 + </div> 61 + {{ end }} 62 + 63 + <!-- Tab Navigation --> 64 + <div class="border-b border-base-300 mt-6"> 65 + <nav class="flex gap-0" role="tablist"> 66 + <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" 67 + data-tab="overview" 68 + role="tab" 69 + onclick="switchRepoTab('overview')"> 70 + Overview 71 + </button> 72 + {{ if .SelectedTag }} 73 + <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" 74 + data-tab="layers" 75 + role="tab" 76 + id="layers-tab-btn" 77 + onclick="switchRepoTab('layers')"> 78 + Layers 79 + </button> 80 + <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" 81 + data-tab="vulns" 82 + role="tab" 83 + id="vulns-tab-btn" 84 + onclick="switchRepoTab('vulns')"> 85 + Vulnerabilities 86 + </button> 87 + <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" 88 + data-tab="sbom" 89 + role="tab" 90 + id="sbom-tab-btn" 91 + onclick="switchRepoTab('sbom')"> 92 + SBOM 93 + </button> 94 + {{ end }} 95 + <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" 96 + data-tab="artifacts" 97 + role="tab" 98 + id="artifacts-tab-btn" 99 + onclick="switchRepoTab('artifacts')"> 100 + Artifacts 101 + </button> 102 + {{ if and .SelectedTag (gt (len .AllTags) 1) }} 103 + <div class="ml-auto flex items-center"> 104 + <div class="dropdown dropdown-end"> 105 + <label tabindex="0" class="btn btn-ghost btn-sm gap-1"> 106 + {{ icon "git-compare" "size-4" }} Diff 107 + </label> 108 + <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"> 109 + {{ range .AllTags }} 110 + {{ if ne . $.SelectedTag.Info.Tag.Tag }} 111 + <li><a href="/diff/{{ $.Owner.Handle }}/{{ $.Repository.Name }}?from={{ $.SelectedTag.Info.Digest }}&to={{ . }}">{{ . }}</a></li> 112 + {{ end }} 113 + {{ end }} 114 + </ul> 115 + </div> 116 + </div> 117 + {{ end }} 118 + </nav> 119 + </div> 120 + 121 + <!-- Tab Panels --> 122 + <div id="tab-overview" class="repo-panel"> 123 + <div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0"> 124 + {{ if and .AIAdvisorEnabled .User .IsOwner .SelectedTag }} 125 + <div id="ai-advisor-section"> 126 + <button id="ai-advisor-btn" class="btn btn-sm btn-outline gap-1" 127 + 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 }}" 128 + hx-target="#ai-advisor-results" 129 + hx-swap="innerHTML" 130 + hx-indicator="#ai-advisor-spinner" 131 + hx-on::after-request="this.disabled=true"> 132 + {{ icon "sparkles" "size-4" }} 133 + Analyze Image 134 + </button> 135 + <span id="ai-advisor-spinner" class="htmx-indicator"> 136 + {{ icon "loader" "size-4 animate-spin" }} 137 + </span> 138 + <div id="ai-advisor-results" class="mt-4"></div> 139 + </div> 140 + {{ end }} 141 + 142 + {{ if and .IsOwner .ReadmeHTML }} 143 + <div class="flex justify-end"> 144 + <button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)"> 145 + {{ icon "pencil" "size-4" }} 146 + Edit 147 + </button> 148 + </div> 149 + {{ end }} 150 + 151 + <div id="overview-rendered" class="prose prose-sm max-w-none"> 152 + {{ if .ReadmeHTML }} 153 + {{ .ReadmeHTML }} 154 + {{ else }} 155 + {{ if .IsOwner }} 156 + <div class="text-center py-12"> 157 + {{ icon "file-text" "size-12 text-base-content/20 mx-auto" }} 158 + <p class="text-base-content/60 mt-4">No README provided</p> 159 + <p class="text-base-content/40 text-sm mt-1">Add a README to help users understand this image.</p> 160 + <button class="btn btn-primary btn-sm mt-4" onclick="toggleOverviewEditor(true)"> 161 + {{ icon "pencil" "size-4" }} Add README 162 + </button> 163 + </div> 164 + {{ else }} 165 + <div class="text-center py-12"> 166 + {{ icon "file-text" "size-12 text-base-content/20 mx-auto" }} 167 + <p class="text-base-content/60 mt-4">No README provided</p> 168 + <p class="text-base-content/40 text-sm mt-1">Image metadata is shown above.</p> 169 + </div> 170 + {{ end }} 171 + {{ end }} 172 + </div> 173 + </div> 174 + </div> 175 + 176 + {{ if .SelectedTag }} 177 + <div id="tab-layers" class="repo-panel hidden"> 178 + <div id="layers-content"> 179 + <div class="flex justify-center py-12"> 180 + <span class="loading loading-spinner loading-lg"></span> 181 + </div> 182 + </div> 183 + </div> 184 + 185 + <div id="tab-vulns" class="repo-panel hidden"> 186 + <div id="vulns-content"> 187 + <div class="flex justify-center py-12"> 188 + <span class="loading loading-spinner loading-lg"></span> 189 + </div> 190 + </div> 191 + </div> 192 + 193 + <div id="tab-sbom" class="repo-panel hidden"> 194 + <div id="sbom-content"> 195 + <div class="flex justify-center py-12"> 196 + <span class="loading loading-spinner loading-lg"></span> 197 + </div> 198 + </div> 199 + </div> 200 + {{ end }} 201 + 202 + <div id="tab-artifacts" class="repo-panel hidden"> 203 + <div id="artifacts-content"> 204 + <div class="flex justify-center py-12"> 205 + <span class="loading loading-spinner loading-lg"></span> 206 + </div> 207 + </div> 208 + </div> 209 + </div> 210 + {{ end }}
+9
pkg/appview/templates/partials/sbom-section.html
··· 1 + {{ define "sbom-section" }} 2 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 3 + {{ if .SbomData }} 4 + {{ template "sbom-details" .SbomData }} 5 + {{ else }} 6 + <p class="text-base-content">No SBOM data available</p> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+9
pkg/appview/templates/partials/vulns-section.html
··· 1 + {{ define "vulns-section" }} 2 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 3 + {{ if .VulnData }} 4 + {{ template "vuln-details" .VulnData }} 5 + {{ else }} 6 + <p class="text-base-content">No vulnerability scan data available</p> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+7
pkg/appview/ui.go
··· 222 222 return licenses.ParseLicenses(licensesStr) 223 223 }, 224 224 225 + "derefTime": func(t *time.Time) time.Time { 226 + if t == nil { 227 + return time.Time{} 228 + } 229 + return *t 230 + }, 231 + 225 232 "sub": func(a, b int) int { 226 233 return a - b 227 234 },
+332
pkg/atproto/cbor_gen.go
··· 1843 1843 1844 1844 return nil 1845 1845 } 1846 + func (t *DailyStatsRecord) MarshalCBOR(w io.Writer) error { 1847 + if t == nil { 1848 + _, err := w.Write(cbg.CborNull) 1849 + return err 1850 + } 1851 + 1852 + cw := cbg.NewCborWriter(w) 1853 + 1854 + if _, err := cw.Write([]byte{167}); err != nil { 1855 + return err 1856 + } 1857 + 1858 + // t.Date (string) (string) 1859 + if len("date") > 8192 { 1860 + return xerrors.Errorf("Value in field \"date\" was too long") 1861 + } 1862 + 1863 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("date"))); err != nil { 1864 + return err 1865 + } 1866 + if _, err := cw.WriteString(string("date")); err != nil { 1867 + return err 1868 + } 1869 + 1870 + if len(t.Date) > 8192 { 1871 + return xerrors.Errorf("Value in field t.Date was too long") 1872 + } 1873 + 1874 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Date))); err != nil { 1875 + return err 1876 + } 1877 + if _, err := cw.WriteString(string(t.Date)); err != nil { 1878 + return err 1879 + } 1880 + 1881 + // t.Type (string) (string) 1882 + if len("$type") > 8192 { 1883 + return xerrors.Errorf("Value in field \"$type\" was too long") 1884 + } 1885 + 1886 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 1887 + return err 1888 + } 1889 + if _, err := cw.WriteString(string("$type")); err != nil { 1890 + return err 1891 + } 1892 + 1893 + if len(t.Type) > 8192 { 1894 + return xerrors.Errorf("Value in field t.Type was too long") 1895 + } 1896 + 1897 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 1898 + return err 1899 + } 1900 + if _, err := cw.WriteString(string(t.Type)); err != nil { 1901 + return err 1902 + } 1903 + 1904 + // t.OwnerDID (string) (string) 1905 + if len("ownerDid") > 8192 { 1906 + return xerrors.Errorf("Value in field \"ownerDid\" was too long") 1907 + } 1908 + 1909 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("ownerDid"))); err != nil { 1910 + return err 1911 + } 1912 + if _, err := cw.WriteString(string("ownerDid")); err != nil { 1913 + return err 1914 + } 1915 + 1916 + if len(t.OwnerDID) > 8192 { 1917 + return xerrors.Errorf("Value in field t.OwnerDID was too long") 1918 + } 1919 + 1920 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.OwnerDID))); err != nil { 1921 + return err 1922 + } 1923 + if _, err := cw.WriteString(string(t.OwnerDID)); err != nil { 1924 + return err 1925 + } 1926 + 1927 + // t.PullCount (int64) (int64) 1928 + if len("pullCount") > 8192 { 1929 + return xerrors.Errorf("Value in field \"pullCount\" was too long") 1930 + } 1931 + 1932 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullCount"))); err != nil { 1933 + return err 1934 + } 1935 + if _, err := cw.WriteString(string("pullCount")); err != nil { 1936 + return err 1937 + } 1938 + 1939 + if t.PullCount >= 0 { 1940 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullCount)); err != nil { 1941 + return err 1942 + } 1943 + } else { 1944 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullCount-1)); err != nil { 1945 + return err 1946 + } 1947 + } 1948 + 1949 + // t.PushCount (int64) (int64) 1950 + if len("pushCount") > 8192 { 1951 + return xerrors.Errorf("Value in field \"pushCount\" was too long") 1952 + } 1953 + 1954 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pushCount"))); err != nil { 1955 + return err 1956 + } 1957 + if _, err := cw.WriteString(string("pushCount")); err != nil { 1958 + return err 1959 + } 1960 + 1961 + if t.PushCount >= 0 { 1962 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PushCount)); err != nil { 1963 + return err 1964 + } 1965 + } else { 1966 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PushCount-1)); err != nil { 1967 + return err 1968 + } 1969 + } 1970 + 1971 + // t.UpdatedAt (string) (string) 1972 + if len("updatedAt") > 8192 { 1973 + return xerrors.Errorf("Value in field \"updatedAt\" was too long") 1974 + } 1975 + 1976 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updatedAt"))); err != nil { 1977 + return err 1978 + } 1979 + if _, err := cw.WriteString(string("updatedAt")); err != nil { 1980 + return err 1981 + } 1982 + 1983 + if len(t.UpdatedAt) > 8192 { 1984 + return xerrors.Errorf("Value in field t.UpdatedAt was too long") 1985 + } 1986 + 1987 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UpdatedAt))); err != nil { 1988 + return err 1989 + } 1990 + if _, err := cw.WriteString(string(t.UpdatedAt)); err != nil { 1991 + return err 1992 + } 1993 + 1994 + // t.Repository (string) (string) 1995 + if len("repository") > 8192 { 1996 + return xerrors.Errorf("Value in field \"repository\" was too long") 1997 + } 1998 + 1999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil { 2000 + return err 2001 + } 2002 + if _, err := cw.WriteString(string("repository")); err != nil { 2003 + return err 2004 + } 2005 + 2006 + if len(t.Repository) > 8192 { 2007 + return xerrors.Errorf("Value in field t.Repository was too long") 2008 + } 2009 + 2010 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil { 2011 + return err 2012 + } 2013 + if _, err := cw.WriteString(string(t.Repository)); err != nil { 2014 + return err 2015 + } 2016 + return nil 2017 + } 2018 + 2019 + func (t *DailyStatsRecord) UnmarshalCBOR(r io.Reader) (err error) { 2020 + *t = DailyStatsRecord{} 2021 + 2022 + cr := cbg.NewCborReader(r) 2023 + 2024 + maj, extra, err := cr.ReadHeader() 2025 + if err != nil { 2026 + return err 2027 + } 2028 + defer func() { 2029 + if err == io.EOF { 2030 + err = io.ErrUnexpectedEOF 2031 + } 2032 + }() 2033 + 2034 + if maj != cbg.MajMap { 2035 + return fmt.Errorf("cbor input should be of type map") 2036 + } 2037 + 2038 + if extra > cbg.MaxLength { 2039 + return fmt.Errorf("DailyStatsRecord: map struct too large (%d)", extra) 2040 + } 2041 + 2042 + n := extra 2043 + 2044 + nameBuf := make([]byte, 10) 2045 + for i := uint64(0); i < n; i++ { 2046 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2047 + if err != nil { 2048 + return err 2049 + } 2050 + 2051 + if !ok { 2052 + // Field doesn't exist on this type, so ignore it 2053 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2054 + return err 2055 + } 2056 + continue 2057 + } 2058 + 2059 + switch string(nameBuf[:nameLen]) { 2060 + // t.Date (string) (string) 2061 + case "date": 2062 + 2063 + { 2064 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2065 + if err != nil { 2066 + return err 2067 + } 2068 + 2069 + t.Date = string(sval) 2070 + } 2071 + // t.Type (string) (string) 2072 + case "$type": 2073 + 2074 + { 2075 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2076 + if err != nil { 2077 + return err 2078 + } 2079 + 2080 + t.Type = string(sval) 2081 + } 2082 + // t.OwnerDID (string) (string) 2083 + case "ownerDid": 2084 + 2085 + { 2086 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2087 + if err != nil { 2088 + return err 2089 + } 2090 + 2091 + t.OwnerDID = string(sval) 2092 + } 2093 + // t.PullCount (int64) (int64) 2094 + case "pullCount": 2095 + { 2096 + maj, extra, err := cr.ReadHeader() 2097 + if err != nil { 2098 + return err 2099 + } 2100 + var extraI int64 2101 + switch maj { 2102 + case cbg.MajUnsignedInt: 2103 + extraI = int64(extra) 2104 + if extraI < 0 { 2105 + return fmt.Errorf("int64 positive overflow") 2106 + } 2107 + case cbg.MajNegativeInt: 2108 + extraI = int64(extra) 2109 + if extraI < 0 { 2110 + return fmt.Errorf("int64 negative overflow") 2111 + } 2112 + extraI = -1 - extraI 2113 + default: 2114 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2115 + } 2116 + 2117 + t.PullCount = int64(extraI) 2118 + } 2119 + // t.PushCount (int64) (int64) 2120 + case "pushCount": 2121 + { 2122 + maj, extra, err := cr.ReadHeader() 2123 + if err != nil { 2124 + return err 2125 + } 2126 + var extraI int64 2127 + switch maj { 2128 + case cbg.MajUnsignedInt: 2129 + extraI = int64(extra) 2130 + if extraI < 0 { 2131 + return fmt.Errorf("int64 positive overflow") 2132 + } 2133 + case cbg.MajNegativeInt: 2134 + extraI = int64(extra) 2135 + if extraI < 0 { 2136 + return fmt.Errorf("int64 negative overflow") 2137 + } 2138 + extraI = -1 - extraI 2139 + default: 2140 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2141 + } 2142 + 2143 + t.PushCount = int64(extraI) 2144 + } 2145 + // t.UpdatedAt (string) (string) 2146 + case "updatedAt": 2147 + 2148 + { 2149 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2150 + if err != nil { 2151 + return err 2152 + } 2153 + 2154 + t.UpdatedAt = string(sval) 2155 + } 2156 + // t.Repository (string) (string) 2157 + case "repository": 2158 + 2159 + { 2160 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2161 + if err != nil { 2162 + return err 2163 + } 2164 + 2165 + t.Repository = string(sval) 2166 + } 2167 + 2168 + default: 2169 + // Field doesn't exist on this type, so ignore it 2170 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2171 + return err 2172 + } 2173 + } 2174 + } 2175 + 2176 + return nil 2177 + } 1846 2178 func (t *ScanRecord) MarshalCBOR(w io.Writer) error { 1847 2179 if t == nil { 1848 2180 _, err := w.Write(cbg.CborNull)
+1
pkg/atproto/generate.go
··· 32 32 atproto.LayerRecord{}, 33 33 atproto.TangledProfileRecord{}, 34 34 atproto.StatsRecord{}, 35 + atproto.DailyStatsRecord{}, 35 36 atproto.ScanRecord{}, 36 37 atproto.ImageConfigRecord{}, 37 38 ); err != nil {
+44
pkg/atproto/lexicon.go
··· 44 44 // Stored in hold's embedded PDS to track pull/push counts per owner+repo 45 45 StatsCollection = "io.atcr.hold.stats" 46 46 47 + // DailyStatsCollection is the collection name for daily repository statistics 48 + // Stored in hold's embedded PDS to track daily pull/push counts per owner+repo+date 49 + DailyStatsCollection = "io.atcr.hold.stats.daily" 50 + 47 51 // ScanCollection is the collection name for vulnerability scan results 48 52 // Stored in hold's embedded PDS to track scan results per manifest 49 53 ScanCollection = "io.atcr.hold.scan" ··· 352 356 // OciClient is the preferred OCI client for pull commands (docker, podman, buildah, nerdctl, crane). 353 357 // Defaults to "docker" if empty. 354 358 OciClient string `json:"ociClient,omitempty"` 359 + 360 + // AIAdvisorEnabled controls whether the AI Image Advisor feature is active for this user. 361 + // nil = default (enabled if user has billing access), false = explicitly disabled. 362 + AIAdvisorEnabled *bool `json:"aiAdvisorEnabled,omitempty"` 355 363 356 364 // CreatedAt timestamp 357 365 CreatedAt time.Time `json:"createdAt"` ··· 772 780 hash := sha256.Sum256([]byte(combined)) 773 781 // Use first 16 bytes (128 bits) for collision resistance 774 782 // Encode with base32 (alphanumeric, lowercase, no padding) for ATProto rkey compatibility 783 + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16])) 784 + } 785 + 786 + // DailyStatsRecord represents daily repository statistics stored in the hold's PDS 787 + // Collection: io.atcr.hold.stats.daily 788 + // Stored in the hold's embedded PDS for tracking daily pull/push counts 789 + // Uses CBOR encoding for efficient storage in hold's carstore 790 + // RKey is deterministic: base32(sha256(ownerDID + "/" + repository + "/" + date)[:16]) 791 + type DailyStatsRecord struct { 792 + Type string `json:"$type" cborgen:"$type"` 793 + OwnerDID string `json:"ownerDid" cborgen:"ownerDid"` // DID of the image owner 794 + Repository string `json:"repository" cborgen:"repository"` // Repository name 795 + Date string `json:"date" cborgen:"date"` // YYYY-MM-DD format 796 + PullCount int64 `json:"pullCount" cborgen:"pullCount"` // Number of manifest downloads on this date 797 + PushCount int64 `json:"pushCount" cborgen:"pushCount"` // Number of manifest uploads on this date 798 + UpdatedAt string `json:"updatedAt" cborgen:"updatedAt"` // RFC3339 timestamp 799 + } 800 + 801 + // NewDailyStatsRecord creates a new daily stats record 802 + func NewDailyStatsRecord(ownerDID, repository, date string) *DailyStatsRecord { 803 + return &DailyStatsRecord{ 804 + Type: DailyStatsCollection, 805 + OwnerDID: ownerDID, 806 + Repository: repository, 807 + Date: date, 808 + PullCount: 0, 809 + PushCount: 0, 810 + UpdatedAt: time.Now().Format(time.RFC3339), 811 + } 812 + } 813 + 814 + // DailyStatsRecordKey generates a deterministic record key for daily stats 815 + // Uses base32 encoding of first 16 bytes of SHA-256 hash of "ownerDID/repository/date" 816 + func DailyStatsRecordKey(ownerDID, repository, date string) string { 817 + combined := ownerDID + "/" + repository + "/" + date 818 + hash := sha256.Sum256([]byte(combined)) 775 819 return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16])) 776 820 } 777 821
+32
pkg/billing/billing.go
··· 139 139 return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers 140 140 } 141 141 142 + // HasAIAdvisor returns whether a user has access to the AI Image Advisor based on their subscription tier. 143 + // Hold captains always have access. 144 + func (m *Manager) HasAIAdvisor(userDID string) bool { 145 + if m.isCaptain(userDID) { 146 + return true 147 + } 148 + if !m.Enabled() { 149 + return false 150 + } 151 + 152 + info, err := m.GetSubscriptionInfo(userDID) 153 + if err != nil || info == nil { 154 + return m.cfg.Tiers[0].AIAdvisor 155 + } 156 + 157 + rank := info.TierRank 158 + if rank >= 0 && rank < len(m.cfg.Tiers) { 159 + return m.cfg.Tiers[rank].AIAdvisor 160 + } 161 + 162 + return m.cfg.Tiers[0].AIAdvisor 163 + } 164 + 142 165 // GetSupporterBadge returns the supporter badge tier name for a user based on their subscription. 143 166 // Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise. 144 167 // Hold captains get a "Captain" badge. ··· 203 226 // Dynamic features: hold-derived first, then webhook limits, then static config 204 227 features := m.aggregateHoldFeatures(i) 205 228 features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...) 229 + features = append(features, aiAdvisorFeatures(tier.AIAdvisor)...) 206 230 if tier.SupporterBadge { 207 231 features = append(features, "Supporter badge") 208 232 } ··· 687 711 features = append(features, "All webhook triggers") 688 712 } 689 713 return features 714 + } 715 + 716 + // aiAdvisorFeatures generates feature bullet strings for AI advisor access. 717 + func aiAdvisorFeatures(enabled bool) []string { 718 + if enabled { 719 + return []string{"AI Image Advisor"} 720 + } 721 + return nil 690 722 } 691 723 692 724 // formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
+9
pkg/billing/billing_stub.go
··· 36 36 return 1, false 37 37 } 38 38 39 + // HasAIAdvisor returns whether a user has access to the AI Image Advisor. 40 + // Hold captains always have access. Default is false when billing is not compiled in. 41 + func (m *Manager) HasAIAdvisor(userDID string) bool { 42 + if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) { 43 + return true 44 + } 45 + return false 46 + } 47 + 39 48 // GetSubscriptionInfo returns an error when billing is not compiled in. 40 49 func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) { 41 50 return nil, ErrBillingDisabled
+3
pkg/billing/config.go
··· 51 51 // Whether all webhook trigger types are available (not just first-scan). 52 52 WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."` 53 53 54 + // Whether AI Image Advisor is available for this tier. 55 + AIAdvisor bool `yaml:"ai_advisor" comment:"Enable AI Image Advisor for this tier."` 56 + 54 57 // Whether this tier earns a supporter badge on user profiles. 55 58 SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."` 56 59 }
+7
pkg/hold/admin/public/icons.svg
··· 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 9 <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> 10 + <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> 10 11 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 11 12 <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> 12 13 <symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol> ··· 18 19 <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> 19 20 <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> 20 21 <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> 22 + <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> 21 23 <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> 22 24 <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> 23 25 <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> 24 26 <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> 25 27 <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> 28 + <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> 26 29 <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> 27 30 <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> 31 + <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> 28 32 <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> 29 33 <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> 30 34 <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 54 <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> 51 55 <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> 52 56 <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> 57 + <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> 58 + <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> 53 59 <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> 54 60 <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> 55 61 <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> 62 + <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> 56 63 <symbol id="terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol> 57 64 <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> 58 65 <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>
+12 -2
pkg/hold/oci/xrpc.go
··· 227 227 Architecture string `json:"architecture"` 228 228 } `json:"platform"` 229 229 } `json:"manifests"` 230 + Subject *struct { 231 + Digest string `json:"digest"` 232 + Size int64 `json:"size"` 233 + MediaType string `json:"mediaType"` 234 + } `json:"subject"` 230 235 } `json:"manifest"` 231 236 } 232 237 ··· 401 406 } 402 407 } 403 408 404 - // Enqueue scan job if scanner is connected (skip manifest lists — children get their own jobs) 405 - if h.scanBroadcaster != nil && !isMultiArch { 409 + // Enqueue scan job if scanner is connected (skip manifest lists and attestations — no scannable content) 410 + if h.scanBroadcaster != nil && !isMultiArch && req.Manifest.Subject == nil { 406 411 tier := "deckhand" 407 412 if stats != nil && stats.Tier != "" { 408 413 tier = stats.Tier ··· 454 459 slog.Error("Failed to increment stats", "operation", operation, "error", err) 455 460 } else { 456 461 statsUpdated = true 462 + } 463 + 464 + // Also increment daily stats for trend tracking 465 + if err := h.pds.IncrementDailyStats(ctx, req.UserDID, req.Repository, operation); err != nil { 466 + slog.Warn("Failed to increment daily stats", "operation", operation, "error", err) 457 467 } 458 468 459 469 // Return response
+435 -177
pkg/hold/pds/scan_broadcaster.go
··· 41 41 rescanInterval time.Duration // Minimum interval between re-scans (0 = disabled) 42 42 stopCh chan struct{} // Signal to stop background goroutines 43 43 wg sync.WaitGroup // Wait for background goroutines to finish 44 - userIdx int // Round-robin index through DIDs for proactive scanning 45 44 predecessorCache map[string]bool // holdDID → "has this hold been migrated (has successor)?" 45 + relayEndpoint string // Relay URL for listReposByCollection 46 46 47 - // Relay-based manifest DID discovery 48 - relayEndpoint string // Relay URL for listReposByCollection 49 - manifestDIDs []string // Cached list of DIDs with manifest records 50 - manifestDIDsMu sync.RWMutex // Protects manifestDIDs 47 + // Work queues for proactive scanning (populated by discovery/stale goroutines) 48 + unscannedQueue chan *scanCandidate // Medium priority: manifests with no scan record 49 + staleQueue chan *scanCandidate // Low priority: scan records older than rescanInterval 50 + inflight map[string]struct{} // Manifest digests currently queued or being scanned 51 + inflightMu sync.Mutex 52 + completionSignal chan struct{} // Signaled when a scan job completes (wakes dispatchLoop) 53 + discoverNow chan struct{} // Signaled to trigger an early discovery pass 51 54 } 52 55 53 56 // ScanSubscriber represents a connected scanner WebSocket client ··· 139 142 stopCh: make(chan struct{}), 140 143 predecessorCache: make(map[string]bool), 141 144 relayEndpoint: relayEndpoint, 145 + unscannedQueue: make(chan *scanCandidate, 500), 146 + staleQueue: make(chan *scanCandidate, 200), 147 + inflight: make(map[string]struct{}), 148 + completionSignal: make(chan struct{}, 1), 149 + discoverNow: make(chan struct{}, 1), 142 150 } 143 151 144 152 if err := sb.initSchema(); err != nil { ··· 149 157 sb.wg.Add(1) 150 158 go sb.reDispatchLoop() 151 159 152 - // Start proactive scan loop if rescan interval is configured 160 + // Start proactive scan loops if rescan interval is configured 153 161 if rescanInterval > 0 { 154 - sb.wg.Add(2) 155 - go sb.proactiveScanLoop() 156 - go sb.refreshManifestDIDsLoop() 162 + sb.wg.Add(3) 163 + go sb.discoveryLoop() 164 + go sb.staleScanLoop() 165 + go sb.dispatchLoop() 157 166 slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint) 158 167 } 159 168 ··· 181 190 stopCh: make(chan struct{}), 182 191 predecessorCache: make(map[string]bool), 183 192 relayEndpoint: relayEndpoint, 193 + unscannedQueue: make(chan *scanCandidate, 500), 194 + staleQueue: make(chan *scanCandidate, 200), 195 + inflight: make(map[string]struct{}), 196 + completionSignal: make(chan struct{}, 1), 197 + discoverNow: make(chan struct{}, 1), 184 198 } 185 199 186 200 if err := sb.initSchema(); err != nil { ··· 190 204 go sb.reDispatchLoop() 191 205 192 206 if rescanInterval > 0 { 193 - sb.wg.Add(2) 194 - go sb.proactiveScanLoop() 195 - go sb.refreshManifestDIDsLoop() 207 + sb.wg.Add(3) 208 + go sb.discoveryLoop() 209 + go sb.staleScanLoop() 210 + go sb.dispatchLoop() 196 211 slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint) 197 212 } 198 213 ··· 238 253 job.HoldDID = sb.holdDID 239 254 job.HoldEndpoint = sb.holdEndpoint 240 255 256 + // Track in-flight to prevent duplicate proactive scans 257 + sb.addInflight(job.ManifestDigest) 258 + 241 259 // Insert into database 242 260 result, err := sb.db.Exec(` 243 261 INSERT INTO scan_jobs (manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json, status) 244 262 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending') 245 263 `, job.ManifestDigest, job.Repository, job.Tag, job.UserDID, job.UserHandle, job.HoldDID, job.HoldEndpoint, job.Tier, string(job.Config), string(job.Layers)) 246 264 if err != nil { 265 + sb.removeInflight(job.ManifestDigest) 247 266 return fmt.Errorf("failed to insert scan job: %w", err) 248 267 } 249 268 250 269 seq, err := result.LastInsertId() 251 270 if err != nil { 271 + sb.removeInflight(job.ManifestDigest) 252 272 return fmt.Errorf("failed to get job seq: %w", err) 253 273 } 254 274 job.Seq = seq ··· 294 314 // Drain pending and timed-out jobs from database 295 315 go sb.drainPendingJobs(sub, cursor) 296 316 317 + // Trigger early discovery pass so missed scans are found quickly 318 + sb.triggerDiscovery() 319 + 297 320 return sub 298 321 } 299 322 ··· 309 332 } 310 333 } 311 334 312 - // Mark assigned jobs as pending again so they can be re-dispatched 335 + // Mark assigned/processing jobs as pending again so they can be re-dispatched. 336 + // Including 'processing' handles scanner crashes mid-scan. 313 337 _, err := sb.db.Exec(` 314 338 UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL 315 - WHERE assigned_to = ? AND status IN ('pending', 'assigned') 339 + WHERE assigned_to = ? AND status IN ('pending', 'assigned', 'processing') 316 340 `, sub.id) 317 341 if err != nil { 318 342 slog.Error("Failed to unassign jobs from disconnected scanner", ··· 542 566 "error", err) 543 567 } 544 568 569 + // Remove from in-flight tracking and wake dispatch loop 570 + sb.removeInflight(manifestDigest) 571 + sb.signalCompletion() 572 + 545 573 slog.Info("Scan job completed", 546 574 "seq", msg.Seq, 547 575 "repository", repository, ··· 592 620 "error", err) 593 621 } 594 622 623 + // Remove from in-flight tracking and wake dispatch loop 624 + sb.removeInflight(manifestDigest) 625 + sb.signalCompletion() 626 + 595 627 slog.Warn("Scan job failed", 596 628 "seq", msg.Seq, 597 629 "subscriberId", sub.id, ··· 768 800 return sb.secret != "" && secret == sb.secret 769 801 } 770 802 771 - // refreshManifestDIDsLoop periodically queries the relay to discover all DIDs 772 - // with io.atcr.manifest records. The cached list is used by the proactive scan loop. 773 - func (sb *ScanBroadcaster) refreshManifestDIDsLoop() { 803 + // scanCandidate is a manifest that needs scanning, with its scan freshness. 804 + type scanCandidate struct { 805 + manifest atproto.ManifestRecord // May be zero-value for stale candidates (resolved lazily) 806 + manifestDigest string // Always set; used for dedup and lazy resolution 807 + userDID string 808 + userHandle string 809 + scannedAt time.Time // zero value = never scanned 810 + } 811 + 812 + // discoveryLoop fetches DIDs from the relay, walks each user's PDS to find manifests 813 + // with no scan record, and pushes them to the unscannedQueue. Runs on startup (after 814 + // settle), then every 4 hours. Scanner reconnect triggers an early pass via discoverNow. 815 + func (sb *ScanBroadcaster) discoveryLoop() { 774 816 defer sb.wg.Done() 775 817 776 - // Wait for the system to settle before first refresh 818 + // Wait for system to settle 777 819 select { 778 820 case <-sb.stopCh: 779 821 return 780 - case <-time.After(30 * time.Second): 822 + case <-time.After(45 * time.Second): 781 823 } 782 824 783 - // Initial refresh 784 - sb.refreshManifestDIDs() 825 + slog.Info("Discovery loop started") 826 + sb.runDiscoveryPass() 785 827 786 - ticker := time.NewTicker(30 * time.Minute) 828 + ticker := time.NewTicker(4 * time.Hour) 787 829 defer ticker.Stop() 788 830 789 831 for { 790 832 select { 791 833 case <-sb.stopCh: 792 - slog.Info("Manifest DID refresh loop stopped") 834 + slog.Info("Discovery loop stopped") 793 835 return 794 836 case <-ticker.C: 795 - sb.refreshManifestDIDs() 837 + sb.runDiscoveryPass() 838 + case <-sb.discoverNow: 839 + slog.Info("Discovery loop: early pass triggered (scanner reconnect)") 840 + sb.runDiscoveryPass() 796 841 } 797 842 } 798 843 } 799 844 800 - // refreshManifestDIDs queries the relay for all DIDs that have io.atcr.manifest records. 801 - // On success, atomically replaces the cached DID list. On failure, retains the previous list. 802 - func (sb *ScanBroadcaster) refreshManifestDIDs() { 803 - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 845 + // runDiscoveryPass fetches the DID list from relay and walks each user's PDS. 846 + func (sb *ScanBroadcaster) runDiscoveryPass() { 847 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) 804 848 defer cancel() 805 849 806 - client := atproto.NewClient(sb.relayEndpoint, "", "") 850 + // Fetch DID list from relay 851 + userDIDs := sb.fetchManifestDIDs(ctx) 852 + if len(userDIDs) == 0 { 853 + slog.Debug("Discovery: no manifest DIDs from relay") 854 + return 855 + } 856 + 857 + slog.Info("Discovery: starting pass", "users", len(userDIDs)) 858 + found := 0 807 859 860 + for _, userDID := range userDIDs { 861 + select { 862 + case <-sb.stopCh: 863 + return 864 + default: 865 + } 866 + 867 + n := sb.discoverUnscannedForUser(ctx, userDID) 868 + found += n 869 + } 870 + 871 + slog.Info("Discovery: pass complete", "users", len(userDIDs), "unscannedFound", found) 872 + } 873 + 874 + // fetchManifestDIDs queries the relay for all DIDs with io.atcr.manifest records. 875 + func (sb *ScanBroadcaster) fetchManifestDIDs(ctx context.Context) []string { 876 + client := atproto.NewClient(sb.relayEndpoint, "", "") 808 877 var allDIDs []string 809 878 var cursor string 810 879 811 880 for { 812 881 result, err := client.ListReposByCollection(ctx, atproto.ManifestCollection, 1000, cursor) 813 882 if err != nil { 814 - slog.Warn("Proactive scan: failed to list repos from relay", 883 + slog.Warn("Discovery: failed to list repos from relay", 815 884 "relay", sb.relayEndpoint, "error", err) 816 - return // Keep existing cached list 885 + return allDIDs // Return what we have so far 817 886 } 818 887 819 888 for _, repo := range result.Repos { ··· 826 895 cursor = result.Cursor 827 896 } 828 897 829 - sb.manifestDIDsMu.Lock() 830 - sb.manifestDIDs = allDIDs 831 - sb.manifestDIDsMu.Unlock() 832 - 833 - slog.Info("Proactive scan: refreshed manifest DID list from relay", 834 - "count", len(allDIDs), "relay", sb.relayEndpoint) 835 - } 836 - 837 - // proactiveScanLoop periodically finds manifests needing scanning and enqueues jobs. 838 - // It fetches manifest records from users' PDS (the source of truth) and creates scan 839 - // jobs for manifests that haven't been scanned recently. 840 - func (sb *ScanBroadcaster) proactiveScanLoop() { 841 - defer sb.wg.Done() 842 - 843 - // Wait for the system to settle and DID list to populate 844 - select { 845 - case <-sb.stopCh: 846 - return 847 - case <-time.After(45 * time.Second): 848 - } 849 - 850 - // Run immediately on startup, then every 60s 851 - slog.Info("Proactive scan loop started") 852 - sb.tryEnqueueProactiveScan() 853 - 854 - ticker := time.NewTicker(60 * time.Second) 855 - defer ticker.Stop() 856 - 857 - for { 858 - select { 859 - case <-sb.stopCh: 860 - slog.Info("Proactive scan loop stopped") 861 - return 862 - case <-ticker.C: 863 - sb.tryEnqueueProactiveScan() 864 - } 865 - } 898 + return allDIDs 866 899 } 867 900 868 - // tryEnqueueProactiveScan finds the next manifest needing a scan and enqueues it. 869 - // Only enqueues one job per call to avoid flooding the scanner. 870 - // Uses the cached DID list from the relay (refreshed by refreshManifestDIDsLoop). 871 - func (sb *ScanBroadcaster) tryEnqueueProactiveScan() { 872 - if !sb.hasConnectedScanners() { 873 - slog.Debug("Proactive scan: no scanners connected, skipping") 874 - return 875 - } 876 - if sb.hasActiveJobs() { 877 - slog.Debug("Proactive scan: active jobs in queue, skipping") 878 - return 879 - } 880 - 881 - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 882 - defer cancel() 883 - 884 - // Read cached DID list from relay discovery 885 - sb.manifestDIDsMu.RLock() 886 - userDIDs := sb.manifestDIDs 887 - sb.manifestDIDsMu.RUnlock() 888 - 889 - if len(userDIDs) == 0 { 890 - slog.Debug("Proactive scan: no manifest DIDs cached from relay, skipping") 891 - return 892 - } 893 - 894 - // Round-robin through DIDs, trying each until we find work or exhaust the list 895 - for attempts := 0; attempts < len(userDIDs); attempts++ { 896 - idx := sb.userIdx % len(userDIDs) 897 - sb.userIdx++ 898 - userDID := userDIDs[idx] 899 - 900 - if sb.tryEnqueueForUser(ctx, userDID) { 901 - return // Enqueued one job, done for this tick 902 - } 903 - } 904 - } 905 - 906 - // scanCandidate is a manifest that needs scanning, with its scan freshness. 907 - type scanCandidate struct { 908 - manifest atproto.ManifestRecord 909 - userDID string 910 - userHandle string 911 - scannedAt time.Time // zero value = never scanned 912 - } 913 - 914 - // tryEnqueueForUser fetches manifests from a user's PDS and enqueues a scan for the 915 - // one that most needs it: never-scanned manifests first, then the stalest scan. 916 - // Returns true if a job was enqueued. 917 - func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string) bool { 918 - // Resolve user DID to PDS endpoint and handle 901 + // discoverUnscannedForUser fetches manifests from a user's PDS and pushes any 902 + // without scan records to the unscannedQueue. Returns count of candidates found. 903 + func (sb *ScanBroadcaster) discoverUnscannedForUser(ctx context.Context, userDID string) int { 919 904 did, userHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, userDID) 920 905 if err != nil { 921 - slog.Debug("Proactive scan: failed to resolve user identity", 906 + slog.Debug("Discovery: failed to resolve user identity", 922 907 "userDID", userDID, "error", err) 923 - return false 908 + return 0 924 909 } 925 910 926 - // Collect all scannable manifests with their scan age 927 - var unscanned []scanCandidate 928 - var oldest *scanCandidate 929 - 911 + found := 0 930 912 client := atproto.NewClient(pdsEndpoint, did, "") 931 913 var cursor string 932 914 for { 933 915 records, nextCursor, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, cursor) 934 916 if err != nil { 935 - slog.Debug("Proactive scan: failed to list manifest records", 917 + slog.Debug("Discovery: failed to list manifest records", 936 918 "userDID", did, "pds", pdsEndpoint, "error", err) 937 - return false 919 + return found 938 920 } 939 921 940 922 for _, record := range records { ··· 943 925 continue 944 926 } 945 927 946 - // Check if this manifest belongs to us (directly or via successor) 928 + // Check if this manifest belongs to us 947 929 holdDID := manifest.HoldDID 948 930 if holdDID == "" { 949 931 holdDID = manifest.HoldEndpoint // Legacy field ··· 952 934 continue 953 935 } 954 936 955 - // Skip manifest lists (no layers to scan) 956 - if len(manifest.Layers) == 0 { 937 + // Skip manifest lists and attestations (no scannable content) 938 + if len(manifest.Layers) == 0 || manifest.Subject != nil || manifest.Config == nil { 957 939 continue 958 940 } 959 941 960 - // Skip if config is nil 961 - if manifest.Config == nil { 942 + // Check if already in-flight 943 + if !sb.addInflight(manifest.Digest) { 962 944 continue 963 945 } 964 946 965 - // Check scan status 966 - _, scanRecord, err := sb.pds.GetScanRecord(ctx, manifest.Digest) 947 + // Check scan status — only interested in never-scanned 948 + _, _, err := sb.pds.GetScanRecord(ctx, manifest.Digest) 949 + if err == nil { 950 + // Has a scan record — not our concern (stale loop handles rescans) 951 + sb.removeInflight(manifest.Digest) 952 + continue 953 + } 954 + 955 + // Never scanned — push to queue 956 + candidate := &scanCandidate{ 957 + manifest: manifest, 958 + manifestDigest: manifest.Digest, 959 + userDID: did, 960 + userHandle: userHandle, 961 + } 962 + 963 + select { 964 + case sb.unscannedQueue <- candidate: 965 + found++ 966 + case <-sb.stopCh: 967 + sb.removeInflight(manifest.Digest) 968 + return found 969 + } 970 + } 971 + 972 + if nextCursor == "" || len(records) == 0 { 973 + break 974 + } 975 + cursor = nextCursor 976 + } 977 + 978 + return found 979 + } 980 + 981 + // staleScanLoop walks local scan records to find stale scans (older than rescanInterval) 982 + // and pushes them to the staleQueue. No PDS calls needed — all data is local. 983 + // After a full pass, sleeps for rescanInterval/2 before repeating. 984 + func (sb *ScanBroadcaster) staleScanLoop() { 985 + defer sb.wg.Done() 986 + 987 + // Short initial delay to let system settle 988 + select { 989 + case <-sb.stopCh: 990 + return 991 + case <-time.After(10 * time.Second): 992 + } 993 + 994 + slog.Info("Stale scan loop started") 995 + 996 + for { 997 + sb.runStalePass() 998 + 999 + sleepDuration := sb.rescanInterval / 2 1000 + if sleepDuration < 1*time.Hour { 1001 + sleepDuration = 1 * time.Hour 1002 + } 1003 + 1004 + select { 1005 + case <-sb.stopCh: 1006 + slog.Info("Stale scan loop stopped") 1007 + return 1008 + case <-time.After(sleepDuration): 1009 + } 1010 + } 1011 + } 1012 + 1013 + // runStalePass walks all scan records and pushes stale ones to the staleQueue. 1014 + func (sb *ScanBroadcaster) runStalePass() { 1015 + ri := sb.pds.RecordsIndex() 1016 + if ri == nil { 1017 + slog.Debug("Stale scan: no records index available") 1018 + return 1019 + } 1020 + 1021 + ctx := context.Background() 1022 + found := 0 1023 + var cursor string 1024 + 1025 + for { 1026 + records, nextCursor, err := ri.ListRecords(atproto.ScanCollection, 100, cursor, true) // oldest first 1027 + if err != nil { 1028 + slog.Error("Stale scan: failed to list scan records", "error", err) 1029 + return 1030 + } 1031 + 1032 + for _, record := range records { 1033 + select { 1034 + case <-sb.stopCh: 1035 + return 1036 + default: 1037 + } 1038 + 1039 + // The rkey is the manifest digest (without sha256: prefix) 1040 + manifestDigest := "sha256:" + record.Rkey 1041 + 1042 + // Check if already in-flight 1043 + if !sb.addInflight(manifestDigest) { 1044 + continue 1045 + } 1046 + 1047 + // Fetch the actual scan record to check staleness 1048 + _, scanRecord, err := sb.pds.GetScanRecord(ctx, manifestDigest) 967 1049 if err != nil { 968 - // No scan record — never scanned 969 - unscanned = append(unscanned, scanCandidate{ 970 - manifest: manifest, 971 - userDID: did, 972 - userHandle: userHandle, 973 - }) 1050 + sb.removeInflight(manifestDigest) 974 1051 continue 975 1052 } 976 1053 977 1054 scannedAt, err := time.Parse(time.RFC3339, scanRecord.ScannedAt) 978 1055 if err != nil { 979 - // Can't parse timestamp — treat as never scanned 980 - unscanned = append(unscanned, scanCandidate{ 981 - manifest: manifest, 982 - userDID: did, 983 - userHandle: userHandle, 984 - }) 1056 + sb.removeInflight(manifestDigest) 985 1057 continue 986 1058 } 987 1059 988 1060 // Skip if scanned recently 989 1061 if time.Since(scannedAt) < sb.rescanInterval { 1062 + sb.removeInflight(manifestDigest) 990 1063 continue 991 1064 } 992 1065 993 - // Stale scan — track the oldest 994 - if oldest == nil || scannedAt.Before(oldest.scannedAt) { 995 - oldest = &scanCandidate{ 996 - manifest: manifest, 997 - userDID: did, 998 - userHandle: userHandle, 999 - scannedAt: scannedAt, 1000 - } 1066 + candidate := &scanCandidate{ 1067 + manifestDigest: manifestDigest, 1068 + userDID: scanRecord.UserDID, 1069 + scannedAt: scannedAt, 1070 + } 1071 + 1072 + select { 1073 + case sb.staleQueue <- candidate: 1074 + found++ 1075 + case <-sb.stopCh: 1076 + sb.removeInflight(manifestDigest) 1077 + return 1001 1078 } 1002 1079 } 1003 1080 ··· 1007 1084 cursor = nextCursor 1008 1085 } 1009 1086 1010 - // Prefer never-scanned, then oldest stale scan 1011 - var pick *scanCandidate 1012 - if len(unscanned) > 0 { 1013 - pick = &unscanned[0] 1014 - } else if oldest != nil { 1015 - pick = oldest 1087 + if found > 0 { 1088 + slog.Info("Stale scan: pass complete", "staleCandidates", found) 1016 1089 } 1090 + } 1017 1091 1018 - if pick == nil { 1019 - return false 1092 + // dispatchLoop pops candidates from the work queues with strict priority 1093 + // (unscanned before stale) and enqueues them as scan jobs. Throttled to one 1094 + // proactive job at a time via hasActiveJobs(). 1095 + func (sb *ScanBroadcaster) dispatchLoop() { 1096 + defer sb.wg.Done() 1097 + 1098 + // Wait for system to settle 1099 + select { 1100 + case <-sb.stopCh: 1101 + return 1102 + case <-time.After(15 * time.Second): 1020 1103 } 1021 1104 1022 - configJSON, _ := json.Marshal(pick.manifest.Config) 1023 - layersJSON, _ := json.Marshal(pick.manifest.Layers) 1105 + slog.Info("Dispatch loop started") 1106 + 1107 + for { 1108 + select { 1109 + case <-sb.stopCh: 1110 + slog.Info("Dispatch loop stopped") 1111 + return 1112 + default: 1113 + } 1114 + 1115 + // Wait until there's capacity (no active proactive jobs) 1116 + if !sb.waitForCapacity() { 1117 + return // stopCh closed 1118 + } 1119 + 1120 + // Wait until at least one scanner is connected 1121 + if !sb.hasConnectedScanners() { 1122 + select { 1123 + case <-sb.stopCh: 1124 + return 1125 + case <-time.After(5 * time.Second): 1126 + } 1127 + continue 1128 + } 1129 + 1130 + // Pop from highest-priority non-empty queue 1131 + var candidate *scanCandidate 1132 + 1133 + select { 1134 + case c := <-sb.unscannedQueue: 1135 + candidate = c 1136 + default: 1137 + // No unscanned; try stale 1138 + select { 1139 + case c := <-sb.staleQueue: 1140 + candidate = c 1141 + default: 1142 + // Both queues empty — wait for completion signal or timeout 1143 + select { 1144 + case <-sb.stopCh: 1145 + return 1146 + case <-sb.completionSignal: 1147 + case <-time.After(30 * time.Second): 1148 + } 1149 + continue 1150 + } 1151 + } 1152 + 1153 + sb.dispatchCandidate(candidate) 1154 + } 1155 + } 1156 + 1157 + // waitForCapacity blocks until there are no active proactive scan jobs. 1158 + // Returns false if stopCh is closed. 1159 + func (sb *ScanBroadcaster) waitForCapacity() bool { 1160 + for { 1161 + if !sb.hasActiveJobs() { 1162 + return true 1163 + } 1164 + select { 1165 + case <-sb.stopCh: 1166 + return false 1167 + case <-sb.completionSignal: 1168 + // Job completed, check again 1169 + case <-time.After(5 * time.Second): 1170 + // Periodic check as fallback 1171 + } 1172 + } 1173 + } 1174 + 1175 + // dispatchCandidate resolves manifest details if needed and enqueues a scan job. 1176 + func (sb *ScanBroadcaster) dispatchCandidate(candidate *scanCandidate) { 1177 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 1178 + defer cancel() 1179 + 1180 + // For stale candidates, verify the scan is still stale (may have been 1181 + // scanned by a push-triggered job while sitting in the queue) 1182 + if !candidate.scannedAt.IsZero() { 1183 + _, scanRecord, err := sb.pds.GetScanRecord(ctx, candidate.manifestDigest) 1184 + if err == nil { 1185 + scannedAt, parseErr := time.Parse(time.RFC3339, scanRecord.ScannedAt) 1186 + if parseErr == nil && time.Since(scannedAt) < sb.rescanInterval { 1187 + // Recently scanned, skip 1188 + sb.removeInflight(candidate.manifestDigest) 1189 + return 1190 + } 1191 + } 1192 + } 1193 + 1194 + // Resolve manifest details if not already present (stale candidates lack Config/Layers) 1195 + if candidate.manifest.Config == nil { 1196 + if !sb.resolveManifestForCandidate(ctx, candidate) { 1197 + sb.removeInflight(candidate.manifestDigest) 1198 + return 1199 + } 1200 + } 1201 + 1202 + configJSON, _ := json.Marshal(candidate.manifest.Config) 1203 + layersJSON, _ := json.Marshal(candidate.manifest.Layers) 1024 1204 1025 1205 reason := "never scanned" 1026 - if !pick.scannedAt.IsZero() { 1027 - reason = fmt.Sprintf("last scanned %s ago", time.Since(pick.scannedAt).Truncate(time.Minute)) 1206 + if !candidate.scannedAt.IsZero() { 1207 + reason = fmt.Sprintf("last scanned %s ago", time.Since(candidate.scannedAt).Truncate(time.Minute)) 1028 1208 } 1029 1209 1030 - slog.Info("Enqueuing proactive scan", 1031 - "manifestDigest", pick.manifest.Digest, 1032 - "repository", pick.manifest.Repository, 1033 - "userDID", pick.userDID, 1210 + slog.Info("Dispatching proactive scan", 1211 + "manifestDigest", candidate.manifestDigest, 1212 + "repository", candidate.manifest.Repository, 1213 + "userDID", candidate.userDID, 1034 1214 "reason", reason) 1035 1215 1036 1216 if err := sb.Enqueue(&ScanJobEvent{ 1037 - ManifestDigest: pick.manifest.Digest, 1038 - Repository: pick.manifest.Repository, 1039 - UserDID: pick.userDID, 1040 - UserHandle: pick.userHandle, 1217 + ManifestDigest: candidate.manifestDigest, 1218 + Repository: candidate.manifest.Repository, 1219 + UserDID: candidate.userDID, 1220 + UserHandle: candidate.userHandle, 1041 1221 Tier: "deckhand", 1042 1222 Config: configJSON, 1043 1223 Layers: layersJSON, 1044 1224 }); err != nil { 1045 - slog.Error("Proactive scan: failed to enqueue", 1046 - "manifest", pick.manifest.Digest, "error", err) 1225 + slog.Error("Dispatch: failed to enqueue", 1226 + "manifest", candidate.manifestDigest, "error", err) 1227 + // removeInflight not needed — Enqueue already cleans up on error 1228 + } 1229 + } 1230 + 1231 + // resolveManifestForCandidate fetches the manifest from the user's PDS to populate 1232 + // Config and Layers fields needed for the scan job. 1233 + func (sb *ScanBroadcaster) resolveManifestForCandidate(ctx context.Context, candidate *scanCandidate) bool { 1234 + did, userHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, candidate.userDID) 1235 + if err != nil { 1236 + slog.Debug("Dispatch: failed to resolve user identity", 1237 + "userDID", candidate.userDID, "error", err) 1047 1238 return false 1048 1239 } 1049 - return true 1240 + if candidate.userHandle == "" { 1241 + candidate.userHandle = userHandle 1242 + } 1243 + 1244 + client := atproto.NewClient(pdsEndpoint, did, "") 1245 + var cursor string 1246 + for { 1247 + records, nextCursor, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, cursor) 1248 + if err != nil { 1249 + return false 1250 + } 1251 + 1252 + for _, record := range records { 1253 + var manifest atproto.ManifestRecord 1254 + if err := json.Unmarshal(record.Value, &manifest); err != nil { 1255 + continue 1256 + } 1257 + 1258 + if manifest.Digest == candidate.manifestDigest { 1259 + candidate.manifest = manifest 1260 + return true 1261 + } 1262 + } 1263 + 1264 + if nextCursor == "" || len(records) == 0 { 1265 + break 1266 + } 1267 + cursor = nextCursor 1268 + } 1269 + 1270 + slog.Debug("Dispatch: manifest not found on user's PDS", 1271 + "manifestDigest", candidate.manifestDigest, 1272 + "userDID", candidate.userDID) 1273 + return false 1050 1274 } 1051 1275 1052 1276 // isOurManifest checks if a manifest's holdDID matches this hold directly, ··· 1161 1385 _, _ = rand.Read(b) 1162 1386 return hex.EncodeToString(b) 1163 1387 } 1388 + 1389 + // addInflight marks a manifest digest as in-flight. Returns false if already present. 1390 + func (sb *ScanBroadcaster) addInflight(digest string) bool { 1391 + sb.inflightMu.Lock() 1392 + defer sb.inflightMu.Unlock() 1393 + if _, ok := sb.inflight[digest]; ok { 1394 + return false 1395 + } 1396 + sb.inflight[digest] = struct{}{} 1397 + return true 1398 + } 1399 + 1400 + // removeInflight removes a manifest digest from the in-flight set. 1401 + func (sb *ScanBroadcaster) removeInflight(digest string) { 1402 + sb.inflightMu.Lock() 1403 + defer sb.inflightMu.Unlock() 1404 + delete(sb.inflight, digest) 1405 + } 1406 + 1407 + // signalCompletion non-blocking signal to wake the dispatch loop. 1408 + func (sb *ScanBroadcaster) signalCompletion() { 1409 + select { 1410 + case sb.completionSignal <- struct{}{}: 1411 + default: 1412 + } 1413 + } 1414 + 1415 + // triggerDiscovery non-blocking signal to trigger an early discovery pass. 1416 + func (sb *ScanBroadcaster) triggerDiscovery() { 1417 + select { 1418 + case sb.discoverNow <- struct{}{}: 1419 + default: 1420 + } 1421 + }
+1
pkg/hold/pds/server.go
··· 31 31 lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{}) 32 32 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 33 33 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{}) 34 + lexutil.RegisterType(atproto.DailyStatsCollection, &atproto.DailyStatsRecord{}) 34 35 lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{}) 35 36 lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{}) 36 37 }
+209
pkg/hold/pds/stats.go
··· 75 75 return nil 76 76 } 77 77 78 + // IncrementDailyStats increments the daily pull or push count for a repository 79 + // Creates a new daily record if none exists for the current date, updates existing otherwise 80 + // On first daily record for a repo, seeds it with existing cumulative stats so trend charts 81 + // have a starting data point for pre-daily-tracking history 82 + func (p *HoldPDS) IncrementDailyStats(ctx context.Context, ownerDID, repository, operation string) error { 83 + if operation != "pull" && operation != "push" { 84 + return fmt.Errorf("invalid operation: %s (must be 'pull' or 'push')", operation) 85 + } 86 + 87 + date := time.Now().UTC().Format("2006-01-02") 88 + rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date) 89 + now := time.Now().Format(time.RFC3339) 90 + 91 + // Try to get existing daily record for today 92 + _, existing, err := p.GetDailyStats(ctx, ownerDID, repository, date) 93 + if err != nil { 94 + // No daily record for today — check if we need to seed from cumulative stats 95 + seedPull, seedPush := p.getSeedCounts(ctx, ownerDID, repository) 96 + 97 + record := atproto.NewDailyStatsRecord(ownerDID, repository, date) 98 + if operation == "pull" { 99 + record.PullCount = 1 100 + } else { 101 + record.PushCount = 1 102 + } 103 + record.UpdatedAt = now 104 + 105 + // If there are existing cumulative counts but no daily records yet, 106 + // create a seed record for the previous day with the historical totals 107 + if seedPull > 0 || seedPush > 0 { 108 + if err := p.seedDailyStats(ctx, ownerDID, repository, date, seedPull, seedPush); err != nil { 109 + slog.Warn("Failed to seed daily stats from cumulative", 110 + "ownerDID", ownerDID, 111 + "repository", repository, 112 + "error", err) 113 + } 114 + } 115 + 116 + _, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record) 117 + if err != nil { 118 + return fmt.Errorf("failed to create daily stats record: %w", err) 119 + } 120 + 121 + slog.Debug("Created daily stats record", 122 + "ownerDID", ownerDID, 123 + "repository", repository, 124 + "date", date, 125 + "operation", operation) 126 + return nil 127 + } 128 + 129 + // Record exists — increment 130 + if operation == "pull" { 131 + existing.PullCount++ 132 + } else { 133 + existing.PushCount++ 134 + } 135 + existing.UpdatedAt = now 136 + 137 + _, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, existing) 138 + if err != nil { 139 + return fmt.Errorf("failed to update daily stats record: %w", err) 140 + } 141 + 142 + slog.Debug("Updated daily stats record", 143 + "ownerDID", ownerDID, 144 + "repository", repository, 145 + "date", date, 146 + "operation", operation, 147 + "pullCount", existing.PullCount, 148 + "pushCount", existing.PushCount) 149 + return nil 150 + } 151 + 152 + // getSeedCounts returns the cumulative pull/push counts from io.atcr.hold.stats 153 + // minus any already-tracked daily counts. Returns (0, 0) if no seeding is needed. 154 + func (p *HoldPDS) getSeedCounts(ctx context.Context, ownerDID, repository string) (int64, int64) { 155 + // Check if any daily records already exist for this repo 156 + dailyStats, err := p.ListDailyStatsForRepo(ctx, ownerDID, repository) 157 + if err == nil && len(dailyStats) > 0 { 158 + // Daily records already exist — no seeding needed 159 + return 0, 0 160 + } 161 + 162 + // Get cumulative stats 163 + _, cumulative, err := p.GetStats(ctx, ownerDID, repository) 164 + if err != nil || cumulative == nil { 165 + return 0, 0 166 + } 167 + 168 + return cumulative.PullCount, cumulative.PushCount 169 + } 170 + 171 + // seedDailyStats creates a seed daily record with historical cumulative totals 172 + // dated to the day before the first real daily record 173 + func (p *HoldPDS) seedDailyStats(ctx context.Context, ownerDID, repository, currentDate string, pullCount, pushCount int64) error { 174 + // Parse current date and go back one day for the seed record 175 + t, err := time.Parse("2006-01-02", currentDate) 176 + if err != nil { 177 + return fmt.Errorf("failed to parse date: %w", err) 178 + } 179 + seedDate := t.AddDate(0, 0, -1).Format("2006-01-02") 180 + rkey := atproto.DailyStatsRecordKey(ownerDID, repository, seedDate) 181 + 182 + record := atproto.NewDailyStatsRecord(ownerDID, repository, seedDate) 183 + record.PullCount = pullCount 184 + record.PushCount = pushCount 185 + record.UpdatedAt = time.Now().Format(time.RFC3339) 186 + 187 + _, _, err = p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record) 188 + if err != nil { 189 + return fmt.Errorf("failed to create seed daily stats record: %w", err) 190 + } 191 + 192 + slog.Info("Seeded daily stats from cumulative totals", 193 + "ownerDID", ownerDID, 194 + "repository", repository, 195 + "seedDate", seedDate, 196 + "pullCount", pullCount, 197 + "pushCount", pushCount) 198 + return nil 199 + } 200 + 201 + // GetDailyStats retrieves the daily stats record for a repository on a specific date 202 + func (p *HoldPDS) GetDailyStats(ctx context.Context, ownerDID, repository, date string) (cid.Cid, *atproto.DailyStatsRecord, error) { 203 + rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date) 204 + 205 + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, cid.Undef) 206 + if err != nil { 207 + return cid.Undef, nil, err 208 + } 209 + 210 + dailyRecord, ok := val.(*atproto.DailyStatsRecord) 211 + if !ok { 212 + return cid.Undef, nil, fmt.Errorf("unexpected type for daily stats record: %T", val) 213 + } 214 + 215 + return recordCID, dailyRecord, nil 216 + } 217 + 218 + // ListDailyStatsForRepo returns all daily stats records for a specific owner+repo 219 + func (p *HoldPDS) ListDailyStatsForRepo(ctx context.Context, ownerDID, repository string) ([]*atproto.DailyStatsRecord, error) { 220 + session, err := p.carstore.ReadOnlySession(p.uid) 221 + if err != nil { 222 + return nil, fmt.Errorf("failed to get read-only session: %w", err) 223 + } 224 + 225 + head, err := p.carstore.GetUserRepoHead(ctx, p.uid) 226 + if err != nil { 227 + return nil, fmt.Errorf("failed to get repo head: %w", err) 228 + } 229 + 230 + if !head.Defined() { 231 + return []*atproto.DailyStatsRecord{}, nil 232 + } 233 + 234 + r, err := repo.OpenRepo(ctx, session, head) 235 + if err != nil { 236 + return nil, fmt.Errorf("failed to open repo: %w", err) 237 + } 238 + 239 + var stats []*atproto.DailyStatsRecord 240 + 241 + err = r.ForEach(ctx, atproto.DailyStatsCollection, func(k string, v cid.Cid) error { 242 + parts := strings.Split(k, "/") 243 + if len(parts) < 2 { 244 + return nil 245 + } 246 + 247 + actualCollection := strings.Join(parts[:len(parts)-1], "/") 248 + if actualCollection != atproto.DailyStatsCollection { 249 + return repo.ErrDoneIterating 250 + } 251 + 252 + _, recBytes, err := r.GetRecordBytes(ctx, k) 253 + if err != nil { 254 + slog.Warn("Failed to get daily stats record bytes", "key", k, "error", err) 255 + return nil 256 + } 257 + 258 + if recBytes == nil { 259 + return nil 260 + } 261 + 262 + var dailyRecord atproto.DailyStatsRecord 263 + if err := dailyRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil { 264 + slog.Warn("Failed to unmarshal daily stats record", "key", k, "error", err) 265 + return nil 266 + } 267 + 268 + if dailyRecord.OwnerDID == ownerDID && dailyRecord.Repository == repository { 269 + stats = append(stats, &dailyRecord) 270 + } 271 + return nil 272 + }) 273 + 274 + if err != nil { 275 + if errors.Is(err, repo.ErrDoneIterating) { 276 + // Expected 277 + } else if strings.Contains(err.Error(), "not found") { 278 + return []*atproto.DailyStatsRecord{}, nil 279 + } else { 280 + return nil, fmt.Errorf("failed to iterate daily stats records: %w", err) 281 + } 282 + } 283 + 284 + return stats, nil 285 + } 286 + 78 287 // GetStats retrieves the stats record for a repository 79 288 // Returns nil, nil if no stats record exists 80 289 func (p *HoldPDS) GetStats(ctx context.Context, ownerDID, repository string) (cid.Cid, *atproto.StatsRecord, error) {
+2
scanner/internal/scan/worker.go
··· 122 122 // container images so Syft/Grype can't analyze their layers. 123 123 var unscannableConfigTypes = map[string]bool{ 124 124 "application/vnd.cncf.helm.config.v1+json": true, // Helm charts 125 + "application/vnd.in-toto+json": true, // In-toto attestations 126 + "application/vnd.dsse.envelope.v1+json": true, // DSSE envelopes (SLSA) 125 127 } 126 128 127 129 func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) {
+1 -1
scripts/publish-artifact.sh
··· 1 1 #!/usr/bin/env bash 2 2 set -e 3 - goat account login -u evan.jarrett.net -p "${APP_PASSWORD}" 3 + goat account login -u "${TANGLED_REPO_DID}" -p "${APP_PASSWORD}" 4 4 TAG_HASH=$(git rev-parse "$TAG") && 5 5 TAG_BYTES=$(echo -n "$TAG_HASH" | xxd -r -p | base64 | tr -d '=') && 6 6 BLOB_OUTPUT=$(goat blob upload "$ARTIFACT_PATH") &&