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

Configure Feed

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

more ui fixes and scanner fixes

+92 -102
+2 -2
pkg/appview/handlers/home.go
··· 31 31 } 32 32 db.SetRegistryURL(featuredCards, h.RegistryURL) 33 33 34 - // Fetch recently updated repositories (top 18 by last push - 6 rows) 35 - recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 18, currentUserDID, db.SortByLastUpdate) 34 + // Fetch recently updated repositories (top 24 by last push - 6 rows at 4-col xl) 35 + recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 24, currentUserDID, db.SortByLastUpdate) 36 36 if err != nil { 37 37 log.Printf("Error fetching recent repos: %v", err) 38 38 recentCards = []db.RepoCardData{}
+1
pkg/appview/public/icons.svg
··· 43 43 <symbol id="loader" viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></symbol> 44 44 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 45 45 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 46 + <symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol> 46 47 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol> 47 48 <symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol> 48 49 <symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol>
+1 -1
pkg/appview/templates/components/card-grid.html
··· 15 15 - .HasMore: bool - whether to show Load More button 16 16 */}} 17 17 {{ if .Repositories }} 18 - <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-6"> 18 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-4"> 19 19 {{ range .Repositories }} 20 20 {{ template "repo-card" . }} 21 21 {{ end }}
+19
pkg/appview/templates/components/docker-command.html
··· 13 13 </button> 14 14 </div> 15 15 {{ end }} 16 + 17 + {{ define "image-ref" }} 18 + {{/* 19 + Image reference component - shows a short image reference with a copy button 20 + that copies the full pull command. Used in dense card layouts where the full 21 + command would truncate. 22 + 23 + Expects: dict with 24 + - Display: string - short form shown in the UI (e.g. "alice.bsky.social/myapp:v1.2.3") 25 + - Copy: string - full command copied to clipboard (e.g. "docker pull atcr.io/alice.bsky.social/myapp:v1.2.3") 26 + */}} 27 + <div class="cmd group !w-full"> 28 + {{ icon "package" "size-4 shrink-0 text-base-content/60" }} 29 + <code class="flex-1">{{ .Display }}</code> 30 + <button class="btn btn-ghost btn-xs shrink-0 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ .Copy }}" aria-label="Copy pull command to clipboard"> 31 + {{ icon "copy" "size-4" }} 32 + </button> 33 + </div> 34 + {{ end }}
+14 -6
pkg/appview/templates/components/repo-card.html
··· 17 17 - LastUpdated: time.Time (optional) - Last push time 18 18 - RegistryURL: string - Registry URL for docker commands (e.g., "atcr.io") 19 19 */}} 20 - <div class="card card-interactive bg-base-200 border-2 border-base-300 p-6 flex flex-col justify-between min-h-60 w-full" data-href="/r/{{ .OwnerHandle }}/{{ .Repository }}"> 20 + <div class="card card-interactive bg-base-200 border-2 border-base-300 p-4 flex flex-col justify-between min-h-60 w-full" data-href="/r/{{ .OwnerHandle }}/{{ .Repository }}"> 21 21 <div class="flex gap-4 items-start"> 22 22 {{ if .IconURL }} 23 23 <img src="{{ resizeImage .IconURL 96 }}" alt="{{ .Repository }}" loading="lazy" width="48" height="48" class="w-12 rounded-lg object-cover shrink-0"> ··· 47 47 <div class="flex-1 flex flex-col justify-end py-2 min-w-0"> 48 48 {{ if eq .ArtifactType "helm-chart" }} 49 49 {{ if .Tag }} 50 - {{ template "docker-command" (printf "helm pull oci://%s/%s/%s --version %s" .RegistryURL .OwnerHandle .Repository .Tag) }} 50 + {{ template "image-ref" (dict 51 + "Display" (printf "%s/%s:%s" .OwnerHandle .Repository .Tag) 52 + "Copy" (printf "helm pull oci://%s/%s/%s --version %s" .RegistryURL .OwnerHandle .Repository .Tag)) }} 51 53 {{ else }} 52 - {{ template "docker-command" (printf "helm pull oci://%s/%s/%s" .RegistryURL .OwnerHandle .Repository) }} 54 + {{ template "image-ref" (dict 55 + "Display" (printf "%s/%s" .OwnerHandle .Repository) 56 + "Copy" (printf "helm pull oci://%s/%s/%s" .RegistryURL .OwnerHandle .Repository)) }} 53 57 {{ end }} 54 58 {{ else }} 55 59 {{ if .Tag }} 56 - {{ template "docker-command" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag) }} 60 + {{ template "image-ref" (dict 61 + "Display" (printf "%s/%s:%s" .OwnerHandle .Repository .Tag) 62 + "Copy" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag)) }} 57 63 {{ else }} 58 - {{ template "docker-command" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository) }} 64 + {{ template "image-ref" (dict 65 + "Display" (printf "%s/%s" .OwnerHandle .Repository) 66 + "Copy" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository)) }} 59 67 {{ end }} 60 68 {{ end }} 61 69 </div> 62 - <div class="flex justify-between items-center pt-3 border-t border-base-300"> 70 + <div class="flex justify-between items-center pt-3 -mx-4 px-4 border-t border-base-300"> 63 71 <div class="flex gap-6 items-center"> 64 72 {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }} 65 73 {{ template "pull-count" (dict "PullCount" .PullCount) }}
+1 -1
pkg/appview/templates/pages/home.html
··· 45 45 {{ if .RecentRepos }} 46 46 <section> 47 47 <h2 class="text-2xl font-bold mb-6">What's New</h2> 48 - {{ template "card-grid" (dict "Repositories" .RecentRepos) }} 48 + {{ template "card-grid" (dict "Repositories" .RecentRepos "Columns" 4) }} 49 49 </section> 50 50 {{ end }} 51 51 </div>
+1 -1
pkg/appview/templates/pages/user.html
··· 48 48 </div> 49 49 {{ else }} 50 50 <div class="w-full"> 51 - {{ template "card-grid" (dict "Repositories" .Repositories "EmptyMessage" "No images yet.") }} 51 + {{ template "card-grid" (dict "Repositories" .Repositories "Columns" 4 "EmptyMessage" "No images yet.") }} 52 52 </div> 53 53 {{ end }} 54 54 </div>
+1
pkg/appview/templates/partials/search-results.html
··· 1 1 {{/* Search results partial - renders repository cards in a grid */}} 2 2 {{ template "card-grid" (dict 3 3 "Repositories" .Repositories 4 + "Columns" 4 4 5 "EmptyIcon" "search-x" 5 6 "EmptyMessage" "No repositories found matching your search." 6 7 "EmptySubtext" "Try a different search term or browse the homepage."
+1
pkg/hold/admin/public/icons.svg
··· 43 43 <symbol id="loader" viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></symbol> 44 44 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 45 45 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 46 + <symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol> 46 47 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol> 47 48 <symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol> 48 49 <symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol>
+37 -86
scanner/internal/scan/grype.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "os" 10 - "path/filepath" 11 10 "sync" 12 11 "sync/atomic" 13 12 "time" ··· 126 125 return reportJSON, digest, summary, nil 127 126 } 128 127 128 + // grypeDBConfig returns the distribution and installation configs used for 129 + // all Grype DB load/update calls. Kept in one place so both the initial load 130 + // and the periodic reload see identical settings. 131 + func grypeDBConfig(vulnDBPath string) (distribution.Config, installation.Config) { 132 + return distribution.DefaultConfig(), installation.Config{ 133 + DBRootDir: vulnDBPath, 134 + ValidateAge: true, 135 + ValidateChecksum: true, 136 + MaxAllowedBuiltAge: 14 * 24 * time.Hour, // tolerates upstream publish gaps 137 + } 138 + } 139 + 129 140 // loadVulnDatabase loads the Grype vulnerability database with caching and 130 141 // automatic refresh. The cached DB is returned if loaded less than 131 - // vulnDBRefreshAge ago. On a stale or missing DB, it downloads a fresh copy. 142 + // vulnDBRefreshAge ago. On a stale or missing DB, Grype downloads a fresh copy 143 + // in the same call (update=true) — a single curator handles everything so 144 + // there is no chance of a double-curator update+load seeing different state. 132 145 func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Provider, error) { 133 146 vulnDBLock.RLock() 134 147 if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge { ··· 155 168 } 156 169 } 157 170 158 - slog.Info("Loading Grype vulnerability database", "path", vulnDBPath) 171 + slog.Info("Loading Grype vulnerability database", "path", vulnDBPath, "tmpdir", os.Getenv("TMPDIR")) 159 172 160 - if err := os.MkdirAll(vulnDBPath, 0755); err != nil { 173 + if err := os.MkdirAll(vulnDBPath, 0o755); err != nil { 161 174 return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err) 162 175 } 163 176 164 - distConfig := distribution.DefaultConfig() 165 - installConfig := installation.Config{ 166 - DBRootDir: vulnDBPath, 167 - ValidateAge: true, 168 - ValidateChecksum: true, 169 - MaxAllowedBuiltAge: 14 * 24 * time.Hour, // 2 weeks — tolerates upstream publish gaps 170 - } 177 + distConfig, installConfig := grypeDBConfig(vulnDBPath) 171 178 172 - // Try loading existing DB first (no network) 173 - store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false) 179 + // update=true: a single grype curator checks the upstream feed, downloads 180 + // if needed, activates, and then opens the reader — all in one call. If 181 + // the upstream is unreachable but the on-disk DB is still valid, it falls 182 + // back to serving the existing DB. 183 + store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, true) 174 184 if err != nil { 175 - slog.Warn("Vulnerability database load failed, attempting update", "error", err) 176 - 177 - // Download fresh DB 178 - if updateErr := updateVulnDatabase(vulnDBPath); updateErr != nil { 179 - return nil, fmt.Errorf("failed to update vulnerability database: %w (original: %w)", updateErr, err) 180 - } 181 - 182 - // Retry loading after update 183 - store, status, err = grype.LoadVulnerabilityDB(distConfig, installConfig, false) 184 - if err != nil { 185 - return nil, fmt.Errorf("failed to load vulnerability database after update (status=%v): %w", status, err) 186 - } 185 + return nil, fmt.Errorf("failed to load vulnerability database: %w", err) 187 186 } 188 187 188 + age := "unknown" 189 + if !status.Built.IsZero() { 190 + age = time.Since(status.Built).Round(time.Minute).String() 191 + } 189 192 slog.Info("Vulnerability database loaded", 190 193 "built", status.Built, 191 - "schemaVersion", status.SchemaVersion) 194 + "age", age, 195 + "schemaVersion", status.SchemaVersion, 196 + "path", status.Path) 192 197 193 198 if vulnDB != nil { 194 199 vulnDB.Close() ··· 198 203 return vulnDB, nil 199 204 } 200 205 201 - // initializeVulnDatabase ensures a fresh vulnerability database exists on startup. 202 - func initializeVulnDatabase(vulnDBPath, tmpDir string) error { 206 + // initializeVulnDatabase primes the in-memory DB cache on startup so the first 207 + // scan doesn't pay the download cost. Caller is responsible for TMPDIR being 208 + // set to a path on the same filesystem as vulnDBPath — see WorkerPool.Start. 209 + func initializeVulnDatabase(vulnDBPath string) error { 203 210 slog.Info("Initializing vulnerability database", "path", vulnDBPath) 204 - 205 - grpeTmpDir := filepath.Join(tmpDir, "grype-dl") 206 - if err := os.MkdirAll(grpeTmpDir, 0755); err != nil { 207 - return fmt.Errorf("failed to create temp directory: %w", err) 208 - } 209 - 210 - oldTmpDir := os.Getenv("TMPDIR") 211 - os.Setenv("TMPDIR", grpeTmpDir) 212 - defer func() { 213 - if oldTmpDir != "" { 214 - os.Setenv("TMPDIR", oldTmpDir) 215 - } else { 216 - os.Unsetenv("TMPDIR") 217 - } 218 - }() 219 - 220 - return updateVulnDatabase(vulnDBPath) 221 - } 222 - 223 - // updateVulnDatabase downloads a fresh vulnerability database if needed. 224 - // The curator internally checks whether an update is necessary (DB missing, 225 - // stale, or update-check cooldown expired) so this is safe to call often. 226 - func updateVulnDatabase(vulnDBPath string) error { 227 - if err := os.MkdirAll(vulnDBPath, 0755); err != nil { 228 - return fmt.Errorf("failed to create database directory: %w", err) 229 - } 230 - 231 - distConfig := distribution.DefaultConfig() 232 - installConfig := installation.Config{ 233 - DBRootDir: vulnDBPath, 234 - ValidateAge: true, 235 - ValidateChecksum: true, 236 - MaxAllowedBuiltAge: 14 * 24 * time.Hour, 237 - } 238 - 239 - downloader, err := distribution.NewClient(distConfig) 240 - if err != nil { 241 - return fmt.Errorf("failed to create database downloader: %w", err) 242 - } 243 - 244 - curator, err := installation.NewCurator(installConfig, downloader) 245 - if err != nil { 246 - return fmt.Errorf("failed to create database curator: %w", err) 247 - } 248 - 249 - slog.Info("Checking vulnerability database for updates...") 250 - updated, err := curator.Update() 251 - if err != nil { 252 - return fmt.Errorf("failed to update vulnerability database: %w", err) 253 - } 254 - 255 - if updated { 256 - slog.Info("Vulnerability database updated successfully") 257 - } else { 258 - slog.Info("Vulnerability database is up to date") 259 - } 260 - 261 - return nil 211 + _, err := loadVulnDatabase(context.Background(), vulnDBPath) 212 + return err 262 213 } 263 214 264 215 func countVulnerabilitiesBySeverity(matches match.Matches) scanner.VulnerabilitySummary {
+14 -5
scanner/internal/scan/worker.go
··· 37 37 38 38 // Start launches worker goroutines 39 39 func (wp *WorkerPool) Start(ctx context.Context) { 40 + // Point TMPDIR at the configured tmp dir so Grype's DB download 41 + // (go-getter zstd decompression can be 1 GB+) and stereoscope's layer 42 + // extraction both land on the same partition as the scanner volume — 43 + // NOT on /tmp, which is typically tmpfs with ~400 MB and would silently 44 + // fail mid-extract. This must be set before any scanner/grype goroutine 45 + // starts and must never be restored to a smaller default mid-process. 46 + if wp.cfg.Vuln.TmpDir != "" { 47 + if err := os.MkdirAll(wp.cfg.Vuln.TmpDir, 0o755); err != nil { 48 + slog.Warn("Failed to create scanner tmp dir", "path", wp.cfg.Vuln.TmpDir, "error", err) 49 + } 50 + os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir) 51 + } 52 + 40 53 // Initialize vuln database on startup if enabled 41 54 if wp.cfg.Vuln.Enabled { 42 55 go func() { 43 - if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath, wp.cfg.Vuln.TmpDir); err != nil { 56 + if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath); err != nil { 44 57 slog.Error("Failed to initialize vulnerability database", "error", err) 45 58 slog.Warn("Vulnerability scanning will be disabled until database is available") 46 59 } 47 60 }() 48 61 } 49 - 50 - // Point TMPDIR at the configured tmp dir so stereoscope's internal 51 - // layer extraction uses the same partition (not /tmp which may be small) 52 - os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir) 53 62 54 63 for i := 0; i < wp.cfg.Scanner.Workers; i++ { 55 64 wp.wg.Add(1)