+3
-8
cmd/appview/serve.go
+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
+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
-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
+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
+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
+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
-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
+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
+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
+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
+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
-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
-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
-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
+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
+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: "",
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
+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
+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
+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
-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
+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
+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
+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
+
}