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.

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

+597 -602
+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 + }