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

implement io.atcr.repo.page. try and fetch from github,gitlab,tangled README.md files if source exists.

evan.jarrett.net b18e4c39 24b265bf

verified
+3 -8
cmd/appview/serve.go
··· 82 82 slog.Info("Initializing hold health checker", "cache_ttl", cfg.Health.CacheTTL) 83 83 healthChecker := holdhealth.NewChecker(cfg.Health.CacheTTL) 84 84 85 - // Initialize README cache 86 - slog.Info("Initializing README cache", "cache_ttl", cfg.Health.ReadmeCacheTTL) 87 - readmeCache := readme.NewCache(uiDatabase, cfg.Health.ReadmeCacheTTL) 85 + // Initialize README fetcher for rendering repo page descriptions 86 + readmeFetcher := readme.NewFetcher() 88 87 89 88 // Start background health check worker 90 89 startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose) ··· 159 158 middleware.SetGlobalAuthorizer(holdAuthorizer) 160 159 slog.Info("Hold authorizer initialized with database caching") 161 160 162 - // Set global readme cache for middleware 163 - middleware.SetGlobalReadmeCache(readmeCache) 164 - slog.Info("README cache initialized for manifest push refresh") 165 - 166 161 // Initialize Jetstream workers (background services before HTTP routes) 167 162 initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode) 168 163 ··· 194 189 BaseURL: baseURL, 195 190 DeviceStore: deviceStore, 196 191 HealthChecker: healthChecker, 197 - ReadmeCache: readmeCache, 192 + ReadmeFetcher: readmeFetcher, 198 193 Templates: uiTemplates, 199 194 }) 200 195 }
+3 -4
docs/TEST_COVERAGE_GAPS.md
··· 112 112 113 113 **Remaining gaps:** 114 114 - `notifyHoldAboutManifest()` - 0% (background notification, less critical) 115 - - `refreshReadmeCache()` - 11.8% (UI feature, lower priority) 116 115 117 116 ## Critical Priority: Core Registry Functionality 118 117 ··· 423 422 424 423 --- 425 424 426 - ### 🟡 pkg/appview/readme (16.7% coverage) 425 + ### 🟡 pkg/appview/readme (Partial coverage) 427 426 428 - README fetching and caching. Less critical but still needs work. 427 + README rendering for repo page descriptions. The cache.go was removed as README content is now stored in `io.atcr.repo.page` records and synced via Jetstream. 429 428 430 - #### cache.go (0% coverage) 431 429 #### fetcher.go (📊 Partial coverage) 430 + - `RenderMarkdown()` - renders repo page description markdown 432 431 433 432 --- 434 433
-4
pkg/appview/config.go
··· 79 79 80 80 // CheckInterval is the hold health check refresh interval (from env: ATCR_HEALTH_CHECK_INTERVAL, default: 15m) 81 81 CheckInterval time.Duration `yaml:"check_interval"` 82 - 83 - // ReadmeCacheTTL is the README cache TTL (from env: ATCR_README_CACHE_TTL, default: 1h) 84 - ReadmeCacheTTL time.Duration `yaml:"readme_cache_ttl"` 85 82 } 86 83 87 84 // JetstreamConfig defines ATProto Jetstream settings ··· 165 162 // Health and cache configuration 166 163 cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute) 167 164 cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute) 168 - cfg.Health.ReadmeCacheTTL = getDurationOrDefault("ATCR_README_CACHE_TTL", 1*time.Hour) 169 165 170 166 // Jetstream configuration 171 167 cfg.Jetstream.URL = getEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe")
+18
pkg/appview/db/migrations/0006_add_repo_pages.yaml
··· 1 + description: Add repo_pages table and remove readme_cache 2 + query: | 3 + -- Create repo_pages table for storing repository page metadata 4 + -- This replaces readme_cache with PDS-synced data 5 + CREATE TABLE IF NOT EXISTS repo_pages ( 6 + did TEXT NOT NULL, 7 + repository TEXT NOT NULL, 8 + description TEXT, 9 + avatar_cid TEXT, 10 + created_at TIMESTAMP NOT NULL, 11 + updated_at TIMESTAMP NOT NULL, 12 + PRIMARY KEY(did, repository), 13 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 14 + ); 15 + CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did); 16 + 17 + -- Drop readme_cache table (no longer needed) 18 + DROP TABLE IF EXISTS readme_cache;
+68
pkg/appview/db/queries.go
··· 1691 1691 1692 1692 return featured, nil 1693 1693 } 1694 + 1695 + // RepoPage represents a repository page record cached from PDS 1696 + type RepoPage struct { 1697 + DID string 1698 + Repository string 1699 + Description string 1700 + AvatarCID string 1701 + CreatedAt time.Time 1702 + UpdatedAt time.Time 1703 + } 1704 + 1705 + // UpsertRepoPage inserts or updates a repo page record 1706 + func UpsertRepoPage(db *sql.DB, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error { 1707 + _, err := db.Exec(` 1708 + INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at) 1709 + VALUES (?, ?, ?, ?, ?, ?) 1710 + ON CONFLICT(did, repository) DO UPDATE SET 1711 + description = excluded.description, 1712 + avatar_cid = excluded.avatar_cid, 1713 + updated_at = excluded.updated_at 1714 + `, did, repository, description, avatarCID, createdAt, updatedAt) 1715 + return err 1716 + } 1717 + 1718 + // GetRepoPage retrieves a repo page record 1719 + func GetRepoPage(db *sql.DB, did, repository string) (*RepoPage, error) { 1720 + var rp RepoPage 1721 + err := db.QueryRow(` 1722 + SELECT did, repository, description, avatar_cid, created_at, updated_at 1723 + FROM repo_pages 1724 + WHERE did = ? AND repository = ? 1725 + `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt) 1726 + if err != nil { 1727 + return nil, err 1728 + } 1729 + return &rp, nil 1730 + } 1731 + 1732 + // DeleteRepoPage deletes a repo page record 1733 + func DeleteRepoPage(db *sql.DB, did, repository string) error { 1734 + _, err := db.Exec(` 1735 + DELETE FROM repo_pages WHERE did = ? AND repository = ? 1736 + `, did, repository) 1737 + return err 1738 + } 1739 + 1740 + // GetRepoPagesByDID returns all repo pages for a DID 1741 + func GetRepoPagesByDID(db *sql.DB, did string) ([]RepoPage, error) { 1742 + rows, err := db.Query(` 1743 + SELECT did, repository, description, avatar_cid, created_at, updated_at 1744 + FROM repo_pages 1745 + WHERE did = ? 1746 + `, did) 1747 + if err != nil { 1748 + return nil, err 1749 + } 1750 + defer rows.Close() 1751 + 1752 + var pages []RepoPage 1753 + for rows.Next() { 1754 + var rp RepoPage 1755 + if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil { 1756 + return nil, err 1757 + } 1758 + pages = append(pages, rp) 1759 + } 1760 + return pages, rows.Err() 1761 + }
+10 -5
pkg/appview/db/schema.sql
··· 205 205 ); 206 206 CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at); 207 207 208 - CREATE TABLE IF NOT EXISTS readme_cache ( 209 - url TEXT PRIMARY KEY, 210 - html TEXT NOT NULL, 211 - fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 208 + CREATE TABLE IF NOT EXISTS repo_pages ( 209 + did TEXT NOT NULL, 210 + repository TEXT NOT NULL, 211 + description TEXT, 212 + avatar_cid TEXT, 213 + created_at TIMESTAMP NOT NULL, 214 + updated_at TIMESTAMP NOT NULL, 215 + PRIMARY KEY(did, repository), 216 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 212 217 ); 213 - CREATE INDEX IF NOT EXISTS idx_readme_cache_fetched ON readme_cache(fetched_at); 218 + CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
+27 -14
pkg/appview/handlers/repository.go
··· 27 27 Directory identity.Directory 28 28 Refresher *oauth.Refresher 29 29 HealthChecker *holdhealth.Checker 30 - ReadmeCache *readme.Cache 30 + ReadmeFetcher *readme.Fetcher // For rendering repo page descriptions 31 31 } 32 32 33 33 func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 190 190 isOwner = (user.DID == owner.DID) 191 191 } 192 192 193 - // Fetch README content if available 193 + // Fetch README content from repo page record or annotations 194 194 var readmeHTML template.HTML 195 - if h.ReadmeCache != nil { 196 - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 197 - defer cancel() 198 195 199 - if repo.ReadmeURL != "" { 200 - // Explicit io.atcr.readme takes priority 201 - html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL) 196 + // Try repo page record from database (synced from PDS via Jetstream) 197 + repoPage, err := db.GetRepoPage(h.DB, owner.DID, repository) 198 + if err == nil && repoPage != nil && repoPage.Description != "" { 199 + // Use repo page data 200 + if h.ReadmeFetcher != nil { 201 + html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description)) 202 202 if err != nil { 203 - slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err) 203 + slog.Warn("Failed to render repo page description", "error", err) 204 204 } else { 205 205 readmeHTML = template.HTML(html) 206 206 } 207 - } else if repo.SourceURL != "" { 208 - // Derive README from org.opencontainers.image.source 209 - html, err := h.ReadmeCache.GetFromSource(ctx, repo.SourceURL) 207 + } 208 + if repoPage.AvatarCID != "" { 209 + repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) 210 + } 211 + } else if h.ReadmeFetcher != nil { 212 + // Fall back to fetching from URL annotations 213 + readmeURL := repo.ReadmeURL 214 + if readmeURL == "" && repo.SourceURL != "" { 215 + // Try to derive README URL from source URL 216 + readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "main") 217 + if readmeURL == "" { 218 + readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "master") 219 + } 220 + } 221 + if readmeURL != "" { 222 + html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL) 210 223 if err != nil { 211 - slog.Debug("Failed to derive README from source", "url", repo.SourceURL, "error", err) 212 - } else if html != "" { 224 + slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err) 225 + } else { 213 226 readmeHTML = template.HTML(html) 214 227 } 215 228 }
+4
pkg/appview/jetstream/backfill.go
··· 67 67 atproto.TagCollection, // io.atcr.tag 68 68 atproto.StarCollection, // io.atcr.sailor.star 69 69 atproto.SailorProfileCollection, // io.atcr.sailor.profile 70 + atproto.RepoPageCollection, // io.atcr.repo.page 70 71 } 71 72 72 73 for _, collection := range collections { ··· 282 283 return b.processor.ProcessStar(context.Background(), did, record.Value) 283 284 case atproto.SailorProfileCollection: 284 285 return b.processor.ProcessSailorProfile(ctx, did, record.Value, b.queryCaptainRecordWrapper) 286 + case atproto.RepoPageCollection: 287 + // rkey is extracted from the record URI, but for repo pages we use Repository field 288 + return b.processor.ProcessRepoPage(ctx, did, record.URI, record.Value, false) 285 289 default: 286 290 return fmt.Errorf("unsupported collection: %s", collection) 287 291 }
+24
pkg/appview/jetstream/processor.go
··· 299 299 return nil 300 300 } 301 301 302 + // ProcessRepoPage processes a repository page record 303 + // This is called when Jetstream receives a repo page create/update event 304 + func (p *Processor) ProcessRepoPage(ctx context.Context, did string, rkey string, recordData []byte, isDelete bool) error { 305 + if isDelete { 306 + // Delete the repo page from our cache 307 + return db.DeleteRepoPage(p.db, did, rkey) 308 + } 309 + 310 + // Unmarshal repo page record 311 + var pageRecord atproto.RepoPageRecord 312 + if err := json.Unmarshal(recordData, &pageRecord); err != nil { 313 + return fmt.Errorf("failed to unmarshal repo page: %w", err) 314 + } 315 + 316 + // Extract avatar CID if present 317 + avatarCID := "" 318 + if pageRecord.Avatar != nil && pageRecord.Avatar.Ref.Link != "" { 319 + avatarCID = pageRecord.Avatar.Ref.Link 320 + } 321 + 322 + // Upsert to database 323 + return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.CreatedAt, pageRecord.UpdatedAt) 324 + } 325 + 302 326 // ProcessIdentity handles identity change events (handle updates) 303 327 // This is called when Jetstream receives an identity event indicating a handle change. 304 328 // The identity cache is invalidated to ensure the next lookup uses the new handle,
+38
pkg/appview/jetstream/worker.go
··· 312 312 case atproto.StarCollection: 313 313 slog.Info("Jetstream processing star event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey) 314 314 return w.processStar(commit) 315 + case atproto.RepoPageCollection: 316 + slog.Info("Jetstream processing repo page event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey) 317 + return w.processRepoPage(commit) 315 318 default: 316 319 // Ignore other collections 317 320 return nil ··· 434 437 435 438 // Use shared processor for DB operations 436 439 return w.processor.ProcessStar(context.Background(), commit.DID, recordBytes) 440 + } 441 + 442 + // processRepoPage processes a repo page commit event 443 + func (w *Worker) processRepoPage(commit *CommitEvent) error { 444 + // Resolve and upsert user with handle/PDS endpoint 445 + if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil { 446 + return fmt.Errorf("failed to ensure user: %w", err) 447 + } 448 + 449 + isDelete := commit.Operation == "delete" 450 + 451 + if isDelete { 452 + // Delete - rkey is the repository name 453 + slog.Info("Jetstream deleting repo page", "did", commit.DID, "repository", commit.RKey) 454 + if err := w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, nil, true); err != nil { 455 + slog.Error("Jetstream ERROR deleting repo page", "error", err) 456 + return err 457 + } 458 + slog.Info("Jetstream successfully deleted repo page", "did", commit.DID, "repository", commit.RKey) 459 + return nil 460 + } 461 + 462 + // Parse repo page record 463 + if commit.Record == nil { 464 + return nil 465 + } 466 + 467 + // Marshal map to bytes for processing 468 + recordBytes, err := json.Marshal(commit.Record) 469 + if err != nil { 470 + return fmt.Errorf("failed to marshal record: %w", err) 471 + } 472 + 473 + // Use shared processor for DB operations 474 + return w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, recordBytes, false) 437 475 } 438 476 439 477 // processIdentity processes an identity event (handle change)
+3 -13
pkg/appview/middleware/registry.go
··· 170 170 // These are set by main.go during startup and copied into NamespaceResolver instances. 171 171 // After initialization, request handling uses the NamespaceResolver's instance fields. 172 172 var ( 173 - globalRefresher *oauth.Refresher 174 - globalDatabase storage.DatabaseMetrics 175 - globalAuthorizer auth.HoldAuthorizer 176 - globalReadmeCache storage.ReadmeCache 173 + globalRefresher *oauth.Refresher 174 + globalDatabase storage.DatabaseMetrics 175 + globalAuthorizer auth.HoldAuthorizer 177 176 ) 178 177 179 178 // SetGlobalRefresher sets the OAuth refresher instance during initialization ··· 194 193 globalAuthorizer = authorizer 195 194 } 196 195 197 - // SetGlobalReadmeCache sets the readme cache instance during initialization 198 - // Must be called before the registry starts serving requests 199 - func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) { 200 - globalReadmeCache = readmeCache 201 - } 202 - 203 196 func init() { 204 197 // Register the name resolution middleware 205 198 registrymw.Register("atproto-resolver", initATProtoResolver) ··· 214 207 refresher *oauth.Refresher // OAuth session manager (copied from global on init) 215 208 database storage.DatabaseMetrics // Metrics database (copied from global on init) 216 209 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 217 - readmeCache storage.ReadmeCache // README cache (copied from global on init) 218 210 validationCache *validationCache // Request-level service token cache 219 211 } 220 212 ··· 249 241 refresher: globalRefresher, 250 242 database: globalDatabase, 251 243 authorizer: globalAuthorizer, 252 - readmeCache: globalReadmeCache, 253 244 validationCache: newValidationCache(), 254 245 }, nil 255 246 } ··· 467 458 Database: nr.database, 468 459 Authorizer: nr.authorizer, 469 460 Refresher: nr.refresher, 470 - ReadmeCache: nr.readmeCache, 471 461 } 472 462 473 463 return storage.NewRoutingRepository(repo, registryCtx), nil
-5
pkg/appview/middleware/registry_test.go
··· 67 67 // If we get here without panic, test passes 68 68 } 69 69 70 - func TestSetGlobalReadmeCache(t *testing.T) { 71 - SetGlobalReadmeCache(nil) 72 - // If we get here without panic, test passes 73 - } 74 - 75 70 // TestInitATProtoResolver tests the initialization function 76 71 func TestInitATProtoResolver(t *testing.T) { 77 72 ctx := context.Background()
-175
pkg/appview/readme/cache.go
··· 1 - // Package readme provides README fetching, rendering, and caching functionality 2 - // for container repositories. It fetches markdown content from URLs, renders it 3 - // to sanitized HTML using GitHub-flavored markdown, and caches the results in 4 - // a database with configurable TTL. 5 - package readme 6 - 7 - import ( 8 - "context" 9 - "database/sql" 10 - "log/slog" 11 - "time" 12 - ) 13 - 14 - const ( 15 - // negativeCacheTTL is the TTL for negative cache entries (no README found) 16 - negativeCacheTTL = 15 * time.Minute 17 - // sourceCachePrefix is the prefix for source-derived cache keys 18 - sourceCachePrefix = "source:" 19 - ) 20 - 21 - // Cache stores rendered README HTML in the database 22 - type Cache struct { 23 - db *sql.DB 24 - fetcher *Fetcher 25 - ttl time.Duration 26 - } 27 - 28 - // NewCache creates a new README cache 29 - func NewCache(db *sql.DB, ttl time.Duration) *Cache { 30 - if ttl == 0 { 31 - ttl = 1 * time.Hour // Default TTL 32 - } 33 - return &Cache{ 34 - db: db, 35 - fetcher: NewFetcher(), 36 - ttl: ttl, 37 - } 38 - } 39 - 40 - // Get retrieves a README from cache or fetches it 41 - func (c *Cache) Get(ctx context.Context, readmeURL string) (string, error) { 42 - // Try to get from cache 43 - html, fetchedAt, err := c.getFromDB(readmeURL) 44 - if err == nil { 45 - // Check if cache is still valid 46 - if time.Since(fetchedAt) < c.ttl { 47 - return html, nil 48 - } 49 - } 50 - 51 - // Cache miss or expired, fetch fresh content 52 - html, err = c.fetcher.FetchAndRender(ctx, readmeURL) 53 - if err != nil { 54 - // If fetch fails but we have stale cache, return it 55 - if html != "" { 56 - return html, nil 57 - } 58 - return "", err 59 - } 60 - 61 - // Store in cache 62 - if err := c.storeInDB(readmeURL, html); err != nil { 63 - // Log error but don't fail - we have the content 64 - slog.Warn("Failed to cache README", "error", err) 65 - } 66 - 67 - return html, nil 68 - } 69 - 70 - // GetFromSource fetches a README by deriving the URL from a source repository URL. 71 - // It tries main branch first, then falls back to master if 404. 72 - // Returns empty string if no README found (cached as negative result with shorter TTL). 73 - func (c *Cache) GetFromSource(ctx context.Context, sourceURL string) (string, error) { 74 - cacheKey := sourceCachePrefix + sourceURL 75 - 76 - // Try to get from cache 77 - html, fetchedAt, err := c.getFromDB(cacheKey) 78 - if err == nil { 79 - // Determine TTL based on whether this is a negative cache entry 80 - ttl := c.ttl 81 - if html == "" { 82 - ttl = negativeCacheTTL 83 - } 84 - if time.Since(fetchedAt) < ttl { 85 - return html, nil 86 - } 87 - } 88 - 89 - // Derive README URL and fetch 90 - // Try main branch first 91 - readmeURL := DeriveReadmeURL(sourceURL, "main") 92 - if readmeURL == "" { 93 - return "", nil // Unsupported platform, don't cache 94 - } 95 - 96 - html, err = c.fetcher.FetchAndRender(ctx, readmeURL) 97 - if err != nil { 98 - if Is404(err) { 99 - // Try master branch 100 - readmeURL = DeriveReadmeURL(sourceURL, "master") 101 - html, err = c.fetcher.FetchAndRender(ctx, readmeURL) 102 - if err != nil { 103 - if Is404(err) { 104 - // No README on either branch - cache negative result 105 - if cacheErr := c.storeInDB(cacheKey, ""); cacheErr != nil { 106 - slog.Warn("Failed to cache negative README result", "error", cacheErr) 107 - } 108 - return "", nil 109 - } 110 - // Other error (network, etc.) - don't cache, allow retry 111 - return "", err 112 - } 113 - } else { 114 - // Other error (network, etc.) - don't cache, allow retry 115 - return "", err 116 - } 117 - } 118 - 119 - // Store successful result in cache 120 - if err := c.storeInDB(cacheKey, html); err != nil { 121 - slog.Warn("Failed to cache README from source", "error", err) 122 - } 123 - 124 - return html, nil 125 - } 126 - 127 - // getFromDB retrieves cached README from database 128 - func (c *Cache) getFromDB(readmeURL string) (string, time.Time, error) { 129 - var html string 130 - var fetchedAt time.Time 131 - 132 - err := c.db.QueryRow(` 133 - SELECT html, fetched_at 134 - FROM readme_cache 135 - WHERE url = ? 136 - `, readmeURL).Scan(&html, &fetchedAt) 137 - 138 - if err != nil { 139 - return "", time.Time{}, err 140 - } 141 - 142 - return html, fetchedAt, nil 143 - } 144 - 145 - // storeInDB stores rendered README in database 146 - func (c *Cache) storeInDB(readmeURL, html string) error { 147 - _, err := c.db.Exec(` 148 - INSERT INTO readme_cache (url, html, fetched_at) 149 - VALUES (?, ?, ?) 150 - ON CONFLICT(url) DO UPDATE SET 151 - html = excluded.html, 152 - fetched_at = excluded.fetched_at 153 - `, readmeURL, html, time.Now()) 154 - 155 - return err 156 - } 157 - 158 - // Invalidate removes a README from the cache 159 - func (c *Cache) Invalidate(readmeURL string) error { 160 - _, err := c.db.Exec(` 161 - DELETE FROM readme_cache 162 - WHERE url = ? 163 - `, readmeURL) 164 - return err 165 - } 166 - 167 - // Cleanup removes expired entries from the cache 168 - func (c *Cache) Cleanup() error { 169 - cutoff := time.Now().Add(-c.ttl * 2) // Keep for 2x TTL 170 - _, err := c.db.Exec(` 171 - DELETE FROM readme_cache 172 - WHERE fetched_at < ? 173 - `, cutoff) 174 - return err 175 - }
-256
pkg/appview/readme/cache_test.go
··· 1 - package readme 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - "testing" 8 - "time" 9 - 10 - _ "github.com/mattn/go-sqlite3" 11 - ) 12 - 13 - func TestCache_Struct(t *testing.T) { 14 - // Simple struct test 15 - cache := &Cache{} 16 - if cache == nil { 17 - t.Error("Expected non-nil cache") 18 - } 19 - } 20 - 21 - func setupTestDB(t *testing.T) *sql.DB { 22 - t.Helper() 23 - db, err := sql.Open("sqlite3", ":memory:") 24 - if err != nil { 25 - t.Fatalf("Failed to open database: %v", err) 26 - } 27 - 28 - // Create the readme_cache table 29 - _, err = db.Exec(` 30 - CREATE TABLE readme_cache ( 31 - url TEXT PRIMARY KEY, 32 - html TEXT NOT NULL, 33 - fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 34 - ) 35 - `) 36 - if err != nil { 37 - t.Fatalf("Failed to create table: %v", err) 38 - } 39 - 40 - return db 41 - } 42 - 43 - func TestGetFromSource_UnsupportedPlatform(t *testing.T) { 44 - db := setupTestDB(t) 45 - defer db.Close() 46 - 47 - cache := NewCache(db, time.Hour) 48 - ctx := context.Background() 49 - 50 - // Unsupported platform should return empty, no error 51 - html, err := cache.GetFromSource(ctx, "https://bitbucket.org/user/repo") 52 - if err != nil { 53 - t.Errorf("Expected no error for unsupported platform, got: %v", err) 54 - } 55 - if html != "" { 56 - t.Errorf("Expected empty string for unsupported platform, got: %q", html) 57 - } 58 - } 59 - 60 - func TestGetFromSource_CacheHit(t *testing.T) { 61 - db := setupTestDB(t) 62 - defer db.Close() 63 - 64 - cache := NewCache(db, time.Hour) 65 - sourceURL := "https://github.com/test/repo" 66 - cacheKey := sourceCachePrefix + sourceURL 67 - expectedHTML := "<h1>Cached Content</h1>" 68 - 69 - // Pre-populate cache 70 - _, err := db.Exec(` 71 - INSERT INTO readme_cache (url, html, fetched_at) 72 - VALUES (?, ?, ?) 73 - `, cacheKey, expectedHTML, time.Now()) 74 - if err != nil { 75 - t.Fatalf("Failed to insert cache: %v", err) 76 - } 77 - 78 - ctx := context.Background() 79 - html, err := cache.GetFromSource(ctx, sourceURL) 80 - if err != nil { 81 - t.Errorf("Expected no error, got: %v", err) 82 - } 83 - if html != expectedHTML { 84 - t.Errorf("Expected %q, got %q", expectedHTML, html) 85 - } 86 - } 87 - 88 - func TestGetFromSource_CacheExpired(t *testing.T) { 89 - db := setupTestDB(t) 90 - defer db.Close() 91 - 92 - cache := NewCache(db, time.Millisecond) // Very short TTL 93 - sourceURL := "https://github.com/test/repo" 94 - cacheKey := sourceCachePrefix + sourceURL 95 - oldHTML := "<h1>Old Content</h1>" 96 - 97 - // Pre-populate cache with old timestamp 98 - _, err := db.Exec(` 99 - INSERT INTO readme_cache (url, html, fetched_at) 100 - VALUES (?, ?, ?) 101 - `, cacheKey, oldHTML, time.Now().Add(-time.Hour)) 102 - if err != nil { 103 - t.Fatalf("Failed to insert cache: %v", err) 104 - } 105 - 106 - ctx := context.Background() 107 - 108 - // With expired cache and no network (GitHub won't respond), we expect an error 109 - // but the function should try to fetch 110 - _, err = cache.GetFromSource(ctx, sourceURL) 111 - // We expect an error because we can't actually fetch from GitHub in tests 112 - // The important thing is that it tried to fetch (didn't return cached content) 113 - if err == nil { 114 - t.Log("Note: GetFromSource returned no error - cache was expired and fetch was attempted") 115 - } 116 - } 117 - 118 - func TestGetFromSource_NegativeCache(t *testing.T) { 119 - db := setupTestDB(t) 120 - defer db.Close() 121 - 122 - cache := NewCache(db, time.Hour) 123 - sourceURL := "https://github.com/test/repo" 124 - cacheKey := sourceCachePrefix + sourceURL 125 - 126 - // Pre-populate cache with empty string (negative cache) 127 - _, err := db.Exec(` 128 - INSERT INTO readme_cache (url, html, fetched_at) 129 - VALUES (?, ?, ?) 130 - `, cacheKey, "", time.Now()) 131 - if err != nil { 132 - t.Fatalf("Failed to insert cache: %v", err) 133 - } 134 - 135 - ctx := context.Background() 136 - html, err := cache.GetFromSource(ctx, sourceURL) 137 - if err != nil { 138 - t.Errorf("Expected no error for negative cache hit, got: %v", err) 139 - } 140 - if html != "" { 141 - t.Errorf("Expected empty string for negative cache hit, got: %q", html) 142 - } 143 - } 144 - 145 - func TestGetFromSource_NegativeCacheExpired(t *testing.T) { 146 - db := setupTestDB(t) 147 - defer db.Close() 148 - 149 - cache := NewCache(db, time.Hour) 150 - sourceURL := "https://github.com/test/repo" 151 - cacheKey := sourceCachePrefix + sourceURL 152 - 153 - // Pre-populate cache with expired negative cache (older than negativeCacheTTL) 154 - _, err := db.Exec(` 155 - INSERT INTO readme_cache (url, html, fetched_at) 156 - VALUES (?, ?, ?) 157 - `, cacheKey, "", time.Now().Add(-30*time.Minute)) // 30 min ago, negative TTL is 15 min 158 - if err != nil { 159 - t.Fatalf("Failed to insert cache: %v", err) 160 - } 161 - 162 - ctx := context.Background() 163 - 164 - // With expired negative cache, it should try to fetch again 165 - _, err = cache.GetFromSource(ctx, sourceURL) 166 - // We expect an error because we can't actually fetch from GitHub 167 - // The important thing is that it tried (didn't return empty from expired negative cache) 168 - if err == nil { 169 - t.Log("Note: GetFromSource attempted refetch after negative cache expired") 170 - } 171 - } 172 - 173 - func TestGetFromSource_EmptyURL(t *testing.T) { 174 - db := setupTestDB(t) 175 - defer db.Close() 176 - 177 - cache := NewCache(db, time.Hour) 178 - ctx := context.Background() 179 - 180 - html, err := cache.GetFromSource(ctx, "") 181 - if err != nil { 182 - t.Errorf("Expected no error for empty URL, got: %v", err) 183 - } 184 - if html != "" { 185 - t.Errorf("Expected empty string for empty URL, got: %q", html) 186 - } 187 - } 188 - 189 - func TestGetFromSource_UnsupportedPlatforms(t *testing.T) { 190 - db := setupTestDB(t) 191 - defer db.Close() 192 - 193 - cache := NewCache(db, time.Hour) 194 - ctx := context.Background() 195 - 196 - unsupportedURLs := []string{ 197 - "https://bitbucket.org/user/repo", 198 - "https://sourcehut.org/user/repo", 199 - "https://codeberg.org/user/repo", 200 - "ftp://github.com/user/repo", 201 - "not-a-url", 202 - } 203 - 204 - for _, url := range unsupportedURLs { 205 - html, err := cache.GetFromSource(ctx, url) 206 - if err != nil { 207 - t.Errorf("Expected no error for unsupported URL %q, got: %v", url, err) 208 - } 209 - if html != "" { 210 - t.Errorf("Expected empty string for unsupported URL %q, got: %q", url, html) 211 - } 212 - } 213 - } 214 - 215 - func TestIs404(t *testing.T) { 216 - tests := []struct { 217 - name string 218 - err error 219 - want bool 220 - }{ 221 - { 222 - name: "nil error", 223 - err: nil, 224 - want: false, 225 - }, 226 - { 227 - name: "404 error", 228 - err: fmt.Errorf("unexpected status code: 404"), 229 - want: true, 230 - }, 231 - { 232 - name: "404 error with context", 233 - err: fmt.Errorf("failed to fetch: unexpected status code: 404"), 234 - want: true, 235 - }, 236 - { 237 - name: "500 error", 238 - err: fmt.Errorf("unexpected status code: 500"), 239 - want: false, 240 - }, 241 - { 242 - name: "network error", 243 - err: fmt.Errorf("connection refused"), 244 - want: false, 245 - }, 246 - } 247 - 248 - for _, tt := range tests { 249 - t.Run(tt.name, func(t *testing.T) { 250 - got := Is404(tt.err) 251 - if got != tt.want { 252 - t.Errorf("Is404(%v) = %v, want %v", tt.err, got, tt.want) 253 - } 254 - }) 255 - } 256 - }
+7
pkg/appview/readme/fetcher.go
··· 185 185 return err != nil && strings.Contains(err.Error(), "unexpected status code: 404") 186 186 } 187 187 188 + // RenderMarkdown renders a markdown string to sanitized HTML 189 + // This is used for rendering repo page descriptions stored in the database 190 + func (f *Fetcher) RenderMarkdown(content []byte) (string, error) { 191 + // Render markdown to HTML (no base URL for repo page descriptions) 192 + return f.renderMarkdown(content, "") 193 + } 194 + 188 195 // rewriteRelativeURLs converts relative URLs to absolute URLs 189 196 func rewriteRelativeURLs(html, baseURL string) string { 190 197 if baseURL == "" {
+106
pkg/appview/readme/fetcher_test.go
··· 157 157 } 158 158 } 159 159 160 + func TestFetcher_RenderMarkdown(t *testing.T) { 161 + fetcher := NewFetcher() 162 + 163 + tests := []struct { 164 + name string 165 + content string 166 + wantContain string 167 + wantErr bool 168 + }{ 169 + { 170 + name: "simple paragraph", 171 + content: "Hello, world!", 172 + wantContain: "<p>Hello, world!</p>", 173 + wantErr: false, 174 + }, 175 + { 176 + name: "heading", 177 + content: "# My App", 178 + wantContain: "<h1", 179 + wantErr: false, 180 + }, 181 + { 182 + name: "bold text", 183 + content: "This is **bold** text.", 184 + wantContain: "<strong>bold</strong>", 185 + wantErr: false, 186 + }, 187 + { 188 + name: "italic text", 189 + content: "This is *italic* text.", 190 + wantContain: "<em>italic</em>", 191 + wantErr: false, 192 + }, 193 + { 194 + name: "code block", 195 + content: "```\ncode here\n```", 196 + wantContain: "<pre>", 197 + wantErr: false, 198 + }, 199 + { 200 + name: "link", 201 + content: "[Link text](https://example.com)", 202 + wantContain: `href="https://example.com"`, 203 + wantErr: false, 204 + }, 205 + { 206 + name: "image", 207 + content: "![Alt text](https://example.com/image.png)", 208 + wantContain: `src="https://example.com/image.png"`, 209 + wantErr: false, 210 + }, 211 + { 212 + name: "unordered list", 213 + content: "- Item 1\n- Item 2", 214 + wantContain: "<ul>", 215 + wantErr: false, 216 + }, 217 + { 218 + name: "ordered list", 219 + content: "1. Item 1\n2. Item 2", 220 + wantContain: "<ol>", 221 + wantErr: false, 222 + }, 223 + { 224 + name: "empty content", 225 + content: "", 226 + wantContain: "", 227 + wantErr: false, 228 + }, 229 + { 230 + name: "complex markdown", 231 + content: "# Title\n\nA paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n```go\nfunc main() {}\n```", 232 + wantContain: "<h1", 233 + wantErr: false, 234 + }, 235 + } 236 + 237 + for _, tt := range tests { 238 + t.Run(tt.name, func(t *testing.T) { 239 + html, err := fetcher.RenderMarkdown([]byte(tt.content)) 240 + if (err != nil) != tt.wantErr { 241 + t.Errorf("RenderMarkdown() error = %v, wantErr %v", err, tt.wantErr) 242 + return 243 + } 244 + if !tt.wantErr && tt.wantContain != "" { 245 + if !containsSubstring(html, tt.wantContain) { 246 + t.Errorf("RenderMarkdown() = %q, want to contain %q", html, tt.wantContain) 247 + } 248 + } 249 + }) 250 + } 251 + } 252 + 253 + func containsSubstring(s, substr string) bool { 254 + return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr))) 255 + } 256 + 257 + func containsSubstringHelper(s, substr string) bool { 258 + for i := 0; i <= len(s)-len(substr); i++ { 259 + if s[i:i+len(substr)] == substr { 260 + return true 261 + } 262 + } 263 + return false 264 + } 265 + 160 266 // TODO: Add README fetching and caching tests
+2 -2
pkg/appview/routes/routes.go
··· 27 27 BaseURL string 28 28 DeviceStore *db.DeviceStore 29 29 HealthChecker *holdhealth.Checker 30 - ReadmeCache *readme.Cache 30 + ReadmeFetcher *readme.Fetcher 31 31 Templates *template.Template 32 32 } 33 33 ··· 160 160 Directory: deps.OAuthClientApp.Dir, 161 161 Refresher: deps.Refresher, 162 162 HealthChecker: deps.HealthChecker, 163 - ReadmeCache: deps.ReadmeCache, 163 + ReadmeFetcher: deps.ReadmeFetcher, 164 164 }, 165 165 ).ServeHTTP) 166 166
+75 -42
pkg/appview/static/css/style.css
··· 38 38 --version-badge-text: #7b1fa2; 39 39 --version-badge-border: #ba68c8; 40 40 41 + /* Attestation badge */ 42 + --attestation-badge-bg: #d1fae5; 43 + --attestation-badge-text: #065f46; 44 + 41 45 /* Hero section colors */ 42 46 --hero-bg-start: #f8f9fa; 43 47 --hero-bg-end: #e9ecef; ··· 89 93 --version-badge-bg: #9b59b6; 90 94 --version-badge-text: #ffffff; 91 95 --version-badge-border: #ba68c8; 96 + 97 + /* Attestation badge */ 98 + --attestation-badge-bg: #065f46; 99 + --attestation-badge-text: #6ee7b7; 92 100 93 101 /* Hero section colors */ 94 102 --hero-bg-start: #2d2d2d; ··· 109 117 } 110 118 111 119 body { 112 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 120 + font-family: 121 + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", 122 + Arial, sans-serif; 113 123 background: var(--bg); 114 124 color: var(--fg); 115 125 line-height: 1.6; ··· 170 180 } 171 181 172 182 .nav-links a:hover { 173 - background:var(--secondary); 183 + background: var(--secondary); 174 184 border-radius: 4px; 175 185 } 176 186 ··· 193 203 } 194 204 195 205 .user-menu-btn:hover { 196 - background:var(--secondary); 206 + background: var(--secondary); 197 207 } 198 208 199 209 .user-avatar { ··· 266 276 position: absolute; 267 277 top: calc(100% + 0.5rem); 268 278 right: 0; 269 - background:var(--bg); 279 + background: var(--bg); 270 280 border: 1px solid var(--border); 271 281 border-radius: 8px; 272 282 box-shadow: var(--shadow-lg); ··· 287 297 color: var(--fg); 288 298 text-decoration: none; 289 299 border: none; 290 - background:var(--bg); 300 + background: var(--bg); 291 301 cursor: pointer; 292 302 transition: background 0.2s; 293 303 font-size: 0.95rem; ··· 309 319 } 310 320 311 321 /* Buttons */ 312 - button, .btn, .btn-primary, .btn-secondary { 322 + button, 323 + .btn, 324 + .btn-primary, 325 + .btn-secondary { 313 326 padding: 0.5rem 1rem; 314 327 background: var(--button-primary); 315 328 color: var(--btn-text); ··· 322 335 transition: opacity 0.2s; 323 336 } 324 337 325 - button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { 338 + button:hover, 339 + .btn:hover, 340 + .btn-primary:hover, 341 + .btn-secondary:hover { 326 342 opacity: 0.9; 327 343 } 328 344 ··· 393 409 } 394 410 395 411 /* Cards */ 396 - .push-card, .repository-card { 412 + .push-card, 413 + .repository-card { 397 414 border: 1px solid var(--border); 398 415 border-radius: 8px; 399 416 padding: 1rem; 400 417 margin-bottom: 1rem; 401 - background:var(--bg); 418 + background: var(--bg); 402 419 box-shadow: var(--shadow-sm); 403 420 } 404 421 ··· 449 466 } 450 467 451 468 .digest { 452 - font-family: 'Monaco', 'Courier New', monospace; 469 + font-family: "Monaco", "Courier New", monospace; 453 470 font-size: 0.85rem; 454 471 background: var(--code-bg); 455 472 padding: 0.1rem 0.3rem; ··· 492 509 } 493 510 494 511 .docker-command-text { 495 - font-family: 'Monaco', 'Courier New', monospace; 512 + font-family: "Monaco", "Courier New", monospace; 496 513 font-size: 0.85rem; 497 514 color: var(--fg); 498 515 flex: 0 1 auto; ··· 510 527 border-radius: 4px; 511 528 opacity: 0; 512 529 visibility: hidden; 513 - transition: opacity 0.2s, visibility 0.2s; 530 + transition: 531 + opacity 0.2s, 532 + visibility 0.2s; 514 533 } 515 534 516 535 .docker-command:hover .copy-btn { ··· 752 771 } 753 772 754 773 .repo-stats { 755 - color:var(--border-dark); 774 + color: var(--border-dark); 756 775 font-size: 0.9rem; 757 776 display: flex; 758 777 gap: 0.5rem; ··· 781 800 padding-top: 1rem; 782 801 } 783 802 784 - .tags-section, .manifests-section { 803 + .tags-section, 804 + .manifests-section { 785 805 margin-bottom: 1.5rem; 786 806 } 787 807 788 - .tags-section h3, .manifests-section h3 { 808 + .tags-section h3, 809 + .manifests-section h3 { 789 810 font-size: 1.1rem; 790 811 margin-bottom: 0.5rem; 791 812 color: var(--secondary); 792 813 } 793 814 794 - .tag-row, .manifest-row { 815 + .tag-row, 816 + .manifest-row { 795 817 display: flex; 796 818 gap: 1rem; 797 819 align-items: center; ··· 799 821 border-bottom: 1px solid var(--border); 800 822 } 801 823 802 - .tag-row:last-child, .manifest-row:last-child { 824 + .tag-row:last-child, 825 + .manifest-row:last-child { 803 826 border-bottom: none; 804 827 } 805 828 ··· 821 844 } 822 845 823 846 .settings-section { 824 - background:var(--bg); 847 + background: var(--bg); 825 848 border: 1px solid var(--border); 826 849 border-radius: 8px; 827 850 padding: 1.5rem; ··· 918 941 padding: 1rem; 919 942 border-radius: 4px; 920 943 overflow-x: auto; 921 - font-family: 'Monaco', 'Courier New', monospace; 944 + font-family: "Monaco", "Courier New", monospace; 922 945 font-size: 0.85rem; 923 946 border: 1px solid var(--border); 924 947 } ··· 1024 1047 } 1025 1048 1026 1049 .login-form { 1027 - background:var(--bg); 1050 + background: var(--bg); 1028 1051 padding: 2rem; 1029 1052 border-radius: 8px; 1030 1053 border: 1px solid var(--border); ··· 1175 1198 } 1176 1199 1177 1200 .repository-header { 1178 - background:var(--bg); 1201 + background: var(--bg); 1179 1202 border: 1px solid var(--border); 1180 1203 border-radius: 8px; 1181 1204 padding: 2rem; ··· 1283 1306 } 1284 1307 1285 1308 .star-btn.starred { 1286 - border-color:var(--star); 1309 + border-color: var(--star); 1287 1310 background: var(--code-bg); 1288 1311 } 1289 1312 ··· 1367 1390 } 1368 1391 1369 1392 .repo-section { 1370 - background:var(--bg); 1393 + background: var(--bg); 1371 1394 border: 1px solid var(--border); 1372 1395 border-radius: 8px; 1373 1396 padding: 1.5rem; ··· 1382 1405 border-bottom: 2px solid var(--border); 1383 1406 } 1384 1407 1385 - .tags-list, .manifests-list { 1408 + .tags-list, 1409 + .manifests-list { 1386 1410 display: flex; 1387 1411 flex-direction: column; 1388 1412 gap: 1rem; 1389 1413 } 1390 1414 1391 - .tag-item, .manifest-item { 1415 + .tag-item, 1416 + .manifest-item { 1392 1417 border: 1px solid var(--border); 1393 1418 border-radius: 6px; 1394 1419 padding: 1rem; 1395 1420 background: var(--hover-bg); 1396 1421 } 1397 1422 1398 - .tag-item-header, .manifest-item-header { 1423 + .tag-item-header, 1424 + .manifest-item-header { 1399 1425 display: flex; 1400 1426 justify-content: space-between; 1401 1427 align-items: center; ··· 1525 1551 color: var(--fg); 1526 1552 border: 1px solid var(--border); 1527 1553 white-space: nowrap; 1528 - font-family: 'Monaco', 'Courier New', monospace; 1554 + font-family: "Monaco", "Courier New", monospace; 1529 1555 } 1530 1556 1531 1557 .platforms-inline { ··· 1563 1589 .badge-attestation { 1564 1590 display: inline-flex; 1565 1591 align-items: center; 1566 - gap: 0.35rem; 1567 - padding: 0.25rem 0.5rem; 1568 - background: #f3e8ff; 1569 - color: #7c3aed; 1570 - border: 1px solid #c4b5fd; 1571 - border-radius: 4px; 1572 - font-size: 0.85rem; 1592 + gap: 0.3rem; 1593 + padding: 0.25rem 0.6rem; 1594 + background: var(--attestation-badge-bg); 1595 + color: var(--attestation-badge-text); 1596 + border-radius: 12px; 1597 + font-size: 0.75rem; 1573 1598 font-weight: 600; 1574 1599 margin-left: 0.5rem; 1600 + vertical-align: middle; 1601 + white-space: nowrap; 1575 1602 } 1576 1603 1577 1604 .badge-attestation .lucide { 1578 - width: 0.9rem; 1579 - height: 0.9rem; 1605 + width: 0.75rem; 1606 + height: 0.75rem; 1580 1607 } 1581 1608 1582 1609 /* Featured Repositories Section */ ··· 1729 1756 1730 1757 /* Hero Section */ 1731 1758 .hero-section { 1732 - background: linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-end) 100%); 1759 + background: linear-gradient( 1760 + 135deg, 1761 + var(--hero-bg-start) 0%, 1762 + var(--hero-bg-end) 100% 1763 + ); 1733 1764 padding: 4rem 2rem; 1734 1765 border-bottom: 1px solid var(--border); 1735 1766 } ··· 1794 1825 .terminal-content { 1795 1826 padding: 1.5rem; 1796 1827 margin: 0; 1797 - font-family: 'Monaco', 'Courier New', monospace; 1828 + font-family: "Monaco", "Courier New", monospace; 1798 1829 font-size: 0.95rem; 1799 1830 line-height: 1.8; 1800 1831 color: var(--terminal-text); ··· 1950 1981 } 1951 1982 1952 1983 .code-block code { 1953 - font-family: 'Monaco', 'Menlo', monospace; 1984 + font-family: "Monaco", "Menlo", monospace; 1954 1985 font-size: 0.9rem; 1955 1986 line-height: 1.5; 1956 1987 white-space: pre-wrap; ··· 2007 2038 flex-wrap: wrap; 2008 2039 } 2009 2040 2010 - .tag-row, .manifest-row { 2041 + .tag-row, 2042 + .manifest-row { 2011 2043 flex-wrap: wrap; 2012 2044 } 2013 2045 ··· 2096 2128 /* README and Repository Layout */ 2097 2129 .repo-content-layout { 2098 2130 display: grid; 2099 - grid-template-columns: 7fr 3fr; 2131 + grid-template-columns: 6fr 4fr; 2100 2132 gap: 2rem; 2101 2133 margin-top: 2rem; 2102 2134 } ··· 2207 2239 background: var(--code-bg); 2208 2240 padding: 0.2rem 0.4rem; 2209 2241 border-radius: 3px; 2210 - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 2242 + font-family: 2243 + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; 2211 2244 font-size: 0.9em; 2212 2245 } 2213 2246
+3 -12
pkg/appview/storage/context.go
··· 1 1 package storage 2 2 3 3 import ( 4 - "context" 5 - 6 4 "atcr.io/pkg/atproto" 7 5 "atcr.io/pkg/auth" 8 6 "atcr.io/pkg/auth/oauth" ··· 13 11 IncrementPullCount(did, repository string) error 14 12 IncrementPushCount(did, repository string) error 15 13 GetLatestHoldDIDForRepo(did, repository string) (string, error) 16 - } 17 - 18 - // ReadmeCache interface for README content caching 19 - type ReadmeCache interface { 20 - Get(ctx context.Context, url string) (string, error) 21 - Invalidate(url string) error 22 14 } 23 15 24 16 // RegistryContext bundles all the context needed for registry operations ··· 35 27 AuthMethod string // Auth method used ("oauth" or "app_password") 36 28 37 29 // Shared services (same for all requests) 38 - Database DatabaseMetrics // Metrics tracking database 39 - Authorizer auth.HoldAuthorizer // Hold access authorization 40 - Refresher *oauth.Refresher // OAuth session manager 41 - ReadmeCache ReadmeCache // README content cache 30 + Database DatabaseMetrics // Metrics tracking database 31 + Authorizer auth.HoldAuthorizer // Hold access authorization 32 + Refresher *oauth.Refresher // OAuth session manager 42 33 }
+1 -34
pkg/appview/storage/context_test.go
··· 1 1 package storage 2 2 3 3 import ( 4 - "context" 5 4 "sync" 6 5 "testing" 7 6 ··· 46 45 return m.pushCount 47 46 } 48 47 49 - type mockReadmeCache struct{} 50 - 51 - func (m *mockReadmeCache) Get(ctx context.Context, url string) (string, error) { 52 - return "# Test README", nil 53 - } 54 - 55 - func (m *mockReadmeCache) Invalidate(url string) error { 56 - return nil 57 - } 58 - 59 48 type mockHoldAuthorizer struct{} 60 49 61 50 func (m *mockHoldAuthorizer) Authorize(holdDID, userDID, permission string) (bool, error) { ··· 74 63 ATProtoClient: &atproto.Client{ 75 64 // Mock client - would need proper initialization in real tests 76 65 }, 77 - Database: &mockDatabaseMetrics{}, 78 - ReadmeCache: &mockReadmeCache{}, 66 + Database: &mockDatabaseMetrics{}, 79 67 } 80 68 81 69 // Verify fields are accessible ··· 112 100 } 113 101 114 102 err = ctx.Database.IncrementPushCount("did:plc:test", "repo") 115 - if err != nil { 116 - t.Errorf("Unexpected error: %v", err) 117 - } 118 - } 119 - 120 - func TestRegistryContext_ReadmeCacheInterface(t *testing.T) { 121 - cache := &mockReadmeCache{} 122 - ctx := &RegistryContext{ 123 - ReadmeCache: cache, 124 - } 125 - 126 - // Test that interface methods are callable 127 - content, err := ctx.ReadmeCache.Get(context.Background(), "https://example.com/README.md") 128 - if err != nil { 129 - t.Errorf("Unexpected error: %v", err) 130 - } 131 - if content != "# Test README" { 132 - t.Errorf("Expected content %q, got %q", "# Test README", content) 133 - } 134 - 135 - err = ctx.ReadmeCache.Invalidate("https://example.com/README.md") 136 103 if err != nil { 137 104 t.Errorf("Unexpected error: %v", err) 138 105 }
+33 -28
pkg/appview/storage/manifest_store.go
··· 12 12 "net/http" 13 13 "strings" 14 14 "sync" 15 - "time" 16 15 17 16 "atcr.io/pkg/atproto" 18 17 "github.com/distribution/distribution/v3" ··· 237 236 }() 238 237 } 239 238 240 - // Refresh README cache asynchronously if manifest has io.atcr.readme annotation 241 - // This ensures fresh README content is available on repository pages 239 + // Create or update repo page asynchronously if manifest has relevant annotations 240 + // This ensures repository metadata is synced to user's PDS 242 241 go func() { 243 242 defer func() { 244 243 if r := recover(); r != nil { 245 - slog.Error("Panic in refreshReadmeCache", "panic", r) 244 + slog.Error("Panic in ensureRepoPage", "panic", r) 246 245 } 247 246 }() 248 - s.refreshReadmeCache(context.Background(), manifestRecord) 247 + s.ensureRepoPage(context.Background(), manifestRecord) 249 248 }() 250 249 251 250 return dgst, nil ··· 424 423 return nil 425 424 } 426 425 427 - // refreshReadmeCache refreshes the README cache for this manifest if it has io.atcr.readme annotation 428 - // This should be called asynchronously after manifest push to keep README content fresh 429 - func (s *ManifestStore) refreshReadmeCache(ctx context.Context, manifestRecord *atproto.ManifestRecord) { 430 - // Skip if no README cache configured 431 - if s.ctx.ReadmeCache == nil { 426 + // ensureRepoPage creates or updates a repo page record in the user's PDS if needed 427 + // This syncs repository metadata from manifest annotations to the io.atcr.repo.page collection 428 + // Only creates a new record if one doesn't exist (doesn't overwrite user's custom content) 429 + func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) { 430 + // Skip if no annotations 431 + if manifestRecord.Annotations == nil { 432 432 return 433 433 } 434 434 435 - // Skip if no annotations or no README URL 436 - if manifestRecord.Annotations == nil { 435 + // Check for relevant annotations that we can use for repo page 436 + description := manifestRecord.Annotations["org.opencontainers.image.description"] 437 + if description == "" { 438 + // No description annotation - nothing to create 437 439 return 438 440 } 439 441 440 - readmeURL, ok := manifestRecord.Annotations["io.atcr.readme"] 441 - if !ok || readmeURL == "" { 442 + // Check if repo page already exists (don't overwrite user's custom content) 443 + rkey := s.ctx.Repository 444 + _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey) 445 + if err == nil { 446 + // Record already exists - don't overwrite 447 + slog.Debug("Repo page already exists, skipping creation", "did", s.ctx.DID, "repository", s.ctx.Repository) 442 448 return 443 449 } 444 450 445 - slog.Info("Refreshing README cache", "did", s.ctx.DID, "repository", s.ctx.Repository, "url", readmeURL) 446 - 447 - // Invalidate the cached entry first 448 - if err := s.ctx.ReadmeCache.Invalidate(readmeURL); err != nil { 449 - slog.Warn("Failed to invalidate README cache", "url", readmeURL, "error", err) 450 - // Continue anyway - Get() will still fetch fresh content 451 + // Only continue if it's a "not found" error - other errors mean we should skip 452 + if !errors.Is(err, atproto.ErrRecordNotFound) { 453 + slog.Warn("Failed to check for existing repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err) 454 + return 451 455 } 452 456 453 - // Fetch fresh content to populate cache 454 - // Use context with timeout to avoid hanging on slow/dead URLs 455 - ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second) 456 - defer cancel() 457 + // Create new repo page record from manifest annotations 458 + // Note: Avatar is not extracted from annotations here - that's handled separately 459 + // (would require uploading a blob if annotation contains a URL) 460 + repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, nil) 461 + 462 + slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository) 457 463 458 - _, err := s.ctx.ReadmeCache.Get(ctxWithTimeout, readmeURL) 464 + _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.RepoPageCollection, rkey, repoPage) 459 465 if err != nil { 460 - slog.Warn("Failed to refresh README cache", "url", readmeURL, "error", err) 461 - // Not a critical error - cache will be refreshed on next page view 466 + slog.Warn("Failed to create repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err) 462 467 return 463 468 } 464 469 465 - slog.Info("README cache refreshed successfully", "url", readmeURL) 470 + slog.Info("Repo page created successfully", "did", s.ctx.DID, "repository", s.ctx.Repository) 466 471 }
+40
pkg/atproto/lexicon.go
··· 50 50 51 51 // StarCollection is the collection name for repository stars 52 52 StarCollection = "io.atcr.sailor.star" 53 + 54 + // RepoPageCollection is the collection name for repository page metadata 55 + // Stored in user's PDS with rkey = repository name 56 + RepoPageCollection = "io.atcr.repo.page" 53 57 ) 54 58 55 59 // ManifestRecord represents a container image manifest stored in ATProto ··· 345 349 return &SailorProfileRecord{ 346 350 Type: SailorProfileCollection, 347 351 DefaultHold: defaultHold, 352 + CreatedAt: now, 353 + UpdatedAt: now, 354 + } 355 + } 356 + 357 + // RepoPageRecord represents repository page metadata (description + avatar) 358 + // Stored in the user's PDS with rkey = repository name 359 + // Users can edit this directly in their PDS to customize their repository page 360 + type RepoPageRecord struct { 361 + // Type should be "io.atcr.repo.page" 362 + Type string `json:"$type"` 363 + 364 + // Repository is the name of the repository (e.g., "myapp") 365 + Repository string `json:"repository"` 366 + 367 + // Description is the markdown README/description content 368 + Description string `json:"description,omitempty"` 369 + 370 + // Avatar is the repository avatar/icon blob reference 371 + Avatar *ATProtoBlobRef `json:"avatar,omitempty"` 372 + 373 + // CreatedAt timestamp 374 + CreatedAt time.Time `json:"createdAt"` 375 + 376 + // UpdatedAt timestamp 377 + UpdatedAt time.Time `json:"updatedAt"` 378 + } 379 + 380 + // NewRepoPageRecord creates a new repo page record 381 + func NewRepoPageRecord(repository, description string, avatar *ATProtoBlobRef) *RepoPageRecord { 382 + now := time.Now() 383 + return &RepoPageRecord{ 384 + Type: RepoPageCollection, 385 + Repository: repository, 386 + Description: description, 387 + Avatar: avatar, 348 388 CreatedAt: now, 349 389 UpdatedAt: now, 350 390 }
+132
pkg/atproto/lexicon_test.go
··· 1285 1285 t.Errorf("CreatedAt = %q, want %q", decoded.CreatedAt, record.CreatedAt) 1286 1286 } 1287 1287 } 1288 + 1289 + func TestNewRepoPageRecord(t *testing.T) { 1290 + tests := []struct { 1291 + name string 1292 + repository string 1293 + description string 1294 + avatar *ATProtoBlobRef 1295 + }{ 1296 + { 1297 + name: "with description only", 1298 + repository: "myapp", 1299 + description: "# My App\n\nA cool container image.", 1300 + avatar: nil, 1301 + }, 1302 + { 1303 + name: "with avatar only", 1304 + repository: "another-app", 1305 + description: "", 1306 + avatar: &ATProtoBlobRef{ 1307 + Type: "blob", 1308 + Ref: Link{Link: "bafyreiabc123"}, 1309 + MimeType: "image/png", 1310 + Size: 1024, 1311 + }, 1312 + }, 1313 + { 1314 + name: "with both description and avatar", 1315 + repository: "full-app", 1316 + description: "This is a full description.", 1317 + avatar: &ATProtoBlobRef{ 1318 + Type: "blob", 1319 + Ref: Link{Link: "bafyreiabc456"}, 1320 + MimeType: "image/jpeg", 1321 + Size: 2048, 1322 + }, 1323 + }, 1324 + { 1325 + name: "empty values", 1326 + repository: "", 1327 + description: "", 1328 + avatar: nil, 1329 + }, 1330 + } 1331 + 1332 + for _, tt := range tests { 1333 + t.Run(tt.name, func(t *testing.T) { 1334 + before := time.Now() 1335 + record := NewRepoPageRecord(tt.repository, tt.description, tt.avatar) 1336 + after := time.Now() 1337 + 1338 + if record.Type != RepoPageCollection { 1339 + t.Errorf("Type = %v, want %v", record.Type, RepoPageCollection) 1340 + } 1341 + 1342 + if record.Repository != tt.repository { 1343 + t.Errorf("Repository = %v, want %v", record.Repository, tt.repository) 1344 + } 1345 + 1346 + if record.Description != tt.description { 1347 + t.Errorf("Description = %v, want %v", record.Description, tt.description) 1348 + } 1349 + 1350 + if tt.avatar == nil && record.Avatar != nil { 1351 + t.Error("Avatar should be nil") 1352 + } 1353 + 1354 + if tt.avatar != nil { 1355 + if record.Avatar == nil { 1356 + t.Fatal("Avatar should not be nil") 1357 + } 1358 + if record.Avatar.Ref.Link != tt.avatar.Ref.Link { 1359 + t.Errorf("Avatar.Ref.Link = %v, want %v", record.Avatar.Ref.Link, tt.avatar.Ref.Link) 1360 + } 1361 + } 1362 + 1363 + if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 1364 + t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 1365 + } 1366 + 1367 + if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 1368 + t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 1369 + } 1370 + 1371 + // CreatedAt and UpdatedAt should be equal for new records 1372 + if !record.CreatedAt.Equal(record.UpdatedAt) { 1373 + t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt) 1374 + } 1375 + }) 1376 + } 1377 + } 1378 + 1379 + func TestRepoPageRecord_JSONSerialization(t *testing.T) { 1380 + record := NewRepoPageRecord( 1381 + "myapp", 1382 + "# My App\n\nA description with **markdown**.", 1383 + &ATProtoBlobRef{ 1384 + Type: "blob", 1385 + Ref: Link{Link: "bafyreiabc123"}, 1386 + MimeType: "image/png", 1387 + Size: 1024, 1388 + }, 1389 + ) 1390 + 1391 + // Serialize to JSON 1392 + jsonData, err := json.Marshal(record) 1393 + if err != nil { 1394 + t.Fatalf("json.Marshal() error = %v", err) 1395 + } 1396 + 1397 + // Deserialize from JSON 1398 + var decoded RepoPageRecord 1399 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 1400 + t.Fatalf("json.Unmarshal() error = %v", err) 1401 + } 1402 + 1403 + // Verify fields 1404 + if decoded.Type != record.Type { 1405 + t.Errorf("Type = %v, want %v", decoded.Type, record.Type) 1406 + } 1407 + if decoded.Repository != record.Repository { 1408 + t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository) 1409 + } 1410 + if decoded.Description != record.Description { 1411 + t.Errorf("Description = %v, want %v", decoded.Description, record.Description) 1412 + } 1413 + if decoded.Avatar == nil { 1414 + t.Fatal("Avatar should not be nil") 1415 + } 1416 + if decoded.Avatar.Ref.Link != record.Avatar.Ref.Link { 1417 + t.Errorf("Avatar.Ref.Link = %v, want %v", decoded.Avatar.Ref.Link, record.Avatar.Ref.Link) 1418 + } 1419 + }