+4
cmd/appview/serve.go
+4
cmd/appview/serve.go
···
201
201
middleware.SetGlobalAuthorizer(holdAuthorizer)
202
202
fmt.Println("Hold authorizer initialized with database caching")
203
203
204
+
// Set global readme cache for middleware
205
+
middleware.SetGlobalReadmeCache(readmeCache)
206
+
fmt.Println("README cache initialized for manifest push refresh")
207
+
204
208
// Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache
205
209
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache)
206
210
+10
pkg/appview/middleware/registry.go
+10
pkg/appview/middleware/registry.go
···
36
36
globalRefresher *oauth.Refresher
37
37
globalDatabase storage.DatabaseMetrics
38
38
globalAuthorizer auth.HoldAuthorizer
39
+
globalReadmeCache storage.ReadmeCache
39
40
)
40
41
41
42
// SetGlobalRefresher sets the OAuth refresher instance during initialization
···
56
57
globalAuthorizer = authorizer
57
58
}
58
59
60
+
// SetGlobalReadmeCache sets the readme cache instance during initialization
61
+
// Must be called before the registry starts serving requests
62
+
func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
63
+
globalReadmeCache = readmeCache
64
+
}
65
+
59
66
func init() {
60
67
// Register the name resolution middleware
61
68
registrymw.Register("atproto-resolver", initATProtoResolver)
···
71
78
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
72
79
database storage.DatabaseMetrics // Metrics database (copied from global on init)
73
80
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
81
+
readmeCache storage.ReadmeCache // README cache (copied from global on init)
74
82
}
75
83
76
84
// initATProtoResolver initializes the name resolution middleware
···
101
109
refresher: globalRefresher,
102
110
database: globalDatabase,
103
111
authorizer: globalAuthorizer,
112
+
readmeCache: globalReadmeCache,
104
113
}, nil
105
114
}
106
115
···
321
330
Database: nr.database,
322
331
Authorizer: nr.authorizer,
323
332
Refresher: nr.refresher,
333
+
ReadmeCache: nr.readmeCache,
324
334
}
325
335
routingRepo := storage.NewRoutingRepository(repo, registryCtx)
326
336
+6
pkg/appview/static/css/style.css
+6
pkg/appview/static/css/style.css
···
1707
1707
border: 1px solid var(--border);
1708
1708
border-radius: 8px;
1709
1709
padding: 2rem;
1710
+
min-width: 0;
1711
+
box-sizing: border-box;
1710
1712
}
1711
1713
1712
1714
.readme-section h2 {
···
1717
1719
1718
1720
.readme-content {
1719
1721
overflow-wrap: break-word;
1722
+
max-width: 100%;
1723
+
box-sizing: border-box;
1720
1724
}
1721
1725
1722
1726
.repo-sidebar {
···
1814
1818
border-radius: 6px;
1815
1819
overflow-x: auto;
1816
1820
margin-bottom: 1rem;
1821
+
max-width: 100%;
1822
+
box-sizing: border-box;
1817
1823
}
1818
1824
1819
1825
.markdown-body pre code {
+12
-3
pkg/appview/storage/context.go
+12
-3
pkg/appview/storage/context.go
···
1
1
package storage
2
2
3
3
import (
4
+
"context"
5
+
4
6
"atcr.io/pkg/atproto"
5
7
"atcr.io/pkg/auth"
6
8
"atcr.io/pkg/auth/oauth"
···
10
12
type DatabaseMetrics interface {
11
13
IncrementPullCount(did, repository string) error
12
14
IncrementPushCount(did, repository string) error
15
+
}
16
+
17
+
// ReadmeCache interface for README content caching
18
+
type ReadmeCache interface {
19
+
Get(ctx context.Context, url string) (string, error)
20
+
Invalidate(url string) error
13
21
}
14
22
15
23
// RegistryContext bundles all the context needed for registry operations
···
25
33
ATProtoClient *atproto.Client // Authenticated ATProto client for this user
26
34
27
35
// Shared services (same for all requests)
28
-
Database DatabaseMetrics // Metrics tracking database
29
-
Authorizer auth.HoldAuthorizer // Hold access authorization
30
-
Refresher *oauth.Refresher // OAuth session manager
36
+
Database DatabaseMetrics // Metrics tracking database
37
+
Authorizer auth.HoldAuthorizer // Hold access authorization
38
+
Refresher *oauth.Refresher // OAuth session manager
39
+
ReadmeCache ReadmeCache // README content cache
31
40
}
+54
-16
pkg/appview/storage/manifest_store.go
+54
-16
pkg/appview/storage/manifest_store.go
···
10
10
"maps"
11
11
"net/http"
12
12
"strings"
13
+
"time"
13
14
14
15
"atcr.io/pkg/atproto"
15
16
"github.com/distribution/distribution/v3"
16
17
"github.com/opencontainers/go-digest"
17
18
)
18
19
19
-
// HoldNotifier interface for notifying holds about manifest uploads
20
-
type HoldNotifier interface {
21
-
GetServiceToken(ctx context.Context, userDID, audienceDID string) (string, error)
22
-
}
23
-
24
20
// ManifestStore implements distribution.ManifestService
25
21
// It stores manifests in ATProto as records
26
22
type ManifestStore struct {
27
23
ctx *RegistryContext // Context with user/hold info
28
-
notifier HoldNotifier // OAuth refresher for getting service tokens
29
24
lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
30
25
blobStore distribution.BlobStore // Blob store for fetching config during push
31
26
}
32
27
33
28
// NewManifestStore creates a new ATProto-backed manifest store
34
-
func NewManifestStore(ctx *RegistryContext, notifier HoldNotifier, blobStore distribution.BlobStore) *ManifestStore {
29
+
func NewManifestStore(ctx *RegistryContext, blobStore distribution.BlobStore) *ManifestStore {
35
30
return &ManifestStore{
36
31
ctx: ctx,
37
-
notifier: notifier,
38
32
blobStore: blobStore,
39
33
}
40
34
}
···
195
189
196
190
// Notify hold about manifest upload (for layer tracking and Bluesky posts)
197
191
// Do this asynchronously to avoid blocking the push
198
-
if tag != "" && s.notifier != nil && s.ctx.Handle != "" {
192
+
if tag != "" && s.ctx.ServiceToken != "" && s.ctx.Handle != "" {
199
193
go func() {
200
194
if err := s.notifyHoldAboutManifest(context.Background(), manifestRecord, tag, dgst.String()); err != nil {
201
195
fmt.Printf("WARNING: Failed to notify hold about manifest: %v\n", err)
202
196
}
203
197
}()
204
198
}
199
+
200
+
// Refresh README cache asynchronously if manifest has io.atcr.readme annotation
201
+
// This ensures fresh README content is available on repository pages
202
+
go func() {
203
+
s.refreshReadmeCache(context.Background(), manifestRecord)
204
+
}()
205
205
206
206
return dgst, nil
207
207
}
···
287
287
// notifyHoldAboutManifest notifies the hold service about a manifest upload
288
288
// This enables the hold to create layer records and Bluesky posts
289
289
func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.ManifestRecord, tag, manifestDigest string) error {
290
-
// Skip if no notifier configured
291
-
if s.notifier == nil {
290
+
// Skip if no service token configured (e.g., anonymous pulls)
291
+
if s.ctx.ServiceToken == "" {
292
292
return nil
293
293
}
294
294
···
299
299
return fmt.Errorf("failed to resolve hold DID %s: %w", s.ctx.HoldDID, err)
300
300
}
301
301
302
-
// Get service token from user's PDS for hold authentication
303
-
serviceToken, err := s.notifier.GetServiceToken(ctx, s.ctx.DID, s.ctx.HoldDID)
304
-
if err != nil {
305
-
return fmt.Errorf("failed to get service token: %w", err)
306
-
}
302
+
// Use service token from middleware (already cached and validated)
303
+
serviceToken := s.ctx.ServiceToken
307
304
308
305
// Build notification request
309
306
notifyReq := map[string]any{
···
370
367
371
368
return nil
372
369
}
370
+
371
+
// refreshReadmeCache refreshes the README cache for this manifest if it has io.atcr.readme annotation
372
+
// This should be called asynchronously after manifest push to keep README content fresh
373
+
func (s *ManifestStore) refreshReadmeCache(ctx context.Context, manifestRecord *atproto.ManifestRecord) {
374
+
// Skip if no README cache configured
375
+
if s.ctx.ReadmeCache == nil {
376
+
return
377
+
}
378
+
379
+
// Skip if no annotations or no README URL
380
+
if manifestRecord.Annotations == nil {
381
+
return
382
+
}
383
+
384
+
readmeURL, ok := manifestRecord.Annotations["io.atcr.readme"]
385
+
if !ok || readmeURL == "" {
386
+
return
387
+
}
388
+
389
+
fmt.Printf("INFO: Refreshing README cache for %s/%s from %s\n", s.ctx.DID, s.ctx.Repository, readmeURL)
390
+
391
+
// Invalidate the cached entry first
392
+
if err := s.ctx.ReadmeCache.Invalidate(readmeURL); err != nil {
393
+
fmt.Printf("WARNING: Failed to invalidate README cache for %s: %v\n", readmeURL, err)
394
+
// Continue anyway - Get() will still fetch fresh content
395
+
}
396
+
397
+
// Fetch fresh content to populate cache
398
+
// Use context with timeout to avoid hanging on slow/dead URLs
399
+
ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second)
400
+
defer cancel()
401
+
402
+
_, err := s.ctx.ReadmeCache.Get(ctxWithTimeout, readmeURL)
403
+
if err != nil {
404
+
fmt.Printf("WARNING: Failed to refresh README cache for %s: %v\n", readmeURL, err)
405
+
// Not a critical error - cache will be refreshed on next page view
406
+
return
407
+
}
408
+
409
+
fmt.Printf("INFO: README cache refreshed successfully for %s\n", readmeURL)
410
+
}
+8
-8
pkg/appview/storage/manifest_store_test.go
+8
-8
pkg/appview/storage/manifest_store_test.go
···
141
141
db := &mockDatabaseMetrics{}
142
142
143
143
ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", db)
144
-
store := NewManifestStore(ctx, nil, blobStore)
144
+
store := NewManifestStore(ctx, blobStore)
145
145
146
146
if store.ctx.Repository != "myapp" {
147
147
t.Errorf("repository = %v, want myapp", store.ctx.Repository)
···
189
189
t.Run(tt.name, func(t *testing.T) {
190
190
client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
191
191
ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
192
-
store := NewManifestStore(ctx, nil, nil)
192
+
store := NewManifestStore(ctx, nil)
193
193
194
194
// Simulate what happens in Get() when parsing a manifest record
195
195
var manifestRecord atproto.ManifestRecord
···
264
264
// Create manifest store
265
265
client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
266
266
ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
267
-
store := NewManifestStore(ctx, nil, blobStore)
267
+
store := NewManifestStore(ctx, blobStore)
268
268
269
269
// Extract labels
270
270
labels, err := store.extractConfigLabels(context.Background(), configDigest.String())
···
304
304
305
305
client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
306
306
ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
307
-
store := NewManifestStore(ctx, nil, blobStore)
307
+
store := NewManifestStore(ctx, blobStore)
308
308
309
309
labels, err := store.extractConfigLabels(context.Background(), configDigest.String())
310
310
if err != nil {
···
322
322
blobStore := newMockBlobStore()
323
323
client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
324
324
ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
325
-
store := NewManifestStore(ctx, nil, blobStore)
325
+
store := NewManifestStore(ctx, blobStore)
326
326
327
327
_, err := store.extractConfigLabels(context.Background(), "invalid-digest")
328
328
if err == nil {
···
341
341
342
342
client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
343
343
ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
344
-
store := NewManifestStore(ctx, nil, blobStore)
344
+
store := NewManifestStore(ctx, blobStore)
345
345
346
346
_, err := store.extractConfigLabels(context.Background(), configDigest.String())
347
347
if err == nil {
···
354
354
db := &mockDatabaseMetrics{}
355
355
client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
356
356
ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", db)
357
-
store := NewManifestStore(ctx, nil, nil)
357
+
store := NewManifestStore(ctx, nil)
358
358
359
359
if store.ctx.Database != db {
360
360
t.Error("ManifestStore should store database reference")
···
368
368
func TestManifestStore_WithoutMetrics(t *testing.T) {
369
369
client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
370
370
ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", nil)
371
-
store := NewManifestStore(ctx, nil, nil)
371
+
store := NewManifestStore(ctx, nil)
372
372
373
373
if store.ctx.Database != nil {
374
374
t.Error("ManifestStore should accept nil database")
+1
-58
pkg/appview/storage/routing_repository.go
+1
-58
pkg/appview/storage/routing_repository.go
···
6
6
7
7
import (
8
8
"context"
9
-
"encoding/json"
10
9
"fmt"
11
-
"io"
12
-
"net/http"
13
10
"time"
14
11
15
-
"atcr.io/pkg/auth/oauth"
16
12
"github.com/distribution/distribution/v3"
17
13
)
18
14
···
25
21
blobStore *ProxyBlobStore // Cached blob store instance
26
22
}
27
23
28
-
// refresherAdapter adapts the oauth.Refresher to implement atproto.HoldNotifier
29
-
type refresherAdapter struct {
30
-
refresher *oauth.Refresher
31
-
pdsEndpoint string
32
-
}
33
-
34
-
// GetServiceToken implements atproto.HoldNotifier
35
-
func (r *refresherAdapter) GetServiceToken(ctx context.Context, userDID, audienceDID string) (string, error) {
36
-
// Get OAuth session for the user
37
-
session, err := r.refresher.GetSession(ctx, userDID)
38
-
if err != nil {
39
-
return "", fmt.Errorf("failed to get OAuth session: %w", err)
40
-
}
41
-
42
-
// Build service auth URL
43
-
serviceAuthURL := fmt.Sprintf("%s/xrpc/com.atproto.server.getServiceAuth?aud=%s", r.pdsEndpoint, audienceDID)
44
-
45
-
req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
46
-
if err != nil {
47
-
return "", fmt.Errorf("failed to create request: %w", err)
48
-
}
49
-
50
-
// Use session's DoWithAuth to handle OAuth authentication automatically
51
-
resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
52
-
if err != nil {
53
-
return "", fmt.Errorf("failed to request service token: %w", err)
54
-
}
55
-
defer resp.Body.Close()
56
-
57
-
if resp.StatusCode != http.StatusOK {
58
-
body, _ := io.ReadAll(resp.Body)
59
-
return "", fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, body)
60
-
}
61
-
62
-
var result struct {
63
-
Token string `json:"token"`
64
-
}
65
-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
66
-
return "", fmt.Errorf("failed to decode response: %w", err)
67
-
}
68
-
69
-
return result.Token, nil
70
-
}
71
-
72
24
// NewRoutingRepository creates a new routing repository
73
25
func NewRoutingRepository(baseRepo distribution.Repository, ctx *RegistryContext) *RoutingRepository {
74
26
return &RoutingRepository{
···
84
36
// Ensure blob store is created first (needed for label extraction during push)
85
37
blobStore := r.Blobs(ctx)
86
38
87
-
// Wrap the Refresher in an adapter to implement HoldNotifier
88
-
var notifier HoldNotifier
89
-
if r.Ctx.Refresher != nil {
90
-
notifier = &refresherAdapter{
91
-
refresher: r.Ctx.Refresher,
92
-
pdsEndpoint: r.Ctx.PDSEndpoint,
93
-
}
94
-
}
95
-
96
-
r.manifestStore = NewManifestStore(r.Ctx, notifier, blobStore)
39
+
r.manifestStore = NewManifestStore(r.Ctx, blobStore)
97
40
}
98
41
99
42
// After any manifest operation, cache the hold DID for blob fetches