A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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") &&