+4
-4
Dockerfile.appview
+4
-4
Dockerfile.appview
···
1
1
# Production build for ATCR AppView
2
2
# Result: ~30MB scratch image with static binary
3
-
FROM docker.io/golang:1.25.4-trixie AS builder
3
+
FROM docker.io/golang:1.25.2-trixie AS builder
4
4
5
5
ENV DEBIAN_FRONTEND=noninteractive
6
6
···
34
34
LABEL org.opencontainers.image.title="ATCR AppView" \
35
35
org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \
36
36
org.opencontainers.image.authors="ATCR Contributors" \
37
-
org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \
38
-
org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \
37
+
org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \
38
+
org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \
39
39
org.opencontainers.image.licenses="MIT" \
40
40
org.opencontainers.image.version="0.1.0" \
41
41
io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" \
42
-
io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/appview.md"
42
+
io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/appview.md"
43
43
44
44
ENTRYPOINT ["/atcr-appview"]
45
45
CMD ["serve"]
+1
-1
Dockerfile.dev
+1
-1
Dockerfile.dev
···
1
1
# Development image with Air hot reload
2
2
# Build: docker build -f Dockerfile.dev -t atcr-appview-dev .
3
3
# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev
4
-
FROM docker.io/golang:1.25.4-trixie
4
+
FROM docker.io/golang:1.25.2-trixie
5
5
6
6
ENV DEBIAN_FRONTEND=noninteractive
7
7
+4
-4
Dockerfile.hold
+4
-4
Dockerfile.hold
···
1
-
FROM docker.io/golang:1.25.4-trixie AS builder
1
+
FROM docker.io/golang:1.25.2-trixie AS builder
2
2
3
3
ENV DEBIAN_FRONTEND=noninteractive
4
4
···
38
38
LABEL org.opencontainers.image.title="ATCR Hold Service" \
39
39
org.opencontainers.image.description="ATCR Hold Service - Bring Your Own Storage component for ATCR" \
40
40
org.opencontainers.image.authors="ATCR Contributors" \
41
-
org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \
42
-
org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \
41
+
org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \
42
+
org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \
43
43
org.opencontainers.image.licenses="MIT" \
44
44
org.opencontainers.image.version="0.1.0" \
45
45
io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE" \
46
-
io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/hold.md"
46
+
io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/hold.md"
47
47
48
48
ENTRYPOINT ["/atcr-hold"]
+18
-15
cmd/appview/serve.go
+18
-15
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 fetcher for rendering repo page descriptions
86
-
readmeFetcher := readme.NewFetcher()
85
+
// Initialize README cache
86
+
slog.Info("Initializing README cache", "cache_ttl", cfg.Health.ReadmeCacheTTL)
87
+
readmeCache := readme.NewCache(uiDatabase, cfg.Health.ReadmeCacheTTL)
87
88
88
89
// Start background health check worker
89
90
startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose)
···
157
158
holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode)
158
159
middleware.SetGlobalAuthorizer(holdAuthorizer)
159
160
slog.Info("Hold authorizer initialized with database caching")
161
+
162
+
// Set global readme cache for middleware
163
+
middleware.SetGlobalReadmeCache(readmeCache)
164
+
slog.Info("README cache initialized for manifest push refresh")
160
165
161
166
// Initialize Jetstream workers (background services before HTTP routes)
162
-
initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode, refresher)
167
+
initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode)
163
168
164
169
// Create main chi router
165
170
mainRouter := chi.NewRouter()
···
189
194
BaseURL: baseURL,
190
195
DeviceStore: deviceStore,
191
196
HealthChecker: healthChecker,
192
-
ReadmeFetcher: readmeFetcher,
197
+
ReadmeCache: readmeCache,
193
198
Templates: uiTemplates,
194
199
})
195
200
}
···
271
276
}
272
277
273
278
var holdDID string
274
-
if profile != nil && profile.DefaultHold != "" {
279
+
if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" {
280
+
defaultHold := *profile.DefaultHold
275
281
// Check if defaultHold is a URL (needs migration)
276
-
if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
277
-
slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold)
282
+
if strings.HasPrefix(defaultHold, "http://") || strings.HasPrefix(defaultHold, "https://") {
283
+
slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", defaultHold)
278
284
279
285
// Resolve URL to DID
280
-
holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
286
+
holdDID = atproto.ResolveHoldDIDFromURL(defaultHold)
281
287
282
288
// Update profile with DID
283
-
profile.DefaultHold = holdDID
289
+
profile.DefaultHold = &holdDID
284
290
if err := storage.UpdateProfile(ctx, client, profile); err != nil {
285
291
slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err)
286
292
} else {
···
288
294
}
289
295
} else {
290
296
// Already a DID - use it
291
-
holdDID = profile.DefaultHold
297
+
holdDID = defaultHold
292
298
}
293
299
// Register crew regardless of migration (outside the migration block)
294
300
// Run in background to avoid blocking OAuth callback if hold is offline
···
392
398
393
399
w.Header().Set("Content-Type", "application/json")
394
400
w.Header().Set("Access-Control-Allow-Origin", "*")
395
-
// Limit caching to allow scope changes to propagate quickly
396
-
// PDS servers cache client metadata, so short max-age helps with updates
397
-
w.Header().Set("Cache-Control", "public, max-age=300")
398
401
if err := json.NewEncoder(w).Encode(metadataMap); err != nil {
399
402
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
400
403
}
···
517
520
}
518
521
519
522
// initializeJetstream initializes the Jetstream workers for real-time events and backfill
520
-
func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) {
523
+
func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool) {
521
524
// Start Jetstream worker
522
525
jetstreamURL := jetstreamCfg.URL
523
526
···
541
544
// Get relay endpoint for sync API (defaults to Bluesky's relay)
542
545
relayEndpoint := jetstreamCfg.RelayEndpoint
543
546
544
-
backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode, refresher)
547
+
backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode)
545
548
if err != nil {
546
549
slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err)
547
550
} else {
+4
-3
docs/TEST_COVERAGE_GAPS.md
+4
-3
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)
115
116
116
117
## Critical Priority: Core Registry Functionality
117
118
···
422
423
423
424
---
424
425
425
-
### ๐ก pkg/appview/readme (Partial coverage)
426
+
### ๐ก pkg/appview/readme (16.7% coverage)
426
427
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.
428
+
README fetching and caching. Less critical but still needs work.
428
429
430
+
#### cache.go (0% coverage)
429
431
#### fetcher.go (๐ Partial coverage)
430
-
- `RenderMarkdown()` - renders repo page description markdown
431
432
432
433
---
433
434
+1
-1
go.mod
+1
-1
go.mod
+6
-6
lexicons/io/atcr/manifest.json
+6
-6
lexicons/io/atcr/manifest.json
···
65
65
"description": "Referenced manifests (for manifest lists/indexes)"
66
66
},
67
67
"annotations": {
68
-
"type": "unknown",
69
-
"description": "Optional OCI annotation metadata. Map of string keys to string values (e.g., org.opencontainers.image.title โ 'My App')."
68
+
"type": "object",
69
+
"description": "Optional metadata annotations"
70
70
},
71
71
"subject": {
72
72
"type": "ref",
···
111
111
"description": "Optional direct URLs to blob (for BYOS)"
112
112
},
113
113
"annotations": {
114
-
"type": "unknown",
115
-
"description": "Optional OCI annotation metadata. Map of string keys to string values."
114
+
"type": "object",
115
+
"description": "Optional metadata"
116
116
}
117
117
}
118
118
},
···
139
139
"description": "Platform information for this manifest"
140
140
},
141
141
"annotations": {
142
-
"type": "unknown",
143
-
"description": "Optional OCI annotation metadata. Map of string keys to string values."
142
+
"type": "object",
143
+
"description": "Optional metadata"
144
144
}
145
145
}
146
146
},
-43
lexicons/io/atcr/repo/page.json
-43
lexicons/io/atcr/repo/page.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "io.atcr.repo.page",
4
-
"defs": {
5
-
"main": {
6
-
"type": "record",
7
-
"description": "Repository page metadata including description and avatar. Users can edit this directly in their PDS to customize their repository page.",
8
-
"key": "any",
9
-
"record": {
10
-
"type": "object",
11
-
"required": ["repository", "createdAt", "updatedAt"],
12
-
"properties": {
13
-
"repository": {
14
-
"type": "string",
15
-
"description": "The name of the repository (e.g., 'myapp'). Must match the rkey.",
16
-
"maxLength": 256
17
-
},
18
-
"description": {
19
-
"type": "string",
20
-
"description": "Markdown README/description content for the repository page.",
21
-
"maxLength": 100000
22
-
},
23
-
"avatar": {
24
-
"type": "blob",
25
-
"description": "Repository avatar/icon image.",
26
-
"accept": ["image/png", "image/jpeg", "image/webp"],
27
-
"maxSize": 3000000
28
-
},
29
-
"createdAt": {
30
-
"type": "string",
31
-
"format": "datetime",
32
-
"description": "Record creation timestamp"
33
-
},
34
-
"updatedAt": {
35
-
"type": "string",
36
-
"format": "datetime",
37
-
"description": "Record last updated timestamp"
38
-
}
39
-
}
40
-
}
41
-
}
42
-
}
43
-
}
+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"`
82
85
}
83
86
84
87
// JetstreamConfig defines ATProto Jetstream settings
···
162
165
// Health and cache configuration
163
166
cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute)
164
167
cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute)
168
+
cfg.Health.ReadmeCacheTTL = getDurationOrDefault("ATCR_README_CACHE_TTL", 1*time.Hour)
165
169
166
170
// Jetstream configuration
167
171
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;
+2
-3
pkg/appview/db/models.go
+2
-3
pkg/appview/db/models.go
···
148
148
// TagWithPlatforms extends Tag with platform information
149
149
type TagWithPlatforms struct {
150
150
Tag
151
-
Platforms []PlatformInfo
152
-
IsMultiArch bool
153
-
HasAttestations bool // true if manifest list contains attestation references
151
+
Platforms []PlatformInfo
152
+
IsMultiArch bool
154
153
}
155
154
156
155
// ManifestWithMetadata extends Manifest with tags and platform information
+8
-119
pkg/appview/db/queries.go
+8
-119
pkg/appview/db/queries.go
···
7
7
"time"
8
8
)
9
9
10
-
// BlobCDNURL returns the CDN URL for an ATProto blob
11
-
// This is a local copy to avoid importing atproto (prevents circular dependencies)
12
-
func BlobCDNURL(did, cid string) string {
13
-
return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid)
14
-
}
15
-
16
10
// escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching.
17
11
// It also sanitizes the input to prevent injection attacks via special characters.
18
12
func escapeLikePattern(s string) string {
···
52
46
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
53
47
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
54
48
t.created_at,
55
-
m.hold_endpoint,
56
-
COALESCE(rp.avatar_cid, '')
49
+
m.hold_endpoint
57
50
FROM tags t
58
51
JOIN users u ON t.did = u.did
59
52
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
60
53
LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository
61
-
LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository
62
54
`
63
55
64
56
args := []any{currentUserDID}
···
81
73
for rows.Next() {
82
74
var p Push
83
75
var isStarredInt int
84
-
var avatarCID string
85
-
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil {
76
+
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil {
86
77
return nil, 0, err
87
78
}
88
79
p.IsStarred = isStarredInt > 0
89
-
// Prefer repo page avatar over annotation icon
90
-
if avatarCID != "" {
91
-
p.IconURL = BlobCDNURL(p.DID, avatarCID)
92
-
}
93
80
pushes = append(pushes, p)
94
81
}
95
82
···
132
119
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
133
120
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
134
121
t.created_at,
135
-
m.hold_endpoint,
136
-
COALESCE(rp.avatar_cid, '')
122
+
m.hold_endpoint
137
123
FROM tags t
138
124
JOIN users u ON t.did = u.did
139
125
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
140
126
LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository
141
-
LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository
142
127
WHERE u.handle LIKE ? ESCAPE '\'
143
128
OR u.did = ?
144
129
OR t.repository LIKE ? ESCAPE '\'
···
161
146
for rows.Next() {
162
147
var p Push
163
148
var isStarredInt int
164
-
var avatarCID string
165
-
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil {
149
+
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil {
166
150
return nil, 0, err
167
151
}
168
152
p.IsStarred = isStarredInt > 0
169
-
// Prefer repo page avatar over annotation icon
170
-
if avatarCID != "" {
171
-
p.IconURL = BlobCDNURL(p.DID, avatarCID)
172
-
}
173
153
pushes = append(pushes, p)
174
154
}
175
155
···
312
292
r.Licenses = annotations["org.opencontainers.image.licenses"]
313
293
r.IconURL = annotations["io.atcr.icon"]
314
294
r.ReadmeURL = annotations["io.atcr.readme"]
315
-
316
-
// Check for repo page avatar (overrides annotation icon)
317
-
repoPage, err := GetRepoPage(db, did, r.Name)
318
-
if err == nil && repoPage != nil && repoPage.AvatarCID != "" {
319
-
r.IconURL = BlobCDNURL(did, repoPage.AvatarCID)
320
-
}
321
295
322
296
repos = append(repos, r)
323
297
}
···
622
596
// GetTagsWithPlatforms returns all tags for a repository with platform information
623
597
// Only multi-arch tags (manifest lists) have platform info in manifest_references
624
598
// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
625
-
// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
626
599
func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatforms, error) {
627
600
rows, err := db.Query(`
628
601
SELECT
···
636
609
COALESCE(mr.platform_os, '') as platform_os,
637
610
COALESCE(mr.platform_architecture, '') as platform_architecture,
638
611
COALESCE(mr.platform_variant, '') as platform_variant,
639
-
COALESCE(mr.platform_os_version, '') as platform_os_version,
640
-
COALESCE(mr.is_attestation, 0) as is_attestation
612
+
COALESCE(mr.platform_os_version, '') as platform_os_version
641
613
FROM tags t
642
614
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
643
615
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
···
657
629
for rows.Next() {
658
630
var t Tag
659
631
var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string
660
-
var isAttestation bool
661
632
662
633
if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt,
663
-
&mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil {
634
+
&mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion); err != nil {
664
635
return nil, err
665
636
}
666
637
···
672
643
Platforms: []PlatformInfo{},
673
644
}
674
645
tagOrder = append(tagOrder, tagKey)
675
-
}
676
-
677
-
// Track if manifest list has attestations
678
-
if isAttestation {
679
-
tagMap[tagKey].HasAttestations = true
680
-
// Skip attestation references in platform display
681
-
continue
682
646
}
683
647
684
648
// Add platform info if present (only for multi-arch manifest lists)
···
1686
1650
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
1687
1651
rs.pull_count,
1688
1652
rs.star_count,
1689
-
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
1690
-
COALESCE(rp.avatar_cid, '')
1653
+
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0)
1691
1654
FROM latest_manifests lm
1692
1655
JOIN manifests m ON lm.latest_id = m.id
1693
1656
JOIN users u ON m.did = u.did
1694
1657
JOIN repo_stats rs ON m.did = rs.did AND m.repository = rs.repository
1695
-
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
1696
1658
ORDER BY rs.score DESC, rs.star_count DESC, rs.pull_count DESC, m.created_at DESC
1697
1659
LIMIT ?
1698
1660
`
···
1707
1669
for rows.Next() {
1708
1670
var f FeaturedRepository
1709
1671
var isStarredInt int
1710
-
var avatarCID string
1711
1672
1712
1673
if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository,
1713
-
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt, &avatarCID); err != nil {
1674
+
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil {
1714
1675
return nil, err
1715
1676
}
1716
1677
f.IsStarred = isStarredInt > 0
1717
-
// Prefer repo page avatar over annotation icon
1718
-
if avatarCID != "" {
1719
-
f.IconURL = BlobCDNURL(f.OwnerDID, avatarCID)
1720
-
}
1721
1678
1722
1679
featured = append(featured, f)
1723
1680
}
1724
1681
1725
1682
return featured, nil
1726
1683
}
1727
-
1728
-
// RepoPage represents a repository page record cached from PDS
1729
-
type RepoPage struct {
1730
-
DID string
1731
-
Repository string
1732
-
Description string
1733
-
AvatarCID string
1734
-
CreatedAt time.Time
1735
-
UpdatedAt time.Time
1736
-
}
1737
-
1738
-
// UpsertRepoPage inserts or updates a repo page record
1739
-
func UpsertRepoPage(db *sql.DB, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error {
1740
-
_, err := db.Exec(`
1741
-
INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at)
1742
-
VALUES (?, ?, ?, ?, ?, ?)
1743
-
ON CONFLICT(did, repository) DO UPDATE SET
1744
-
description = excluded.description,
1745
-
avatar_cid = excluded.avatar_cid,
1746
-
updated_at = excluded.updated_at
1747
-
`, did, repository, description, avatarCID, createdAt, updatedAt)
1748
-
return err
1749
-
}
1750
-
1751
-
// GetRepoPage retrieves a repo page record
1752
-
func GetRepoPage(db *sql.DB, did, repository string) (*RepoPage, error) {
1753
-
var rp RepoPage
1754
-
err := db.QueryRow(`
1755
-
SELECT did, repository, description, avatar_cid, created_at, updated_at
1756
-
FROM repo_pages
1757
-
WHERE did = ? AND repository = ?
1758
-
`, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt)
1759
-
if err != nil {
1760
-
return nil, err
1761
-
}
1762
-
return &rp, nil
1763
-
}
1764
-
1765
-
// DeleteRepoPage deletes a repo page record
1766
-
func DeleteRepoPage(db *sql.DB, did, repository string) error {
1767
-
_, err := db.Exec(`
1768
-
DELETE FROM repo_pages WHERE did = ? AND repository = ?
1769
-
`, did, repository)
1770
-
return err
1771
-
}
1772
-
1773
-
// GetRepoPagesByDID returns all repo pages for a DID
1774
-
func GetRepoPagesByDID(db *sql.DB, did string) ([]RepoPage, error) {
1775
-
rows, err := db.Query(`
1776
-
SELECT did, repository, description, avatar_cid, created_at, updated_at
1777
-
FROM repo_pages
1778
-
WHERE did = ?
1779
-
`, did)
1780
-
if err != nil {
1781
-
return nil, err
1782
-
}
1783
-
defer rows.Close()
1784
-
1785
-
var pages []RepoPage
1786
-
for rows.Next() {
1787
-
var rp RepoPage
1788
-
if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
1789
-
return nil, err
1790
-
}
1791
-
pages = append(pages, rp)
1792
-
}
1793
-
return pages, rows.Err()
1794
-
}
+5
-10
pkg/appview/db/schema.sql
+5
-10
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 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
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
217
212
);
218
-
CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
213
+
CREATE INDEX IF NOT EXISTS idx_readme_cache_fetched ON readme_cache(fetched_at);
-32
pkg/appview/handlers/errors.go
-32
pkg/appview/handlers/errors.go
···
1
-
package handlers
2
-
3
-
import (
4
-
"html/template"
5
-
"net/http"
6
-
)
7
-
8
-
// NotFoundHandler handles 404 errors
9
-
type NotFoundHandler struct {
10
-
Templates *template.Template
11
-
RegistryURL string
12
-
}
13
-
14
-
func (h *NotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
15
-
RenderNotFound(w, r, h.Templates, h.RegistryURL)
16
-
}
17
-
18
-
// RenderNotFound renders the 404 page template.
19
-
// Use this from other handlers when a resource is not found.
20
-
func RenderNotFound(w http.ResponseWriter, r *http.Request, templates *template.Template, registryURL string) {
21
-
w.WriteHeader(http.StatusNotFound)
22
-
23
-
data := struct {
24
-
PageData
25
-
}{
26
-
PageData: NewPageData(r, registryURL),
27
-
}
28
-
29
-
if err := templates.ExecuteTemplate(w, "404", data); err != nil {
30
-
http.Error(w, "Page not found", http.StatusNotFound)
31
-
}
32
-
}
-114
pkg/appview/handlers/images.go
-114
pkg/appview/handlers/images.go
···
3
3
import (
4
4
"database/sql"
5
5
"encoding/json"
6
-
"errors"
7
6
"fmt"
8
-
"io"
9
7
"net/http"
10
8
"strings"
11
-
"time"
12
9
13
10
"atcr.io/pkg/appview/db"
14
11
"atcr.io/pkg/appview/middleware"
···
158
155
159
156
w.WriteHeader(http.StatusOK)
160
157
}
161
-
162
-
// UploadAvatarHandler handles uploading/updating a repository avatar
163
-
type UploadAvatarHandler struct {
164
-
DB *sql.DB
165
-
Refresher *oauth.Refresher
166
-
}
167
-
168
-
// validImageTypes are the allowed MIME types for avatars (matches lexicon)
169
-
var validImageTypes = map[string]bool{
170
-
"image/png": true,
171
-
"image/jpeg": true,
172
-
"image/webp": true,
173
-
}
174
-
175
-
func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
176
-
user := middleware.GetUser(r)
177
-
if user == nil {
178
-
http.Error(w, "Unauthorized", http.StatusUnauthorized)
179
-
return
180
-
}
181
-
182
-
repo := chi.URLParam(r, "repository")
183
-
184
-
// Parse multipart form (max 3MB to match lexicon maxSize)
185
-
if err := r.ParseMultipartForm(3 << 20); err != nil {
186
-
http.Error(w, "File too large (max 3MB)", http.StatusBadRequest)
187
-
return
188
-
}
189
-
190
-
file, header, err := r.FormFile("avatar")
191
-
if err != nil {
192
-
http.Error(w, "No file provided", http.StatusBadRequest)
193
-
return
194
-
}
195
-
defer file.Close()
196
-
197
-
// Validate MIME type
198
-
contentType := header.Header.Get("Content-Type")
199
-
if !validImageTypes[contentType] {
200
-
http.Error(w, "Invalid file type. Must be PNG, JPEG, or WebP", http.StatusBadRequest)
201
-
return
202
-
}
203
-
204
-
// Read file data
205
-
data, err := io.ReadAll(io.LimitReader(file, 3<<20+1)) // Read up to 3MB + 1 byte
206
-
if err != nil {
207
-
http.Error(w, "Failed to read file", http.StatusInternalServerError)
208
-
return
209
-
}
210
-
if len(data) > 3<<20 {
211
-
http.Error(w, "File too large (max 3MB)", http.StatusBadRequest)
212
-
return
213
-
}
214
-
215
-
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
216
-
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
217
-
218
-
// Upload blob to PDS
219
-
blobRef, err := pdsClient.UploadBlob(r.Context(), data, contentType)
220
-
if err != nil {
221
-
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
222
-
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
223
-
return
224
-
}
225
-
http.Error(w, fmt.Sprintf("Failed to upload image: %v", err), http.StatusInternalServerError)
226
-
return
227
-
}
228
-
229
-
// Fetch existing repo page record to preserve description
230
-
var existingDescription string
231
-
var existingCreatedAt time.Time
232
-
record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repo)
233
-
if err == nil {
234
-
// Parse existing record to preserve description
235
-
var existingRecord atproto.RepoPageRecord
236
-
if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil {
237
-
existingDescription = existingRecord.Description
238
-
existingCreatedAt = existingRecord.CreatedAt
239
-
}
240
-
} else if !errors.Is(err, atproto.ErrRecordNotFound) {
241
-
// Some other error - check if OAuth error
242
-
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
243
-
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
244
-
return
245
-
}
246
-
// Log but continue - we'll create a new record
247
-
}
248
-
249
-
// Create updated repo page record
250
-
repoPage := atproto.NewRepoPageRecord(repo, existingDescription, blobRef)
251
-
// Preserve original createdAt if record existed
252
-
if !existingCreatedAt.IsZero() {
253
-
repoPage.CreatedAt = existingCreatedAt
254
-
}
255
-
256
-
// Save record to PDS
257
-
_, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repo, repoPage)
258
-
if err != nil {
259
-
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
260
-
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
261
-
return
262
-
}
263
-
http.Error(w, fmt.Sprintf("Failed to update repository page: %v", err), http.StatusInternalServerError)
264
-
return
265
-
}
266
-
267
-
// Return new avatar URL
268
-
avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link)
269
-
w.Header().Set("Content-Type", "application/json")
270
-
json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL})
271
-
}
+15
-40
pkg/appview/handlers/repository.go
+15
-40
pkg/appview/handlers/repository.go
···
27
27
Directory identity.Directory
28
28
Refresher *oauth.Refresher
29
29
HealthChecker *holdhealth.Checker
30
-
ReadmeFetcher *readme.Fetcher // For rendering repo page descriptions
30
+
ReadmeCache *readme.Cache
31
31
}
32
32
33
33
func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···
37
37
// Resolve identifier (handle or DID) to canonical DID and current handle
38
38
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
39
39
if err != nil {
40
-
RenderNotFound(w, r, h.Templates, h.RegistryURL)
40
+
http.Error(w, "User not found", http.StatusNotFound)
41
41
return
42
42
}
43
43
···
48
48
return
49
49
}
50
50
if owner == nil {
51
-
RenderNotFound(w, r, h.Templates, h.RegistryURL)
51
+
http.Error(w, "User not found", http.StatusNotFound)
52
52
return
53
53
}
54
54
···
136
136
}
137
137
138
138
if len(tagsWithPlatforms) == 0 && len(manifests) == 0 {
139
-
RenderNotFound(w, r, h.Templates, h.RegistryURL)
139
+
http.Error(w, "Repository not found", http.StatusNotFound)
140
140
return
141
141
}
142
142
···
190
190
isOwner = (user.DID == owner.DID)
191
191
}
192
192
193
-
// Fetch README content from repo page record or annotations
193
+
// Fetch README content if available
194
194
var readmeHTML template.HTML
195
+
if repo.ReadmeURL != "" && h.ReadmeCache != nil {
196
+
// Fetch with timeout
197
+
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
198
+
defer cancel()
195
199
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 {
199
-
// Use repo page avatar if present
200
-
if repoPage.AvatarCID != "" {
201
-
repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID)
202
-
}
203
-
// Render description as markdown if present
204
-
if repoPage.Description != "" && h.ReadmeFetcher != nil {
205
-
html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description))
206
-
if err != nil {
207
-
slog.Warn("Failed to render repo page description", "error", err)
208
-
} else {
209
-
readmeHTML = template.HTML(html)
210
-
}
211
-
}
212
-
}
213
-
// Fall back to fetching README from URL annotations if no description in repo page
214
-
if readmeHTML == "" && h.ReadmeFetcher != nil {
215
-
// Fall back to fetching from URL annotations
216
-
readmeURL := repo.ReadmeURL
217
-
if readmeURL == "" && repo.SourceURL != "" {
218
-
// Try to derive README URL from source URL
219
-
readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "main")
220
-
if readmeURL == "" {
221
-
readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "master")
222
-
}
223
-
}
224
-
if readmeURL != "" {
225
-
html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL)
226
-
if err != nil {
227
-
slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err)
228
-
} else {
229
-
readmeHTML = template.HTML(html)
230
-
}
200
+
html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL)
201
+
if err != nil {
202
+
slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err)
203
+
// Continue without README on error
204
+
} else {
205
+
readmeHTML = template.HTML(html)
231
206
}
232
207
}
233
208
+6
-3
pkg/appview/handlers/settings.go
+6
-3
pkg/appview/handlers/settings.go
···
62
62
data.Profile.Handle = user.Handle
63
63
data.Profile.DID = user.DID
64
64
data.Profile.PDSEndpoint = user.PDSEndpoint
65
-
data.Profile.DefaultHold = profile.DefaultHold
65
+
if profile.DefaultHold != nil {
66
+
data.Profile.DefaultHold = *profile.DefaultHold
67
+
}
66
68
67
69
if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
68
70
http.Error(w, err.Error(), http.StatusInternalServerError)
···
94
96
profile = atproto.NewSailorProfileRecord(holdEndpoint)
95
97
} else {
96
98
// Update existing profile
97
-
profile.DefaultHold = holdEndpoint
98
-
profile.UpdatedAt = time.Now()
99
+
profile.DefaultHold = &holdEndpoint
100
+
now := time.Now().Format(time.RFC3339)
101
+
profile.UpdatedAt = &now
99
102
}
100
103
101
104
// Save profile
+1
-1
pkg/appview/handlers/user.go
+1
-1
pkg/appview/handlers/user.go
···
23
23
// Resolve identifier (handle or DID) to canonical DID and current handle
24
24
did, resolvedHandle, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), identifier)
25
25
if err != nil {
26
-
RenderNotFound(w, r, h.Templates, h.RegistryURL)
26
+
http.Error(w, "User not found", http.StatusNotFound)
27
27
return
28
28
}
29
29
+20
-261
pkg/appview/jetstream/backfill.go
+20
-261
pkg/appview/jetstream/backfill.go
···
5
5
"database/sql"
6
6
"encoding/json"
7
7
"fmt"
8
-
"io"
9
8
"log/slog"
10
-
"net/http"
11
9
"strings"
12
10
"time"
13
11
14
12
"atcr.io/pkg/appview/db"
15
-
"atcr.io/pkg/appview/readme"
16
13
"atcr.io/pkg/atproto"
17
-
"atcr.io/pkg/auth/oauth"
18
14
)
19
15
20
16
// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
21
17
type BackfillWorker struct {
22
18
db *sql.DB
23
19
client *atproto.Client
24
-
processor *Processor // Shared processor for DB operations
25
-
defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
26
-
testMode bool // If true, suppress warnings for external holds
27
-
refresher *oauth.Refresher // OAuth refresher for PDS writes (optional, can be nil)
20
+
processor *Processor // Shared processor for DB operations
21
+
defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
22
+
testMode bool // If true, suppress warnings for external holds
28
23
}
29
24
30
25
// BackfillState tracks backfill progress
···
41
36
// NewBackfillWorker creates a backfill worker using sync API
42
37
// defaultHoldDID should be in format "did:web:hold01.atcr.io"
43
38
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
44
-
// refresher is optional - if provided, backfill will try to update PDS records when fetching README content
45
-
func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) (*BackfillWorker, error) {
39
+
func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool) (*BackfillWorker, error) {
46
40
// Create client for relay - used only for listReposByCollection
47
41
client := atproto.NewClient(relayEndpoint, "", "")
48
42
···
52
46
processor: NewProcessor(database, false), // No cache for batch processing
53
47
defaultHoldDID: defaultHoldDID,
54
48
testMode: testMode,
55
-
refresher: refresher,
56
49
}, nil
57
50
}
58
51
···
74
67
atproto.TagCollection, // io.atcr.tag
75
68
atproto.StarCollection, // io.atcr.sailor.star
76
69
atproto.SailorProfileCollection, // io.atcr.sailor.profile
77
-
atproto.RepoPageCollection, // io.atcr.repo.page
78
70
}
79
71
80
72
for _, collection := range collections {
···
172
164
// Track what we found for deletion reconciliation
173
165
switch collection {
174
166
case atproto.ManifestCollection:
175
-
var manifestRecord atproto.ManifestRecord
167
+
var manifestRecord atproto.Manifest
176
168
if err := json.Unmarshal(record.Value, &manifestRecord); err == nil {
177
169
foundManifestDigests = append(foundManifestDigests, manifestRecord.Digest)
178
170
}
179
171
case atproto.TagCollection:
180
-
var tagRecord atproto.TagRecord
172
+
var tagRecord atproto.Tag
181
173
if err := json.Unmarshal(record.Value, &tagRecord); err == nil {
182
174
foundTags = append(foundTags, struct{ Repository, Tag string }{
183
175
Repository: tagRecord.Repository,
···
185
177
})
186
178
}
187
179
case atproto.StarCollection:
188
-
var starRecord atproto.StarRecord
180
+
var starRecord atproto.SailorStar
189
181
if err := json.Unmarshal(record.Value, &starRecord); err == nil {
190
-
key := fmt.Sprintf("%s/%s", starRecord.Subject.DID, starRecord.Subject.Repository)
191
-
foundStars[key] = starRecord.CreatedAt
182
+
key := fmt.Sprintf("%s/%s", starRecord.Subject.Did, starRecord.Subject.Repository)
183
+
// Parse CreatedAt string to time.Time
184
+
createdAt, parseErr := time.Parse(time.RFC3339, starRecord.CreatedAt)
185
+
if parseErr != nil {
186
+
createdAt = time.Now()
187
+
}
188
+
foundStars[key] = createdAt
192
189
}
193
190
}
194
191
···
225
222
}
226
223
}
227
224
228
-
// After processing repo pages, fetch descriptions from external sources if empty
229
-
if collection == atproto.RepoPageCollection {
230
-
if err := b.reconcileRepoPageDescriptions(ctx, did, pdsEndpoint); err != nil {
231
-
slog.Warn("Backfill failed to reconcile repo page descriptions", "did", did, "error", err)
232
-
}
233
-
}
234
-
235
225
return recordCount, nil
236
226
}
237
227
···
297
287
return b.processor.ProcessStar(context.Background(), did, record.Value)
298
288
case atproto.SailorProfileCollection:
299
289
return b.processor.ProcessSailorProfile(ctx, did, record.Value, b.queryCaptainRecordWrapper)
300
-
case atproto.RepoPageCollection:
301
-
// rkey is extracted from the record URI, but for repo pages we use Repository field
302
-
return b.processor.ProcessRepoPage(ctx, did, record.URI, record.Value, false)
303
290
default:
304
291
return fmt.Errorf("unsupported collection: %s", collection)
305
292
}
···
377
364
378
365
// reconcileAnnotations ensures annotations come from the newest manifest in each repository
379
366
// This fixes the out-of-order backfill issue where older manifests can overwrite newer annotations
367
+
// NOTE: Currently disabled because the generated Manifest_Annotations type doesn't support
368
+
// arbitrary key-value pairs. Would need to update lexicon schema with "unknown" type.
380
369
func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, pdsClient *atproto.Client) error {
381
-
// Get all repositories for this DID
382
-
repositories, err := db.GetRepositoriesForDID(b.db, did)
383
-
if err != nil {
384
-
return fmt.Errorf("failed to get repositories: %w", err)
385
-
}
386
-
387
-
for _, repo := range repositories {
388
-
// Find newest manifest for this repository
389
-
newestManifest, err := db.GetNewestManifestForRepo(b.db, did, repo)
390
-
if err != nil {
391
-
slog.Warn("Backfill failed to get newest manifest for repo", "did", did, "repository", repo, "error", err)
392
-
continue // Skip on error
393
-
}
394
-
395
-
// Fetch the full manifest record from PDS using the digest as rkey
396
-
rkey := strings.TrimPrefix(newestManifest.Digest, "sha256:")
397
-
record, err := pdsClient.GetRecord(ctx, atproto.ManifestCollection, rkey)
398
-
if err != nil {
399
-
slog.Warn("Backfill failed to fetch manifest record for repo", "did", did, "repository", repo, "error", err)
400
-
continue // Skip on error
401
-
}
402
-
403
-
// Parse manifest record
404
-
var manifestRecord atproto.ManifestRecord
405
-
if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
406
-
slog.Warn("Backfill failed to parse manifest record for repo", "did", did, "repository", repo, "error", err)
407
-
continue
408
-
}
409
-
410
-
// Update annotations from newest manifest only
411
-
if len(manifestRecord.Annotations) > 0 {
412
-
// Filter out empty annotations
413
-
hasData := false
414
-
for _, value := range manifestRecord.Annotations {
415
-
if value != "" {
416
-
hasData = true
417
-
break
418
-
}
419
-
}
420
-
421
-
if hasData {
422
-
err = db.UpsertRepositoryAnnotations(b.db, did, repo, manifestRecord.Annotations)
423
-
if err != nil {
424
-
slog.Warn("Backfill failed to reconcile annotations for repo", "did", did, "repository", repo, "error", err)
425
-
} else {
426
-
slog.Info("Backfill reconciled annotations for repo from newest manifest", "did", did, "repository", repo, "digest", newestManifest.Digest)
427
-
}
428
-
}
429
-
}
430
-
}
431
-
432
-
return nil
433
-
}
434
-
435
-
// reconcileRepoPageDescriptions fetches README content from external sources for repo pages with empty descriptions
436
-
// If the user has an OAuth session, it updates the PDS record (source of truth)
437
-
// Otherwise, it just stores the fetched content in the database
438
-
func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did, pdsEndpoint string) error {
439
-
// Get all repo pages for this DID
440
-
repoPages, err := db.GetRepoPagesByDID(b.db, did)
441
-
if err != nil {
442
-
return fmt.Errorf("failed to get repo pages: %w", err)
443
-
}
444
-
445
-
for _, page := range repoPages {
446
-
// Skip pages that already have a description
447
-
if page.Description != "" {
448
-
continue
449
-
}
450
-
451
-
// Get annotations from the repository's manifest
452
-
annotations, err := db.GetRepositoryAnnotations(b.db, did, page.Repository)
453
-
if err != nil {
454
-
slog.Debug("Failed to get annotations for repo page", "did", did, "repository", page.Repository, "error", err)
455
-
continue
456
-
}
457
-
458
-
// Try to fetch README content from external sources
459
-
description := b.fetchReadmeContent(ctx, annotations)
460
-
if description == "" {
461
-
// No README content available, skip
462
-
continue
463
-
}
464
-
465
-
slog.Info("Fetched README for repo page", "did", did, "repository", page.Repository, "descriptionLength", len(description))
466
-
467
-
// Try to update PDS if we have OAuth session
468
-
pdsUpdated := false
469
-
if b.refresher != nil {
470
-
if err := b.updateRepoPageInPDS(ctx, did, pdsEndpoint, page.Repository, description, page.AvatarCID); err != nil {
471
-
slog.Debug("Could not update repo page in PDS, falling back to DB-only", "did", did, "repository", page.Repository, "error", err)
472
-
} else {
473
-
pdsUpdated = true
474
-
slog.Info("Updated repo page in PDS with fetched description", "did", did, "repository", page.Repository)
475
-
}
476
-
}
477
-
478
-
// Always update database with the fetched content
479
-
if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil {
480
-
slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err)
481
-
} else if !pdsUpdated {
482
-
slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository)
483
-
}
484
-
}
485
-
486
-
return nil
487
-
}
488
-
489
-
// fetchReadmeContent attempts to fetch README content from external sources based on annotations
490
-
// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source
491
-
func (b *BackfillWorker) fetchReadmeContent(ctx context.Context, annotations map[string]string) string {
492
-
// Create a context with timeout for README fetching
493
-
fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
494
-
defer cancel()
495
-
496
-
// Priority 1: Direct README URL from io.atcr.readme annotation
497
-
if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" {
498
-
content, err := b.fetchRawReadme(fetchCtx, readmeURL)
499
-
if err != nil {
500
-
slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err)
501
-
} else if content != "" {
502
-
return content
503
-
}
504
-
}
505
-
506
-
// Priority 2: Derive README URL from org.opencontainers.image.source
507
-
if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" {
508
-
// Try main branch first, then master
509
-
for _, branch := range []string{"main", "master"} {
510
-
readmeURL := readme.DeriveReadmeURL(sourceURL, branch)
511
-
if readmeURL == "" {
512
-
continue
513
-
}
514
-
515
-
content, err := b.fetchRawReadme(fetchCtx, readmeURL)
516
-
if err != nil {
517
-
// Only log non-404 errors (404 is expected when trying main vs master)
518
-
if !readme.Is404(err) {
519
-
slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err)
520
-
}
521
-
continue
522
-
}
523
-
524
-
if content != "" {
525
-
return content
526
-
}
527
-
}
528
-
}
529
-
530
-
return ""
531
-
}
532
-
533
-
// fetchRawReadme fetches raw markdown content from a URL
534
-
func (b *BackfillWorker) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) {
535
-
req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil)
536
-
if err != nil {
537
-
return "", fmt.Errorf("failed to create request: %w", err)
538
-
}
539
-
540
-
req.Header.Set("User-Agent", "ATCR-Backfill-README-Fetcher/1.0")
541
-
542
-
client := &http.Client{
543
-
Timeout: 10 * time.Second,
544
-
CheckRedirect: func(req *http.Request, via []*http.Request) error {
545
-
if len(via) >= 5 {
546
-
return fmt.Errorf("too many redirects")
547
-
}
548
-
return nil
549
-
},
550
-
}
551
-
552
-
resp, err := client.Do(req)
553
-
if err != nil {
554
-
return "", fmt.Errorf("failed to fetch URL: %w", err)
555
-
}
556
-
defer resp.Body.Close()
557
-
558
-
if resp.StatusCode != http.StatusOK {
559
-
return "", fmt.Errorf("status %d", resp.StatusCode)
560
-
}
561
-
562
-
// Limit content size to 100KB
563
-
limitedReader := io.LimitReader(resp.Body, 100*1024)
564
-
content, err := io.ReadAll(limitedReader)
565
-
if err != nil {
566
-
return "", fmt.Errorf("failed to read response body: %w", err)
567
-
}
568
-
569
-
return string(content), nil
570
-
}
571
-
572
-
// updateRepoPageInPDS updates the repo page record in the user's PDS using OAuth
573
-
func (b *BackfillWorker) updateRepoPageInPDS(ctx context.Context, did, pdsEndpoint, repository, description, avatarCID string) error {
574
-
if b.refresher == nil {
575
-
return fmt.Errorf("no OAuth refresher available")
576
-
}
577
-
578
-
// Create ATProto client with session provider
579
-
pdsClient := atproto.NewClientWithSessionProvider(pdsEndpoint, did, b.refresher)
580
-
581
-
// Get existing repo page record to preserve other fields
582
-
existingRecord, err := pdsClient.GetRecord(ctx, atproto.RepoPageCollection, repository)
583
-
var createdAt time.Time
584
-
var avatarRef *atproto.ATProtoBlobRef
585
-
586
-
if err == nil && existingRecord != nil {
587
-
// Parse existing record
588
-
var existingPage atproto.RepoPageRecord
589
-
if err := json.Unmarshal(existingRecord.Value, &existingPage); err == nil {
590
-
createdAt = existingPage.CreatedAt
591
-
avatarRef = existingPage.Avatar
592
-
}
593
-
}
594
-
595
-
if createdAt.IsZero() {
596
-
createdAt = time.Now()
597
-
}
598
-
599
-
// Create updated repo page record
600
-
repoPage := &atproto.RepoPageRecord{
601
-
Type: atproto.RepoPageCollection,
602
-
Repository: repository,
603
-
Description: description,
604
-
Avatar: avatarRef,
605
-
CreatedAt: createdAt,
606
-
UpdatedAt: time.Now(),
607
-
}
608
-
609
-
// Write to PDS - this will use DoWithSession internally
610
-
_, err = pdsClient.PutRecord(ctx, atproto.RepoPageCollection, repository, repoPage)
611
-
if err != nil {
612
-
return fmt.Errorf("failed to write to PDS: %w", err)
613
-
}
614
-
370
+
// TODO: Re-enable once lexicon supports annotations as map[string]string
371
+
// For now, skip annotation reconciliation as the generated type is an empty struct
372
+
_ = did
373
+
_ = pdsClient
615
374
return nil
616
375
}
+51
-65
pkg/appview/jetstream/processor.go
+51
-65
pkg/appview/jetstream/processor.go
···
100
100
// Returns the manifest ID for further processing (layers/references)
101
101
func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData []byte) (int64, error) {
102
102
// Unmarshal manifest record
103
-
var manifestRecord atproto.ManifestRecord
103
+
var manifestRecord atproto.Manifest
104
104
if err := json.Unmarshal(recordData, &manifestRecord); err != nil {
105
105
return 0, fmt.Errorf("failed to unmarshal manifest: %w", err)
106
106
}
···
110
110
// Extract hold DID from manifest (with fallback for legacy manifests)
111
111
// New manifests use holdDid field (DID format)
112
112
// Old manifests use holdEndpoint field (URL format) - convert to DID
113
-
holdDID := manifestRecord.HoldDID
114
-
if holdDID == "" && manifestRecord.HoldEndpoint != "" {
113
+
var holdDID string
114
+
if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" {
115
+
holdDID = *manifestRecord.HoldDid
116
+
} else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" {
115
117
// Legacy manifest - convert URL to DID
116
-
holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
118
+
holdDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint)
119
+
}
120
+
121
+
// Parse CreatedAt string to time.Time
122
+
createdAt, err := time.Parse(time.RFC3339, manifestRecord.CreatedAt)
123
+
if err != nil {
124
+
// Fall back to current time if parsing fails
125
+
createdAt = time.Now()
117
126
}
118
127
119
128
// Prepare manifest for insertion (WITHOUT annotation fields)
···
122
131
Repository: manifestRecord.Repository,
123
132
Digest: manifestRecord.Digest,
124
133
MediaType: manifestRecord.MediaType,
125
-
SchemaVersion: manifestRecord.SchemaVersion,
134
+
SchemaVersion: int(manifestRecord.SchemaVersion),
126
135
HoldEndpoint: holdDID,
127
-
CreatedAt: manifestRecord.CreatedAt,
136
+
CreatedAt: createdAt,
128
137
// Annotations removed - stored separately in repository_annotations table
129
138
}
130
139
···
154
163
}
155
164
}
156
165
157
-
// Update repository annotations ONLY if manifest has at least one non-empty annotation
158
-
if manifestRecord.Annotations != nil {
159
-
hasData := false
160
-
for _, value := range manifestRecord.Annotations {
161
-
if value != "" {
162
-
hasData = true
163
-
break
164
-
}
165
-
}
166
-
167
-
if hasData {
168
-
// Replace all annotations for this repository
169
-
err = db.UpsertRepositoryAnnotations(p.db, did, manifestRecord.Repository, manifestRecord.Annotations)
170
-
if err != nil {
171
-
return 0, fmt.Errorf("failed to upsert annotations: %w", err)
172
-
}
173
-
}
174
-
}
166
+
// Note: Repository annotations are currently disabled because the generated
167
+
// Manifest_Annotations type doesn't support arbitrary key-value pairs.
168
+
// The lexicon would need to use "unknown" type for annotations to support this.
169
+
// TODO: Re-enable once lexicon supports annotations as map[string]string
170
+
_ = manifestRecord.Annotations
175
171
176
172
// Insert manifest references or layers
177
173
if isManifestList {
···
184
180
185
181
if ref.Platform != nil {
186
182
platformArch = ref.Platform.Architecture
187
-
platformOS = ref.Platform.OS
188
-
platformVariant = ref.Platform.Variant
189
-
platformOSVersion = ref.Platform.OSVersion
183
+
platformOS = ref.Platform.Os
184
+
if ref.Platform.Variant != nil {
185
+
platformVariant = *ref.Platform.Variant
186
+
}
187
+
if ref.Platform.OsVersion != nil {
188
+
platformOSVersion = *ref.Platform.OsVersion
189
+
}
190
190
}
191
191
192
-
// Detect attestation manifests from annotations
192
+
// Note: Attestation detection via annotations is currently disabled
193
+
// because the generated Manifest_ManifestReference_Annotations type
194
+
// doesn't support arbitrary key-value pairs.
193
195
isAttestation := false
194
-
if ref.Annotations != nil {
195
-
if refType, ok := ref.Annotations["vnd.docker.reference.type"]; ok {
196
-
isAttestation = refType == "attestation-manifest"
197
-
}
198
-
}
199
196
200
197
if err := db.InsertManifestReference(p.db, &db.ManifestReference{
201
198
ManifestID: manifestID,
···
235
232
// ProcessTag processes a tag record and stores it in the database
236
233
func (p *Processor) ProcessTag(ctx context.Context, did string, recordData []byte) error {
237
234
// Unmarshal tag record
238
-
var tagRecord atproto.TagRecord
235
+
var tagRecord atproto.Tag
239
236
if err := json.Unmarshal(recordData, &tagRecord); err != nil {
240
237
return fmt.Errorf("failed to unmarshal tag: %w", err)
241
238
}
···
245
242
return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
246
243
}
247
244
245
+
// Parse CreatedAt string to time.Time
246
+
tagCreatedAt, err := time.Parse(time.RFC3339, tagRecord.CreatedAt)
247
+
if err != nil {
248
+
// Fall back to current time if parsing fails
249
+
tagCreatedAt = time.Now()
250
+
}
251
+
248
252
// Insert or update tag
249
253
return db.UpsertTag(p.db, &db.Tag{
250
254
DID: did,
251
255
Repository: tagRecord.Repository,
252
256
Tag: tagRecord.Tag,
253
257
Digest: manifestDigest,
254
-
CreatedAt: tagRecord.UpdatedAt,
258
+
CreatedAt: tagCreatedAt,
255
259
})
256
260
}
257
261
258
262
// ProcessStar processes a star record and stores it in the database
259
263
func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []byte) error {
260
264
// Unmarshal star record
261
-
var starRecord atproto.StarRecord
265
+
var starRecord atproto.SailorStar
262
266
if err := json.Unmarshal(recordData, &starRecord); err != nil {
263
267
return fmt.Errorf("failed to unmarshal star: %w", err)
264
268
}
···
266
270
// The DID here is the starrer (user who starred)
267
271
// The subject contains the owner DID and repository
268
272
// Star count will be calculated on demand from the stars table
269
-
return db.UpsertStar(p.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
273
+
// Parse the CreatedAt string to time.Time
274
+
createdAt, err := time.Parse(time.RFC3339, starRecord.CreatedAt)
275
+
if err != nil {
276
+
// Fall back to current time if parsing fails
277
+
createdAt = time.Now()
278
+
}
279
+
return db.UpsertStar(p.db, did, starRecord.Subject.Did, starRecord.Subject.Repository, createdAt)
270
280
}
271
281
272
282
// ProcessSailorProfile processes a sailor profile record
273
283
// This is primarily used by backfill to cache captain records for holds
274
284
func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, recordData []byte, queryCaptainFn func(context.Context, string) error) error {
275
285
// Unmarshal sailor profile record
276
-
var profileRecord atproto.SailorProfileRecord
286
+
var profileRecord atproto.SailorProfile
277
287
if err := json.Unmarshal(recordData, &profileRecord); err != nil {
278
288
return fmt.Errorf("failed to unmarshal sailor profile: %w", err)
279
289
}
280
290
281
291
// Skip if no default hold set
282
-
if profileRecord.DefaultHold == "" {
292
+
if profileRecord.DefaultHold == nil || *profileRecord.DefaultHold == "" {
283
293
return nil
284
294
}
285
295
286
296
// Convert hold URL/DID to canonical DID
287
-
holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold)
297
+
holdDID := atproto.ResolveHoldDIDFromURL(*profileRecord.DefaultHold)
288
298
if holdDID == "" {
289
-
slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold)
299
+
slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", *profileRecord.DefaultHold)
290
300
return nil
291
301
}
292
302
···
297
307
}
298
308
299
309
return nil
300
-
}
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
310
}
325
311
326
312
// ProcessIdentity handles identity change events (handle updates)
+36
-54
pkg/appview/jetstream/processor_test.go
+36
-54
pkg/appview/jetstream/processor_test.go
···
11
11
_ "github.com/mattn/go-sqlite3"
12
12
)
13
13
14
+
// ptrString returns a pointer to the given string
15
+
func ptrString(s string) *string {
16
+
return &s
17
+
}
18
+
14
19
// setupTestDB creates an in-memory SQLite database for testing
15
20
func setupTestDB(t *testing.T) *sql.DB {
16
21
database, err := sql.Open("sqlite3", ":memory:")
···
143
148
ctx := context.Background()
144
149
145
150
// Create test manifest record
146
-
manifestRecord := &atproto.ManifestRecord{
151
+
manifestRecord := &atproto.Manifest{
147
152
Repository: "test-app",
148
153
Digest: "sha256:abc123",
149
154
MediaType: "application/vnd.oci.image.manifest.v1+json",
150
155
SchemaVersion: 2,
151
-
HoldEndpoint: "did:web:hold01.atcr.io",
152
-
CreatedAt: time.Now(),
153
-
Config: &atproto.BlobReference{
156
+
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
157
+
CreatedAt: time.Now().Format(time.RFC3339),
158
+
Config: &atproto.Manifest_BlobReference{
154
159
Digest: "sha256:config123",
155
160
Size: 1234,
156
161
},
157
-
Layers: []atproto.BlobReference{
162
+
Layers: []atproto.Manifest_BlobReference{
158
163
{Digest: "sha256:layer1", Size: 5000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"},
159
164
{Digest: "sha256:layer2", Size: 3000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"},
160
165
},
161
-
Annotations: map[string]string{
162
-
"org.opencontainers.image.title": "Test App",
163
-
"org.opencontainers.image.description": "A test application",
164
-
"org.opencontainers.image.source": "https://github.com/test/app",
165
-
"org.opencontainers.image.licenses": "MIT",
166
-
"io.atcr.icon": "https://example.com/icon.png",
167
-
},
166
+
// Annotations disabled - generated Manifest_Annotations is empty struct
168
167
}
169
168
170
169
// Marshal to bytes for ProcessManifest
···
193
192
t.Errorf("Expected 1 manifest, got %d", count)
194
193
}
195
194
196
-
// Verify annotations were stored in repository_annotations table
197
-
var title, source string
198
-
err = database.QueryRow("SELECT value FROM repository_annotations WHERE did = ? AND repository = ? AND key = ?",
199
-
"did:plc:test123", "test-app", "org.opencontainers.image.title").Scan(&title)
200
-
if err != nil {
201
-
t.Fatalf("Failed to query title annotation: %v", err)
202
-
}
203
-
if title != "Test App" {
204
-
t.Errorf("title = %q, want %q", title, "Test App")
205
-
}
206
-
207
-
err = database.QueryRow("SELECT value FROM repository_annotations WHERE did = ? AND repository = ? AND key = ?",
208
-
"did:plc:test123", "test-app", "org.opencontainers.image.source").Scan(&source)
209
-
if err != nil {
210
-
t.Fatalf("Failed to query source annotation: %v", err)
211
-
}
212
-
if source != "https://github.com/test/app" {
213
-
t.Errorf("source = %q, want %q", source, "https://github.com/test/app")
214
-
}
195
+
// Note: Annotations verification disabled - generated Manifest_Annotations is empty struct
196
+
// TODO: Re-enable when lexicon uses "unknown" type for annotations
215
197
216
198
// Verify layers were inserted
217
199
var layerCount int
···
242
224
ctx := context.Background()
243
225
244
226
// Create test manifest list record
245
-
manifestRecord := &atproto.ManifestRecord{
227
+
manifestRecord := &atproto.Manifest{
246
228
Repository: "test-app",
247
229
Digest: "sha256:list123",
248
230
MediaType: "application/vnd.oci.image.index.v1+json",
249
231
SchemaVersion: 2,
250
-
HoldEndpoint: "did:web:hold01.atcr.io",
251
-
CreatedAt: time.Now(),
252
-
Manifests: []atproto.ManifestReference{
232
+
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
233
+
CreatedAt: time.Now().Format(time.RFC3339),
234
+
Manifests: []atproto.Manifest_ManifestReference{
253
235
{
254
236
Digest: "sha256:amd64manifest",
255
237
MediaType: "application/vnd.oci.image.manifest.v1+json",
256
238
Size: 1000,
257
-
Platform: &atproto.Platform{
239
+
Platform: &atproto.Manifest_Platform{
258
240
Architecture: "amd64",
259
-
OS: "linux",
241
+
Os: "linux",
260
242
},
261
243
},
262
244
{
263
245
Digest: "sha256:arm64manifest",
264
246
MediaType: "application/vnd.oci.image.manifest.v1+json",
265
247
Size: 1100,
266
-
Platform: &atproto.Platform{
248
+
Platform: &atproto.Manifest_Platform{
267
249
Architecture: "arm64",
268
-
OS: "linux",
269
-
Variant: "v8",
250
+
Os: "linux",
251
+
Variant: ptrString("v8"),
270
252
},
271
253
},
272
254
},
···
326
308
ctx := context.Background()
327
309
328
310
// Create test tag record (using ManifestDigest field for simplicity)
329
-
tagRecord := &atproto.TagRecord{
311
+
tagRecord := &atproto.Tag{
330
312
Repository: "test-app",
331
313
Tag: "latest",
332
-
ManifestDigest: "sha256:abc123",
333
-
UpdatedAt: time.Now(),
314
+
ManifestDigest: ptrString("sha256:abc123"),
315
+
CreatedAt: time.Now().Format(time.RFC3339),
334
316
}
335
317
336
318
// Marshal to bytes for ProcessTag
···
368
350
}
369
351
370
352
// Test upserting same tag with new digest
371
-
tagRecord.ManifestDigest = "sha256:newdigest"
353
+
tagRecord.ManifestDigest = ptrString("sha256:newdigest")
372
354
recordBytes, err = json.Marshal(tagRecord)
373
355
if err != nil {
374
356
t.Fatalf("Failed to marshal tag: %v", err)
···
407
389
ctx := context.Background()
408
390
409
391
// Create test star record
410
-
starRecord := &atproto.StarRecord{
411
-
Subject: atproto.StarSubject{
412
-
DID: "did:plc:owner123",
392
+
starRecord := &atproto.SailorStar{
393
+
Subject: atproto.SailorStar_Subject{
394
+
Did: "did:plc:owner123",
413
395
Repository: "test-app",
414
396
},
415
-
CreatedAt: time.Now(),
397
+
CreatedAt: time.Now().Format(time.RFC3339),
416
398
}
417
399
418
400
// Marshal to bytes for ProcessStar
···
466
448
p := NewProcessor(database, false)
467
449
ctx := context.Background()
468
450
469
-
manifestRecord := &atproto.ManifestRecord{
451
+
manifestRecord := &atproto.Manifest{
470
452
Repository: "test-app",
471
453
Digest: "sha256:abc123",
472
454
MediaType: "application/vnd.oci.image.manifest.v1+json",
473
455
SchemaVersion: 2,
474
-
HoldEndpoint: "did:web:hold01.atcr.io",
475
-
CreatedAt: time.Now(),
456
+
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
457
+
CreatedAt: time.Now().Format(time.RFC3339),
476
458
}
477
459
478
460
// Marshal to bytes for ProcessManifest
···
518
500
ctx := context.Background()
519
501
520
502
// Manifest with nil annotations
521
-
manifestRecord := &atproto.ManifestRecord{
503
+
manifestRecord := &atproto.Manifest{
522
504
Repository: "test-app",
523
505
Digest: "sha256:abc123",
524
506
MediaType: "application/vnd.oci.image.manifest.v1+json",
525
507
SchemaVersion: 2,
526
-
HoldEndpoint: "did:web:hold01.atcr.io",
527
-
CreatedAt: time.Now(),
508
+
HoldEndpoint: ptrString("did:web:hold01.atcr.io"),
509
+
CreatedAt: time.Now().Format(time.RFC3339),
528
510
Annotations: nil,
529
511
}
530
512
+3
-39
pkg/appview/jetstream/worker.go
+3
-39
pkg/appview/jetstream/worker.go
···
61
61
jetstreamURL: jetstreamURL,
62
62
startCursor: startCursor,
63
63
wantedCollections: []string{
64
-
"io.atcr.*", // Subscribe to all ATCR collections
64
+
atproto.ManifestCollection, // io.atcr.manifest
65
+
atproto.TagCollection, // io.atcr.tag
66
+
atproto.StarCollection, // io.atcr.sailor.star
65
67
},
66
68
processor: NewProcessor(database, true), // Use cache for live streaming
67
69
}
···
310
312
case atproto.StarCollection:
311
313
slog.Info("Jetstream processing star event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
312
314
return w.processStar(commit)
313
-
case atproto.RepoPageCollection:
314
-
slog.Info("Jetstream processing repo page event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
315
-
return w.processRepoPage(commit)
316
315
default:
317
316
// Ignore other collections
318
317
return nil
···
435
434
436
435
// Use shared processor for DB operations
437
436
return w.processor.ProcessStar(context.Background(), commit.DID, recordBytes)
438
-
}
439
-
440
-
// processRepoPage processes a repo page commit event
441
-
func (w *Worker) processRepoPage(commit *CommitEvent) error {
442
-
// Resolve and upsert user with handle/PDS endpoint
443
-
if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil {
444
-
return fmt.Errorf("failed to ensure user: %w", err)
445
-
}
446
-
447
-
isDelete := commit.Operation == "delete"
448
-
449
-
if isDelete {
450
-
// Delete - rkey is the repository name
451
-
slog.Info("Jetstream deleting repo page", "did", commit.DID, "repository", commit.RKey)
452
-
if err := w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, nil, true); err != nil {
453
-
slog.Error("Jetstream ERROR deleting repo page", "error", err)
454
-
return err
455
-
}
456
-
slog.Info("Jetstream successfully deleted repo page", "did", commit.DID, "repository", commit.RKey)
457
-
return nil
458
-
}
459
-
460
-
// Parse repo page record
461
-
if commit.Record == nil {
462
-
return nil
463
-
}
464
-
465
-
// Marshal map to bytes for processing
466
-
recordBytes, err := json.Marshal(commit.Record)
467
-
if err != nil {
468
-
return fmt.Errorf("failed to marshal record: %w", err)
469
-
}
470
-
471
-
// Use shared processor for DB operations
472
-
return w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, recordBytes, false)
473
437
}
474
438
475
439
// processIdentity processes an identity event (handle change)
+23
-14
pkg/appview/middleware/registry.go
+23
-14
pkg/appview/middleware/registry.go
···
15
15
"github.com/distribution/distribution/v3/registry/storage/driver"
16
16
"github.com/distribution/reference"
17
17
18
-
"atcr.io/pkg/appview/readme"
19
18
"atcr.io/pkg/appview/storage"
20
19
"atcr.io/pkg/atproto"
21
20
"atcr.io/pkg/auth"
···
170
169
// These are set by main.go during startup and copied into NamespaceResolver instances.
171
170
// After initialization, request handling uses the NamespaceResolver's instance fields.
172
171
var (
173
-
globalRefresher *oauth.Refresher
174
-
globalDatabase storage.DatabaseMetrics
175
-
globalAuthorizer auth.HoldAuthorizer
172
+
globalRefresher *oauth.Refresher
173
+
globalDatabase storage.DatabaseMetrics
174
+
globalAuthorizer auth.HoldAuthorizer
175
+
globalReadmeCache storage.ReadmeCache
176
176
)
177
177
178
178
// SetGlobalRefresher sets the OAuth refresher instance during initialization
···
193
193
globalAuthorizer = authorizer
194
194
}
195
195
196
+
// SetGlobalReadmeCache sets the readme cache instance during initialization
197
+
// Must be called before the registry starts serving requests
198
+
func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
199
+
globalReadmeCache = readmeCache
200
+
}
201
+
196
202
func init() {
197
203
// Register the name resolution middleware
198
204
registrymw.Register("atproto-resolver", initATProtoResolver)
···
207
213
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
208
214
database storage.DatabaseMetrics // Metrics database (copied from global on init)
209
215
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
216
+
readmeCache storage.ReadmeCache // README cache (copied from global on init)
210
217
validationCache *validationCache // Request-level service token cache
211
-
readmeFetcher *readme.Fetcher // README fetcher for repo pages
212
218
}
213
219
214
220
// initATProtoResolver initializes the name resolution middleware
···
242
248
refresher: globalRefresher,
243
249
database: globalDatabase,
244
250
authorizer: globalAuthorizer,
251
+
readmeCache: globalReadmeCache,
245
252
validationCache: newValidationCache(),
246
-
readmeFetcher: readme.NewFetcher(),
247
253
}, nil
248
254
}
249
255
···
460
466
Database: nr.database,
461
467
Authorizer: nr.authorizer,
462
468
Refresher: nr.refresher,
463
-
ReadmeFetcher: nr.readmeFetcher,
469
+
ReadmeCache: nr.readmeCache,
464
470
}
465
471
466
472
return storage.NewRoutingRepository(repo, registryCtx), nil
···
484
490
// findHoldDID determines which hold DID to use for blob storage
485
491
// Priority order:
486
492
// 1. User's sailor profile defaultHold (if set)
487
-
// 2. AppView's default hold DID
493
+
// 2. User's own hold record (io.atcr.hold)
494
+
// 3. AppView's default hold DID
488
495
// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
489
496
func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
490
497
// Create ATProto client (without auth - reading public records)
···
497
504
slog.Warn("Failed to read profile", "did", did, "error", err)
498
505
}
499
506
500
-
if profile != nil && profile.DefaultHold != "" {
507
+
if profile != nil && profile.DefaultHold != nil && *profile.DefaultHold != "" {
508
+
defaultHold := *profile.DefaultHold
501
509
// Profile exists with defaultHold set
502
510
// In test mode, verify it's reachable before using it
503
511
if nr.testMode {
504
-
if nr.isHoldReachable(ctx, profile.DefaultHold) {
505
-
return profile.DefaultHold
512
+
if nr.isHoldReachable(ctx, defaultHold) {
513
+
return defaultHold
506
514
}
507
-
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", profile.DefaultHold)
515
+
slog.Debug("User's defaultHold unreachable, falling back to default", "component", "registry/middleware/testmode", "default_hold", defaultHold)
508
516
return nr.defaultHoldDID
509
517
}
510
-
return profile.DefaultHold
518
+
return defaultHold
511
519
}
512
520
513
-
// No profile defaultHold - use AppView default
521
+
// Profile doesn't exist or defaultHold is null/empty
522
+
// Legacy io.atcr.hold records are no longer supported - use AppView default
514
523
return nr.defaultHoldDID
515
524
}
516
525
+32
-2
pkg/appview/middleware/registry_test.go
+32
-2
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
+
70
75
// TestInitATProtoResolver tests the initialization function
71
76
func TestInitATProtoResolver(t *testing.T) {
72
77
ctx := context.Background()
···
199
204
assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
200
205
}
201
206
202
-
// TestFindHoldDID_Priority tests the priority order
207
+
// TestFindHoldDID_NoProfile tests fallback to default hold when no profile exists
208
+
func TestFindHoldDID_NoProfile(t *testing.T) {
209
+
// Start a mock PDS server that returns 404 for profile
210
+
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
211
+
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
212
+
// Profile not found
213
+
w.WriteHeader(http.StatusNotFound)
214
+
return
215
+
}
216
+
w.WriteHeader(http.StatusNotFound)
217
+
}))
218
+
defer mockPDS.Close()
219
+
220
+
resolver := &NamespaceResolver{
221
+
defaultHoldDID: "did:web:default.atcr.io",
222
+
}
223
+
224
+
ctx := context.Background()
225
+
holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
226
+
227
+
// Should fall back to default hold DID when no profile exists
228
+
// Note: Legacy io.atcr.hold records are no longer supported
229
+
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default hold DID")
230
+
}
231
+
232
+
// TestFindHoldDID_Priority tests that profile takes priority over default
203
233
func TestFindHoldDID_Priority(t *testing.T) {
204
-
// Start a mock PDS server that returns both profile and hold records
234
+
// Start a mock PDS server that returns profile
205
235
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206
236
if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
207
237
// Return sailor profile with defaultHold (highest priority)
+111
pkg/appview/readme/cache.go
+111
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
+
// Cache stores rendered README HTML in the database
15
+
type Cache struct {
16
+
db *sql.DB
17
+
fetcher *Fetcher
18
+
ttl time.Duration
19
+
}
20
+
21
+
// NewCache creates a new README cache
22
+
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
23
+
if ttl == 0 {
24
+
ttl = 1 * time.Hour // Default TTL
25
+
}
26
+
return &Cache{
27
+
db: db,
28
+
fetcher: NewFetcher(),
29
+
ttl: ttl,
30
+
}
31
+
}
32
+
33
+
// Get retrieves a README from cache or fetches it
34
+
func (c *Cache) Get(ctx context.Context, readmeURL string) (string, error) {
35
+
// Try to get from cache
36
+
html, fetchedAt, err := c.getFromDB(readmeURL)
37
+
if err == nil {
38
+
// Check if cache is still valid
39
+
if time.Since(fetchedAt) < c.ttl {
40
+
return html, nil
41
+
}
42
+
}
43
+
44
+
// Cache miss or expired, fetch fresh content
45
+
html, err = c.fetcher.FetchAndRender(ctx, readmeURL)
46
+
if err != nil {
47
+
// If fetch fails but we have stale cache, return it
48
+
if html != "" {
49
+
return html, nil
50
+
}
51
+
return "", err
52
+
}
53
+
54
+
// Store in cache
55
+
if err := c.storeInDB(readmeURL, html); err != nil {
56
+
// Log error but don't fail - we have the content
57
+
slog.Warn("Failed to cache README", "error", err)
58
+
}
59
+
60
+
return html, nil
61
+
}
62
+
63
+
// getFromDB retrieves cached README from database
64
+
func (c *Cache) getFromDB(readmeURL string) (string, time.Time, error) {
65
+
var html string
66
+
var fetchedAt time.Time
67
+
68
+
err := c.db.QueryRow(`
69
+
SELECT html, fetched_at
70
+
FROM readme_cache
71
+
WHERE url = ?
72
+
`, readmeURL).Scan(&html, &fetchedAt)
73
+
74
+
if err != nil {
75
+
return "", time.Time{}, err
76
+
}
77
+
78
+
return html, fetchedAt, nil
79
+
}
80
+
81
+
// storeInDB stores rendered README in database
82
+
func (c *Cache) storeInDB(readmeURL, html string) error {
83
+
_, err := c.db.Exec(`
84
+
INSERT INTO readme_cache (url, html, fetched_at)
85
+
VALUES (?, ?, ?)
86
+
ON CONFLICT(url) DO UPDATE SET
87
+
html = excluded.html,
88
+
fetched_at = excluded.fetched_at
89
+
`, readmeURL, html, time.Now())
90
+
91
+
return err
92
+
}
93
+
94
+
// Invalidate removes a README from the cache
95
+
func (c *Cache) Invalidate(readmeURL string) error {
96
+
_, err := c.db.Exec(`
97
+
DELETE FROM readme_cache
98
+
WHERE url = ?
99
+
`, readmeURL)
100
+
return err
101
+
}
102
+
103
+
// Cleanup removes expired entries from the cache
104
+
func (c *Cache) Cleanup() error {
105
+
cutoff := time.Now().Add(-c.ttl * 2) // Keep for 2x TTL
106
+
_, err := c.db.Exec(`
107
+
DELETE FROM readme_cache
108
+
WHERE fetched_at < ?
109
+
`, cutoff)
110
+
return err
111
+
}
+13
pkg/appview/readme/cache_test.go
+13
pkg/appview/readme/cache_test.go
+9
-62
pkg/appview/readme/fetcher.go
+9
-62
pkg/appview/readme/fetcher.go
···
7
7
"io"
8
8
"net/http"
9
9
"net/url"
10
-
"regexp"
11
10
"strings"
12
11
"time"
13
12
···
181
180
return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, path)
182
181
}
183
182
184
-
// Is404 returns true if the error indicates a 404 Not Found response
185
-
func Is404(err error) bool {
186
-
return err != nil && strings.Contains(err.Error(), "unexpected status code: 404")
187
-
}
188
-
189
-
// RenderMarkdown renders a markdown string to sanitized HTML
190
-
// This is used for rendering repo page descriptions stored in the database
191
-
func (f *Fetcher) RenderMarkdown(content []byte) (string, error) {
192
-
// Render markdown to HTML (no base URL for repo page descriptions)
193
-
return f.renderMarkdown(content, "")
194
-
}
195
-
196
-
// Regex patterns for matching relative URLs that need rewriting
197
-
// These match src="..." or href="..." where the URL is relative (not absolute, not data:, not #anchor)
198
-
var (
199
-
// Match src="filename" where filename doesn't start with http://, https://, //, /, #, data:, or mailto:
200
-
relativeSrcPattern = regexp.MustCompile(`src="([^"/:][^"]*)"`)
201
-
// Match href="filename" where filename doesn't start with http://, https://, //, /, #, data:, or mailto:
202
-
relativeHrefPattern = regexp.MustCompile(`href="([^"/:][^"]*)"`)
203
-
)
204
-
205
183
// rewriteRelativeURLs converts relative URLs to absolute URLs
206
184
func rewriteRelativeURLs(html, baseURL string) string {
207
185
if baseURL == "" {
···
213
191
return html
214
192
}
215
193
216
-
// Handle root-relative URLs (starting with /) first
217
-
// Must be done before bare relative URLs to avoid double-processing
218
-
if base.Scheme != "" && base.Host != "" {
219
-
root := fmt.Sprintf("%s://%s/", base.Scheme, base.Host)
220
-
// Replace src="/" and href="/" but not src="//" (protocol-relative URLs)
221
-
html = strings.ReplaceAll(html, `src="/`, fmt.Sprintf(`src="%s`, root))
222
-
html = strings.ReplaceAll(html, `href="/`, fmt.Sprintf(`href="%s`, root))
223
-
}
224
-
225
-
// Handle explicit relative paths (./something and ../something)
194
+
// Simple string replacement for common patterns
195
+
// This is a basic implementation - for production, consider using an HTML parser
226
196
html = strings.ReplaceAll(html, `src="./`, fmt.Sprintf(`src="%s`, baseURL))
227
197
html = strings.ReplaceAll(html, `href="./`, fmt.Sprintf(`href="%s`, baseURL))
228
198
html = strings.ReplaceAll(html, `src="../`, fmt.Sprintf(`src="%s../`, baseURL))
229
199
html = strings.ReplaceAll(html, `href="../`, fmt.Sprintf(`href="%s../`, baseURL))
230
200
231
-
// Handle bare relative URLs (e.g., src="image.png" without ./ prefix)
232
-
// Skip URLs that are already absolute (start with http://, https://, or //)
233
-
// Skip anchors (#), data URLs (data:), and mailto links
234
-
html = relativeSrcPattern.ReplaceAllStringFunc(html, func(match string) string {
235
-
// Extract the URL from src="..."
236
-
url := match[5 : len(match)-1] // Remove 'src="' and '"'
237
-
238
-
// Skip if already processed or is a special URL type
239
-
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") ||
240
-
strings.HasPrefix(url, "//") || strings.HasPrefix(url, "#") ||
241
-
strings.HasPrefix(url, "data:") || strings.HasPrefix(url, "mailto:") {
242
-
return match
243
-
}
244
-
245
-
return fmt.Sprintf(`src="%s%s"`, baseURL, url)
246
-
})
247
-
248
-
html = relativeHrefPattern.ReplaceAllStringFunc(html, func(match string) string {
249
-
// Extract the URL from href="..."
250
-
url := match[6 : len(match)-1] // Remove 'href="' and '"'
251
-
252
-
// Skip if already processed or is a special URL type
253
-
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") ||
254
-
strings.HasPrefix(url, "//") || strings.HasPrefix(url, "#") ||
255
-
strings.HasPrefix(url, "data:") || strings.HasPrefix(url, "mailto:") {
256
-
return match
257
-
}
258
-
259
-
return fmt.Sprintf(`href="%s%s"`, baseURL, url)
260
-
})
201
+
// Handle root-relative URLs (starting with /)
202
+
if base.Scheme != "" && base.Host != "" {
203
+
root := fmt.Sprintf("%s://%s/", base.Scheme, base.Host)
204
+
// Replace src="/" and href="/" but not src="//" (absolute URLs)
205
+
html = strings.ReplaceAll(html, `src="/`, fmt.Sprintf(`src="%s`, root))
206
+
html = strings.ReplaceAll(html, `href="/`, fmt.Sprintf(`href="%s`, root))
207
+
}
261
208
262
209
return html
263
210
}
-148
pkg/appview/readme/fetcher_test.go
-148
pkg/appview/readme/fetcher_test.go
···
145
145
baseURL: "https://example.com/docs/",
146
146
expected: `<img src="https://example.com//cdn.example.com/image.png">`,
147
147
},
148
-
{
149
-
name: "bare relative src (no ./ prefix)",
150
-
html: `<img src="image.png">`,
151
-
baseURL: "https://example.com/docs/",
152
-
expected: `<img src="https://example.com/docs/image.png">`,
153
-
},
154
-
{
155
-
name: "bare relative href (no ./ prefix)",
156
-
html: `<a href="page.html">link</a>`,
157
-
baseURL: "https://example.com/docs/",
158
-
expected: `<a href="https://example.com/docs/page.html">link</a>`,
159
-
},
160
-
{
161
-
name: "bare relative with path",
162
-
html: `<img src="images/logo.png">`,
163
-
baseURL: "https://example.com/docs/",
164
-
expected: `<img src="https://example.com/docs/images/logo.png">`,
165
-
},
166
-
{
167
-
name: "anchor links unchanged",
168
-
html: `<a href="#section">link</a>`,
169
-
baseURL: "https://example.com/docs/",
170
-
expected: `<a href="#section">link</a>`,
171
-
},
172
-
{
173
-
name: "data URLs unchanged",
174
-
html: `<img src="data:image/png;base64,abc123">`,
175
-
baseURL: "https://example.com/docs/",
176
-
expected: `<img src="data:image/png;base64,abc123">`,
177
-
},
178
-
{
179
-
name: "mailto links unchanged",
180
-
html: `<a href="mailto:test@example.com">email</a>`,
181
-
baseURL: "https://example.com/docs/",
182
-
expected: `<a href="mailto:test@example.com">email</a>`,
183
-
},
184
-
{
185
-
name: "mixed bare and prefixed relative URLs",
186
-
html: `<img src="slices_and_lucy.png"><a href="./other.md">link</a>`,
187
-
baseURL: "https://github.com/user/repo/blob/main/",
188
-
expected: `<img src="https://github.com/user/repo/blob/main/slices_and_lucy.png"><a href="https://github.com/user/repo/blob/main/other.md">link</a>`,
189
-
},
190
148
}
191
149
192
150
for _, tt := range tests {
···
197
155
}
198
156
})
199
157
}
200
-
}
201
-
202
-
func TestFetcher_RenderMarkdown(t *testing.T) {
203
-
fetcher := NewFetcher()
204
-
205
-
tests := []struct {
206
-
name string
207
-
content string
208
-
wantContain string
209
-
wantErr bool
210
-
}{
211
-
{
212
-
name: "simple paragraph",
213
-
content: "Hello, world!",
214
-
wantContain: "<p>Hello, world!</p>",
215
-
wantErr: false,
216
-
},
217
-
{
218
-
name: "heading",
219
-
content: "# My App",
220
-
wantContain: "<h1",
221
-
wantErr: false,
222
-
},
223
-
{
224
-
name: "bold text",
225
-
content: "This is **bold** text.",
226
-
wantContain: "<strong>bold</strong>",
227
-
wantErr: false,
228
-
},
229
-
{
230
-
name: "italic text",
231
-
content: "This is *italic* text.",
232
-
wantContain: "<em>italic</em>",
233
-
wantErr: false,
234
-
},
235
-
{
236
-
name: "code block",
237
-
content: "```\ncode here\n```",
238
-
wantContain: "<pre>",
239
-
wantErr: false,
240
-
},
241
-
{
242
-
name: "link",
243
-
content: "[Link text](https://example.com)",
244
-
wantContain: `href="https://example.com"`,
245
-
wantErr: false,
246
-
},
247
-
{
248
-
name: "image",
249
-
content: "",
250
-
wantContain: `src="https://example.com/image.png"`,
251
-
wantErr: false,
252
-
},
253
-
{
254
-
name: "unordered list",
255
-
content: "- Item 1\n- Item 2",
256
-
wantContain: "<ul>",
257
-
wantErr: false,
258
-
},
259
-
{
260
-
name: "ordered list",
261
-
content: "1. Item 1\n2. Item 2",
262
-
wantContain: "<ol>",
263
-
wantErr: false,
264
-
},
265
-
{
266
-
name: "empty content",
267
-
content: "",
268
-
wantContain: "",
269
-
wantErr: false,
270
-
},
271
-
{
272
-
name: "complex markdown",
273
-
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```",
274
-
wantContain: "<h1",
275
-
wantErr: false,
276
-
},
277
-
}
278
-
279
-
for _, tt := range tests {
280
-
t.Run(tt.name, func(t *testing.T) {
281
-
html, err := fetcher.RenderMarkdown([]byte(tt.content))
282
-
if (err != nil) != tt.wantErr {
283
-
t.Errorf("RenderMarkdown() error = %v, wantErr %v", err, tt.wantErr)
284
-
return
285
-
}
286
-
if !tt.wantErr && tt.wantContain != "" {
287
-
if !containsSubstring(html, tt.wantContain) {
288
-
t.Errorf("RenderMarkdown() = %q, want to contain %q", html, tt.wantContain)
289
-
}
290
-
}
291
-
})
292
-
}
293
-
}
294
-
295
-
func containsSubstring(s, substr string) bool {
296
-
return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr)))
297
-
}
298
-
299
-
func containsSubstringHelper(s, substr string) bool {
300
-
for i := 0; i <= len(s)-len(substr); i++ {
301
-
if s[i:i+len(substr)] == substr {
302
-
return true
303
-
}
304
-
}
305
-
return false
306
158
}
307
159
308
160
// TODO: Add README fetching and caching tests
-103
pkg/appview/readme/source.go
-103
pkg/appview/readme/source.go
···
1
-
package readme
2
-
3
-
import (
4
-
"fmt"
5
-
"net/url"
6
-
"strings"
7
-
)
8
-
9
-
// Platform represents a supported Git hosting platform
10
-
type Platform string
11
-
12
-
const (
13
-
PlatformGitHub Platform = "github"
14
-
PlatformGitLab Platform = "gitlab"
15
-
PlatformTangled Platform = "tangled"
16
-
)
17
-
18
-
// ParseSourceURL extracts platform, user, and repo from a source repository URL.
19
-
// Returns ok=false if the URL is not a recognized pattern.
20
-
func ParseSourceURL(sourceURL string) (platform Platform, user, repo string, ok bool) {
21
-
if sourceURL == "" {
22
-
return "", "", "", false
23
-
}
24
-
25
-
parsed, err := url.Parse(sourceURL)
26
-
if err != nil {
27
-
return "", "", "", false
28
-
}
29
-
30
-
// Normalize: remove trailing slash and .git suffix
31
-
path := strings.TrimSuffix(parsed.Path, "/")
32
-
path = strings.TrimSuffix(path, ".git")
33
-
path = strings.TrimPrefix(path, "/")
34
-
35
-
if path == "" {
36
-
return "", "", "", false
37
-
}
38
-
39
-
host := strings.ToLower(parsed.Host)
40
-
41
-
switch {
42
-
case host == "github.com":
43
-
// GitHub: github.com/{user}/{repo}
44
-
parts := strings.SplitN(path, "/", 3)
45
-
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
46
-
return "", "", "", false
47
-
}
48
-
return PlatformGitHub, parts[0], parts[1], true
49
-
50
-
case host == "gitlab.com":
51
-
// GitLab: gitlab.com/{user}/{repo} or gitlab.com/{group}/{subgroup}/{repo}
52
-
// For nested groups, user = everything except last part, repo = last part
53
-
lastSlash := strings.LastIndex(path, "/")
54
-
if lastSlash == -1 || lastSlash == 0 {
55
-
return "", "", "", false
56
-
}
57
-
user = path[:lastSlash]
58
-
repo = path[lastSlash+1:]
59
-
if user == "" || repo == "" {
60
-
return "", "", "", false
61
-
}
62
-
return PlatformGitLab, user, repo, true
63
-
64
-
case host == "tangled.org" || host == "tangled.sh":
65
-
// Tangled: tangled.org/{user}/{repo} or tangled.sh/@{user}/{repo} (legacy)
66
-
// Strip leading @ from user if present
67
-
path = strings.TrimPrefix(path, "@")
68
-
parts := strings.SplitN(path, "/", 3)
69
-
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
70
-
return "", "", "", false
71
-
}
72
-
return PlatformTangled, parts[0], parts[1], true
73
-
74
-
default:
75
-
return "", "", "", false
76
-
}
77
-
}
78
-
79
-
// DeriveReadmeURL converts a source repository URL to a raw README URL.
80
-
// Returns empty string if platform is not supported.
81
-
func DeriveReadmeURL(sourceURL, branch string) string {
82
-
platform, user, repo, ok := ParseSourceURL(sourceURL)
83
-
if !ok {
84
-
return ""
85
-
}
86
-
87
-
switch platform {
88
-
case PlatformGitHub:
89
-
// https://raw.githubusercontent.com/{user}/{repo}/refs/heads/{branch}/README.md
90
-
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/refs/heads/%s/README.md", user, repo, branch)
91
-
92
-
case PlatformGitLab:
93
-
// https://gitlab.com/{user}/{repo}/-/raw/{branch}/README.md
94
-
return fmt.Sprintf("https://gitlab.com/%s/%s/-/raw/%s/README.md", user, repo, branch)
95
-
96
-
case PlatformTangled:
97
-
// https://tangled.org/{user}/{repo}/raw/{branch}/README.md
98
-
return fmt.Sprintf("https://tangled.org/%s/%s/raw/%s/README.md", user, repo, branch)
99
-
100
-
default:
101
-
return ""
102
-
}
103
-
}
-241
pkg/appview/readme/source_test.go
-241
pkg/appview/readme/source_test.go
···
1
-
package readme
2
-
3
-
import (
4
-
"testing"
5
-
)
6
-
7
-
func TestParseSourceURL(t *testing.T) {
8
-
tests := []struct {
9
-
name string
10
-
sourceURL string
11
-
wantPlatform Platform
12
-
wantUser string
13
-
wantRepo string
14
-
wantOK bool
15
-
}{
16
-
// GitHub
17
-
{
18
-
name: "github standard",
19
-
sourceURL: "https://github.com/bigmoves/quickslice",
20
-
wantPlatform: PlatformGitHub,
21
-
wantUser: "bigmoves",
22
-
wantRepo: "quickslice",
23
-
wantOK: true,
24
-
},
25
-
{
26
-
name: "github with .git suffix",
27
-
sourceURL: "https://github.com/user/repo.git",
28
-
wantPlatform: PlatformGitHub,
29
-
wantUser: "user",
30
-
wantRepo: "repo",
31
-
wantOK: true,
32
-
},
33
-
{
34
-
name: "github with trailing slash",
35
-
sourceURL: "https://github.com/user/repo/",
36
-
wantPlatform: PlatformGitHub,
37
-
wantUser: "user",
38
-
wantRepo: "repo",
39
-
wantOK: true,
40
-
},
41
-
{
42
-
name: "github with subpath (ignored)",
43
-
sourceURL: "https://github.com/user/repo/tree/main",
44
-
wantPlatform: PlatformGitHub,
45
-
wantUser: "user",
46
-
wantRepo: "repo",
47
-
wantOK: true,
48
-
},
49
-
{
50
-
name: "github user only",
51
-
sourceURL: "https://github.com/user",
52
-
wantOK: false,
53
-
},
54
-
55
-
// GitLab
56
-
{
57
-
name: "gitlab standard",
58
-
sourceURL: "https://gitlab.com/user/repo",
59
-
wantPlatform: PlatformGitLab,
60
-
wantUser: "user",
61
-
wantRepo: "repo",
62
-
wantOK: true,
63
-
},
64
-
{
65
-
name: "gitlab nested groups",
66
-
sourceURL: "https://gitlab.com/group/subgroup/repo",
67
-
wantPlatform: PlatformGitLab,
68
-
wantUser: "group/subgroup",
69
-
wantRepo: "repo",
70
-
wantOK: true,
71
-
},
72
-
{
73
-
name: "gitlab deep nested groups",
74
-
sourceURL: "https://gitlab.com/a/b/c/d/repo",
75
-
wantPlatform: PlatformGitLab,
76
-
wantUser: "a/b/c/d",
77
-
wantRepo: "repo",
78
-
wantOK: true,
79
-
},
80
-
{
81
-
name: "gitlab with .git suffix",
82
-
sourceURL: "https://gitlab.com/user/repo.git",
83
-
wantPlatform: PlatformGitLab,
84
-
wantUser: "user",
85
-
wantRepo: "repo",
86
-
wantOK: true,
87
-
},
88
-
89
-
// Tangled
90
-
{
91
-
name: "tangled standard",
92
-
sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry",
93
-
wantPlatform: PlatformTangled,
94
-
wantUser: "evan.jarrett.net",
95
-
wantRepo: "at-container-registry",
96
-
wantOK: true,
97
-
},
98
-
{
99
-
name: "tangled with legacy @ prefix",
100
-
sourceURL: "https://tangled.org/@evan.jarrett.net/at-container-registry",
101
-
wantPlatform: PlatformTangled,
102
-
wantUser: "evan.jarrett.net",
103
-
wantRepo: "at-container-registry",
104
-
wantOK: true,
105
-
},
106
-
{
107
-
name: "tangled.sh domain",
108
-
sourceURL: "https://tangled.sh/user/repo",
109
-
wantPlatform: PlatformTangled,
110
-
wantUser: "user",
111
-
wantRepo: "repo",
112
-
wantOK: true,
113
-
},
114
-
{
115
-
name: "tangled with trailing slash",
116
-
sourceURL: "https://tangled.org/user/repo/",
117
-
wantPlatform: PlatformTangled,
118
-
wantUser: "user",
119
-
wantRepo: "repo",
120
-
wantOK: true,
121
-
},
122
-
123
-
// Unsupported / Invalid
124
-
{
125
-
name: "unsupported platform",
126
-
sourceURL: "https://bitbucket.org/user/repo",
127
-
wantOK: false,
128
-
},
129
-
{
130
-
name: "empty url",
131
-
sourceURL: "",
132
-
wantOK: false,
133
-
},
134
-
{
135
-
name: "invalid url",
136
-
sourceURL: "not-a-url",
137
-
wantOK: false,
138
-
},
139
-
{
140
-
name: "just host",
141
-
sourceURL: "https://github.com",
142
-
wantOK: false,
143
-
},
144
-
}
145
-
146
-
for _, tt := range tests {
147
-
t.Run(tt.name, func(t *testing.T) {
148
-
platform, user, repo, ok := ParseSourceURL(tt.sourceURL)
149
-
if ok != tt.wantOK {
150
-
t.Errorf("ParseSourceURL(%q) ok = %v, want %v", tt.sourceURL, ok, tt.wantOK)
151
-
return
152
-
}
153
-
if !tt.wantOK {
154
-
return
155
-
}
156
-
if platform != tt.wantPlatform {
157
-
t.Errorf("ParseSourceURL(%q) platform = %v, want %v", tt.sourceURL, platform, tt.wantPlatform)
158
-
}
159
-
if user != tt.wantUser {
160
-
t.Errorf("ParseSourceURL(%q) user = %q, want %q", tt.sourceURL, user, tt.wantUser)
161
-
}
162
-
if repo != tt.wantRepo {
163
-
t.Errorf("ParseSourceURL(%q) repo = %q, want %q", tt.sourceURL, repo, tt.wantRepo)
164
-
}
165
-
})
166
-
}
167
-
}
168
-
169
-
func TestDeriveReadmeURL(t *testing.T) {
170
-
tests := []struct {
171
-
name string
172
-
sourceURL string
173
-
branch string
174
-
want string
175
-
}{
176
-
// GitHub
177
-
{
178
-
name: "github main",
179
-
sourceURL: "https://github.com/bigmoves/quickslice",
180
-
branch: "main",
181
-
want: "https://raw.githubusercontent.com/bigmoves/quickslice/refs/heads/main/README.md",
182
-
},
183
-
{
184
-
name: "github master",
185
-
sourceURL: "https://github.com/user/repo",
186
-
branch: "master",
187
-
want: "https://raw.githubusercontent.com/user/repo/refs/heads/master/README.md",
188
-
},
189
-
190
-
// GitLab
191
-
{
192
-
name: "gitlab main",
193
-
sourceURL: "https://gitlab.com/user/repo",
194
-
branch: "main",
195
-
want: "https://gitlab.com/user/repo/-/raw/main/README.md",
196
-
},
197
-
{
198
-
name: "gitlab nested groups",
199
-
sourceURL: "https://gitlab.com/group/subgroup/repo",
200
-
branch: "main",
201
-
want: "https://gitlab.com/group/subgroup/repo/-/raw/main/README.md",
202
-
},
203
-
204
-
// Tangled
205
-
{
206
-
name: "tangled main",
207
-
sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry",
208
-
branch: "main",
209
-
want: "https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/README.md",
210
-
},
211
-
{
212
-
name: "tangled legacy @ prefix",
213
-
sourceURL: "https://tangled.org/@user/repo",
214
-
branch: "main",
215
-
want: "https://tangled.org/user/repo/raw/main/README.md",
216
-
},
217
-
218
-
// Unsupported
219
-
{
220
-
name: "unsupported platform",
221
-
sourceURL: "https://bitbucket.org/user/repo",
222
-
branch: "main",
223
-
want: "",
224
-
},
225
-
{
226
-
name: "empty url",
227
-
sourceURL: "",
228
-
branch: "main",
229
-
want: "",
230
-
},
231
-
}
232
-
233
-
for _, tt := range tests {
234
-
t.Run(tt.name, func(t *testing.T) {
235
-
got := DeriveReadmeURL(tt.sourceURL, tt.branch)
236
-
if got != tt.want {
237
-
t.Errorf("DeriveReadmeURL(%q, %q) = %q, want %q", tt.sourceURL, tt.branch, got, tt.want)
238
-
}
239
-
})
240
-
}
241
-
}
+2
-15
pkg/appview/routes/routes.go
+2
-15
pkg/appview/routes/routes.go
···
27
27
BaseURL string
28
28
DeviceStore *db.DeviceStore
29
29
HealthChecker *holdhealth.Checker
30
-
ReadmeFetcher *readme.Fetcher
30
+
ReadmeCache *readme.Cache
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
-
ReadmeFetcher: deps.ReadmeFetcher,
163
+
ReadmeCache: deps.ReadmeCache,
164
164
},
165
165
).ServeHTTP)
166
166
···
184
184
}).ServeHTTP)
185
185
186
186
r.Delete("/api/images/{repository}/manifests/{digest}", (&uihandlers.DeleteManifestHandler{
187
-
DB: deps.Database,
188
-
Refresher: deps.Refresher,
189
-
}).ServeHTTP)
190
-
191
-
r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{
192
187
DB: deps.Database,
193
188
Refresher: deps.Refresher,
194
189
}).ServeHTTP)
···
224
219
}
225
220
router.Get("/auth/logout", logoutHandler.ServeHTTP)
226
221
router.Post("/auth/logout", logoutHandler.ServeHTTP)
227
-
228
-
// Custom 404 handler
229
-
router.NotFound(middleware.OptionalAuth(deps.SessionStore, deps.Database)(
230
-
&uihandlers.NotFoundHandler{
231
-
Templates: deps.Templates,
232
-
RegistryURL: registryURL,
233
-
},
234
-
).ServeHTTP)
235
222
}
236
223
237
224
// CORSMiddleware returns a middleware that sets CORS headers for API endpoints
+49
-160
pkg/appview/static/css/style.css
+49
-160
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
-
45
41
/* Hero section colors */
46
42
--hero-bg-start: #f8f9fa;
47
43
--hero-bg-end: #e9ecef;
···
94
90
--version-badge-text: #ffffff;
95
91
--version-badge-border: #ba68c8;
96
92
97
-
/* Attestation badge */
98
-
--attestation-badge-bg: #065f46;
99
-
--attestation-badge-text: #6ee7b7;
100
-
101
93
/* Hero section colors */
102
94
--hero-bg-start: #2d2d2d;
103
95
--hero-bg-end: #1a1a1a;
···
117
109
}
118
110
119
111
body {
120
-
font-family:
121
-
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
122
-
Arial, sans-serif;
112
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
123
113
background: var(--bg);
124
114
color: var(--fg);
125
115
line-height: 1.6;
···
180
170
}
181
171
182
172
.nav-links a:hover {
183
-
background: var(--secondary);
173
+
background:var(--secondary);
184
174
border-radius: 4px;
185
175
}
186
176
···
203
193
}
204
194
205
195
.user-menu-btn:hover {
206
-
background: var(--secondary);
196
+
background:var(--secondary);
207
197
}
208
198
209
199
.user-avatar {
···
276
266
position: absolute;
277
267
top: calc(100% + 0.5rem);
278
268
right: 0;
279
-
background: var(--bg);
269
+
background:var(--bg);
280
270
border: 1px solid var(--border);
281
271
border-radius: 8px;
282
272
box-shadow: var(--shadow-lg);
···
297
287
color: var(--fg);
298
288
text-decoration: none;
299
289
border: none;
300
-
background: var(--bg);
290
+
background:var(--bg);
301
291
cursor: pointer;
302
292
transition: background 0.2s;
303
293
font-size: 0.95rem;
···
319
309
}
320
310
321
311
/* Buttons */
322
-
button,
323
-
.btn,
324
-
.btn-primary,
325
-
.btn-secondary {
312
+
button, .btn, .btn-primary, .btn-secondary {
326
313
padding: 0.5rem 1rem;
327
314
background: var(--button-primary);
328
315
color: var(--btn-text);
···
335
322
transition: opacity 0.2s;
336
323
}
337
324
338
-
button:hover,
339
-
.btn:hover,
340
-
.btn-primary:hover,
341
-
.btn-secondary:hover {
325
+
button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
342
326
opacity: 0.9;
343
327
}
344
328
···
409
393
}
410
394
411
395
/* Cards */
412
-
.push-card,
413
-
.repository-card {
396
+
.push-card, .repository-card {
414
397
border: 1px solid var(--border);
415
398
border-radius: 8px;
416
399
padding: 1rem;
417
400
margin-bottom: 1rem;
418
-
background: var(--bg);
401
+
background:var(--bg);
419
402
box-shadow: var(--shadow-sm);
420
403
}
421
404
···
466
449
}
467
450
468
451
.digest {
469
-
font-family: "Monaco", "Courier New", monospace;
452
+
font-family: 'Monaco', 'Courier New', monospace;
470
453
font-size: 0.85rem;
471
454
background: var(--code-bg);
472
455
padding: 0.1rem 0.3rem;
···
509
492
}
510
493
511
494
.docker-command-text {
512
-
font-family: "Monaco", "Courier New", monospace;
495
+
font-family: 'Monaco', 'Courier New', monospace;
513
496
font-size: 0.85rem;
514
497
color: var(--fg);
515
498
flex: 0 1 auto;
···
527
510
border-radius: 4px;
528
511
opacity: 0;
529
512
visibility: hidden;
530
-
transition:
531
-
opacity 0.2s,
532
-
visibility 0.2s;
513
+
transition: opacity 0.2s, visibility 0.2s;
533
514
}
534
515
535
516
.docker-command:hover .copy-btn {
···
771
752
}
772
753
773
754
.repo-stats {
774
-
color: var(--border-dark);
755
+
color:var(--border-dark);
775
756
font-size: 0.9rem;
776
757
display: flex;
777
758
gap: 0.5rem;
···
800
781
padding-top: 1rem;
801
782
}
802
783
803
-
.tags-section,
804
-
.manifests-section {
784
+
.tags-section, .manifests-section {
805
785
margin-bottom: 1.5rem;
806
786
}
807
787
808
-
.tags-section h3,
809
-
.manifests-section h3 {
788
+
.tags-section h3, .manifests-section h3 {
810
789
font-size: 1.1rem;
811
790
margin-bottom: 0.5rem;
812
791
color: var(--secondary);
813
792
}
814
793
815
-
.tag-row,
816
-
.manifest-row {
794
+
.tag-row, .manifest-row {
817
795
display: flex;
818
796
gap: 1rem;
819
797
align-items: center;
···
821
799
border-bottom: 1px solid var(--border);
822
800
}
823
801
824
-
.tag-row:last-child,
825
-
.manifest-row:last-child {
802
+
.tag-row:last-child, .manifest-row:last-child {
826
803
border-bottom: none;
827
804
}
828
805
···
844
821
}
845
822
846
823
.settings-section {
847
-
background: var(--bg);
824
+
background:var(--bg);
848
825
border: 1px solid var(--border);
849
826
border-radius: 8px;
850
827
padding: 1.5rem;
···
941
918
padding: 1rem;
942
919
border-radius: 4px;
943
920
overflow-x: auto;
944
-
font-family: "Monaco", "Courier New", monospace;
921
+
font-family: 'Monaco', 'Courier New', monospace;
945
922
font-size: 0.85rem;
946
923
border: 1px solid var(--border);
947
924
}
···
1027
1004
margin: 1rem 0;
1028
1005
}
1029
1006
1007
+
/* Load More Button */
1008
+
.load-more {
1009
+
width: 100%;
1010
+
margin-top: 1rem;
1011
+
background: var(--secondary);
1012
+
}
1013
+
1030
1014
/* Login Page */
1031
1015
.login-page {
1032
1016
max-width: 450px;
···
1047
1031
}
1048
1032
1049
1033
.login-form {
1050
-
background: var(--bg);
1034
+
background:var(--bg);
1051
1035
padding: 2rem;
1052
1036
border-radius: 8px;
1053
1037
border: 1px solid var(--border);
···
1198
1182
}
1199
1183
1200
1184
.repository-header {
1201
-
background: var(--bg);
1185
+
background:var(--bg);
1202
1186
border: 1px solid var(--border);
1203
1187
border-radius: 8px;
1204
1188
padding: 2rem;
···
1236
1220
flex-shrink: 0;
1237
1221
}
1238
1222
1239
-
.repo-hero-icon-wrapper {
1240
-
position: relative;
1241
-
display: inline-block;
1242
-
flex-shrink: 0;
1243
-
}
1244
-
1245
-
.avatar-upload-overlay {
1246
-
position: absolute;
1247
-
inset: 0;
1248
-
display: flex;
1249
-
align-items: center;
1250
-
justify-content: center;
1251
-
background: rgba(0, 0, 0, 0.5);
1252
-
border-radius: 12px;
1253
-
opacity: 0;
1254
-
cursor: pointer;
1255
-
transition: opacity 0.2s ease;
1256
-
}
1257
-
1258
-
.avatar-upload-overlay i {
1259
-
color: white;
1260
-
width: 24px;
1261
-
height: 24px;
1262
-
}
1263
-
1264
-
.repo-hero-icon-wrapper:hover .avatar-upload-overlay {
1265
-
opacity: 1;
1266
-
}
1267
-
1268
1223
.repo-hero-info {
1269
1224
flex: 1;
1270
1225
}
···
1335
1290
}
1336
1291
1337
1292
.star-btn.starred {
1338
-
border-color: var(--star);
1293
+
border-color:var(--star);
1339
1294
background: var(--code-bg);
1340
1295
}
1341
1296
···
1419
1374
}
1420
1375
1421
1376
.repo-section {
1422
-
background: var(--bg);
1377
+
background:var(--bg);
1423
1378
border: 1px solid var(--border);
1424
1379
border-radius: 8px;
1425
1380
padding: 1.5rem;
···
1434
1389
border-bottom: 2px solid var(--border);
1435
1390
}
1436
1391
1437
-
.tags-list,
1438
-
.manifests-list {
1392
+
.tags-list, .manifests-list {
1439
1393
display: flex;
1440
1394
flex-direction: column;
1441
1395
gap: 1rem;
1442
1396
}
1443
1397
1444
-
.tag-item,
1445
-
.manifest-item {
1398
+
.tag-item, .manifest-item {
1446
1399
border: 1px solid var(--border);
1447
1400
border-radius: 6px;
1448
1401
padding: 1rem;
1449
1402
background: var(--hover-bg);
1450
1403
}
1451
1404
1452
-
.tag-item-header,
1453
-
.manifest-item-header {
1405
+
.tag-item-header, .manifest-item-header {
1454
1406
display: flex;
1455
1407
justify-content: space-between;
1456
1408
align-items: center;
···
1580
1532
color: var(--fg);
1581
1533
border: 1px solid var(--border);
1582
1534
white-space: nowrap;
1583
-
font-family: "Monaco", "Courier New", monospace;
1535
+
font-family: 'Monaco', 'Courier New', monospace;
1584
1536
}
1585
1537
1586
1538
.platforms-inline {
···
1618
1570
.badge-attestation {
1619
1571
display: inline-flex;
1620
1572
align-items: center;
1621
-
gap: 0.3rem;
1622
-
padding: 0.25rem 0.6rem;
1623
-
background: var(--attestation-badge-bg);
1624
-
color: var(--attestation-badge-text);
1625
-
border-radius: 12px;
1626
-
font-size: 0.75rem;
1573
+
gap: 0.35rem;
1574
+
padding: 0.25rem 0.5rem;
1575
+
background: #f3e8ff;
1576
+
color: #7c3aed;
1577
+
border: 1px solid #c4b5fd;
1578
+
border-radius: 4px;
1579
+
font-size: 0.85rem;
1627
1580
font-weight: 600;
1628
1581
margin-left: 0.5rem;
1629
-
vertical-align: middle;
1630
-
white-space: nowrap;
1631
1582
}
1632
1583
1633
1584
.badge-attestation .lucide {
1634
-
width: 0.75rem;
1635
-
height: 0.75rem;
1585
+
width: 0.9rem;
1586
+
height: 0.9rem;
1636
1587
}
1637
1588
1638
1589
/* Featured Repositories Section */
···
1785
1736
1786
1737
/* Hero Section */
1787
1738
.hero-section {
1788
-
background: linear-gradient(
1789
-
135deg,
1790
-
var(--hero-bg-start) 0%,
1791
-
var(--hero-bg-end) 100%
1792
-
);
1739
+
background: linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-end) 100%);
1793
1740
padding: 4rem 2rem;
1794
1741
border-bottom: 1px solid var(--border);
1795
1742
}
···
1854
1801
.terminal-content {
1855
1802
padding: 1.5rem;
1856
1803
margin: 0;
1857
-
font-family: "Monaco", "Courier New", monospace;
1804
+
font-family: 'Monaco', 'Courier New', monospace;
1858
1805
font-size: 0.95rem;
1859
1806
line-height: 1.8;
1860
1807
color: var(--terminal-text);
···
2010
1957
}
2011
1958
2012
1959
.code-block code {
2013
-
font-family: "Monaco", "Menlo", monospace;
1960
+
font-family: 'Monaco', 'Menlo', monospace;
2014
1961
font-size: 0.9rem;
2015
1962
line-height: 1.5;
2016
1963
white-space: pre-wrap;
···
2067
2014
flex-wrap: wrap;
2068
2015
}
2069
2016
2070
-
.tag-row,
2071
-
.manifest-row {
2017
+
.tag-row, .manifest-row {
2072
2018
flex-wrap: wrap;
2073
2019
}
2074
2020
···
2157
2103
/* README and Repository Layout */
2158
2104
.repo-content-layout {
2159
2105
display: grid;
2160
-
grid-template-columns: 6fr 4fr;
2106
+
grid-template-columns: 7fr 3fr;
2161
2107
gap: 2rem;
2162
2108
margin-top: 2rem;
2163
2109
}
···
2268
2214
background: var(--code-bg);
2269
2215
padding: 0.2rem 0.4rem;
2270
2216
border-radius: 3px;
2271
-
font-family:
2272
-
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
2217
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
2273
2218
font-size: 0.9em;
2274
2219
}
2275
2220
···
2373
2318
padding: 0.75rem;
2374
2319
}
2375
2320
}
2376
-
2377
-
/* 404 Error Page */
2378
-
.error-page {
2379
-
display: flex;
2380
-
align-items: center;
2381
-
justify-content: center;
2382
-
min-height: calc(100vh - 60px);
2383
-
text-align: center;
2384
-
padding: 2rem;
2385
-
}
2386
-
2387
-
.error-content {
2388
-
max-width: 480px;
2389
-
}
2390
-
2391
-
.error-icon {
2392
-
width: 80px;
2393
-
height: 80px;
2394
-
color: var(--secondary);
2395
-
margin-bottom: 1.5rem;
2396
-
}
2397
-
2398
-
.error-code {
2399
-
font-size: 8rem;
2400
-
font-weight: 700;
2401
-
color: var(--primary);
2402
-
line-height: 1;
2403
-
margin-bottom: 0.5rem;
2404
-
}
2405
-
2406
-
.error-content h1 {
2407
-
font-size: 2rem;
2408
-
margin-bottom: 0.75rem;
2409
-
color: var(--fg);
2410
-
}
2411
-
2412
-
.error-content p {
2413
-
font-size: 1.125rem;
2414
-
color: var(--secondary);
2415
-
margin-bottom: 2rem;
2416
-
}
2417
-
2418
-
@media (max-width: 768px) {
2419
-
.error-code {
2420
-
font-size: 5rem;
2421
-
}
2422
-
2423
-
.error-icon {
2424
-
width: 60px;
2425
-
height: 60px;
2426
-
}
2427
-
2428
-
.error-content h1 {
2429
-
font-size: 1.5rem;
2430
-
}
2431
-
}
-63
pkg/appview/static/js/app.js
-63
pkg/appview/static/js/app.js
···
434
434
}
435
435
}
436
436
437
-
// Upload repository avatar
438
-
async function uploadAvatar(input, repository) {
439
-
const file = input.files[0];
440
-
if (!file) return;
441
-
442
-
// Client-side validation
443
-
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
444
-
if (!validTypes.includes(file.type)) {
445
-
alert('Please select a PNG, JPEG, or WebP image');
446
-
return;
447
-
}
448
-
if (file.size > 3 * 1024 * 1024) {
449
-
alert('Image must be less than 3MB');
450
-
return;
451
-
}
452
-
453
-
const formData = new FormData();
454
-
formData.append('avatar', file);
455
-
456
-
try {
457
-
const response = await fetch(`/api/images/${repository}/avatar`, {
458
-
method: 'POST',
459
-
credentials: 'include',
460
-
body: formData
461
-
});
462
-
463
-
if (response.status === 401) {
464
-
window.location.href = '/auth/oauth/login';
465
-
return;
466
-
}
467
-
468
-
if (!response.ok) {
469
-
const error = await response.text();
470
-
throw new Error(error);
471
-
}
472
-
473
-
const data = await response.json();
474
-
475
-
// Update the avatar image on the page
476
-
const wrapper = document.querySelector('.repo-hero-icon-wrapper');
477
-
if (!wrapper) return;
478
-
479
-
const existingImg = wrapper.querySelector('.repo-hero-icon');
480
-
const placeholder = wrapper.querySelector('.repo-hero-icon-placeholder');
481
-
482
-
if (existingImg) {
483
-
existingImg.src = data.avatarURL;
484
-
} else if (placeholder) {
485
-
const newImg = document.createElement('img');
486
-
newImg.src = data.avatarURL;
487
-
newImg.alt = repository;
488
-
newImg.className = 'repo-hero-icon';
489
-
placeholder.replaceWith(newImg);
490
-
}
491
-
} catch (err) {
492
-
console.error('Error uploading avatar:', err);
493
-
alert('Failed to upload avatar: ' + err.message);
494
-
}
495
-
496
-
// Clear input so same file can be selected again
497
-
input.value = '';
498
-
}
499
-
500
437
// Close modal when clicking outside
501
438
document.addEventListener('DOMContentLoaded', () => {
502
439
const modal = document.getElementById('manifest-delete-modal');
+12
-5
pkg/appview/storage/context.go
+12
-5
pkg/appview/storage/context.go
···
1
1
package storage
2
2
3
3
import (
4
-
"atcr.io/pkg/appview/readme"
4
+
"context"
5
+
5
6
"atcr.io/pkg/atproto"
6
7
"atcr.io/pkg/auth"
7
8
"atcr.io/pkg/auth/oauth"
···
12
13
IncrementPullCount(did, repository string) error
13
14
IncrementPushCount(did, repository string) error
14
15
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
15
22
}
16
23
17
24
// RegistryContext bundles all the context needed for registry operations
···
28
35
AuthMethod string // Auth method used ("oauth" or "app_password")
29
36
30
37
// Shared services (same for all requests)
31
-
Database DatabaseMetrics // Metrics tracking database
32
-
Authorizer auth.HoldAuthorizer // Hold access authorization
33
-
Refresher *oauth.Refresher // OAuth session manager
34
-
ReadmeFetcher *readme.Fetcher // README fetcher for repo pages
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
35
42
}
+34
-1
pkg/appview/storage/context_test.go
+34
-1
pkg/appview/storage/context_test.go
···
1
1
package storage
2
2
3
3
import (
4
+
"context"
4
5
"sync"
5
6
"testing"
6
7
···
45
46
return m.pushCount
46
47
}
47
48
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
+
48
59
type mockHoldAuthorizer struct{}
49
60
50
61
func (m *mockHoldAuthorizer) Authorize(holdDID, userDID, permission string) (bool, error) {
···
63
74
ATProtoClient: &atproto.Client{
64
75
// Mock client - would need proper initialization in real tests
65
76
},
66
-
Database: &mockDatabaseMetrics{},
77
+
Database: &mockDatabaseMetrics{},
78
+
ReadmeCache: &mockReadmeCache{},
67
79
}
68
80
69
81
// Verify fields are accessible
···
100
112
}
101
113
102
114
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")
103
136
if err != nil {
104
137
t.Errorf("Unexpected error: %v", err)
105
138
}
+34
-277
pkg/appview/storage/manifest_store.go
+34
-277
pkg/appview/storage/manifest_store.go
···
8
8
"fmt"
9
9
"io"
10
10
"log/slog"
11
-
"maps"
12
11
"net/http"
13
12
"strings"
14
13
"sync"
15
-
"time"
16
14
17
-
"atcr.io/pkg/appview/readme"
18
15
"atcr.io/pkg/atproto"
19
16
"github.com/distribution/distribution/v3"
20
17
"github.com/opencontainers/go-digest"
···
62
59
}
63
60
}
64
61
65
-
var manifestRecord atproto.ManifestRecord
62
+
var manifestRecord atproto.Manifest
66
63
if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
67
64
return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err)
68
65
}
69
66
70
67
// Store the hold DID for subsequent blob requests during pull
71
-
// Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
68
+
// Prefer HoldDid (new format) with fallback to HoldEndpoint (legacy URL format)
72
69
// The routing repository will cache this for concurrent blob fetches
73
70
s.mu.Lock()
74
-
if manifestRecord.HoldDID != "" {
71
+
if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" {
75
72
// New format: DID reference (preferred)
76
-
s.lastFetchedHoldDID = manifestRecord.HoldDID
77
-
} else if manifestRecord.HoldEndpoint != "" {
73
+
s.lastFetchedHoldDID = *manifestRecord.HoldDid
74
+
} else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" {
78
75
// Legacy format: URL reference - convert to DID
79
-
s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
76
+
s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint)
80
77
}
81
78
s.mu.Unlock()
82
79
83
80
var ociManifest []byte
84
81
85
82
// New records: Download blob from ATProto blob storage
86
-
if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Link != "" {
87
-
ociManifest, err = s.ctx.ATProtoClient.GetBlob(ctx, manifestRecord.ManifestBlob.Ref.Link)
83
+
if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Defined() {
84
+
ociManifest, err = s.ctx.ATProtoClient.GetBlob(ctx, manifestRecord.ManifestBlob.Ref.String())
88
85
if err != nil {
89
86
return nil, fmt.Errorf("failed to download manifest blob: %w", err)
90
87
}
···
137
134
138
135
// Set the blob reference, hold DID, and hold endpoint
139
136
manifestRecord.ManifestBlob = blobRef
140
-
manifestRecord.HoldDID = s.ctx.HoldDID // Primary reference (DID)
137
+
if s.ctx.HoldDID != "" {
138
+
manifestRecord.HoldDid = &s.ctx.HoldDID // Primary reference (DID)
139
+
}
141
140
142
141
// Extract Dockerfile labels from config blob and add to annotations
143
142
// Only for image manifests (not manifest lists which don't have config blobs)
···
164
163
if !exists {
165
164
platform := "unknown"
166
165
if ref.Platform != nil {
167
-
platform = fmt.Sprintf("%s/%s", ref.Platform.OS, ref.Platform.Architecture)
166
+
platform = fmt.Sprintf("%s/%s", ref.Platform.Os, ref.Platform.Architecture)
168
167
}
169
168
slog.Warn("Manifest list references non-existent child manifest",
170
169
"repository", s.ctx.Repository,
···
175
174
}
176
175
}
177
176
178
-
if !isManifestList && s.blobStore != nil && manifestRecord.Config != nil && manifestRecord.Config.Digest != "" {
179
-
labels, err := s.extractConfigLabels(ctx, manifestRecord.Config.Digest)
180
-
if err != nil {
181
-
// Log error but don't fail the push - labels are optional
182
-
slog.Warn("Failed to extract config labels", "error", err)
183
-
} else {
184
-
// Initialize annotations map if needed
185
-
if manifestRecord.Annotations == nil {
186
-
manifestRecord.Annotations = make(map[string]string)
187
-
}
188
-
189
-
// Copy labels to annotations (Dockerfile LABELs โ manifest annotations)
190
-
maps.Copy(manifestRecord.Annotations, labels)
191
-
192
-
slog.Debug("Extracted labels from config blob", "count", len(labels))
193
-
}
194
-
}
177
+
// Note: Label extraction from config blob is currently disabled because the generated
178
+
// Manifest_Annotations type doesn't support arbitrary keys. The lexicon schema would
179
+
// need to use "unknown" type for annotations to support dynamic key-value pairs.
180
+
// TODO: Update lexicon schema if label extraction is needed.
181
+
_ = isManifestList // silence unused variable warning for now
195
182
196
183
// Store manifest record in ATProto
197
184
rkey := digestToRKey(dgst)
···
238
225
}()
239
226
}
240
227
241
-
// Create or update repo page asynchronously if manifest has relevant annotations
242
-
// This ensures repository metadata is synced to user's PDS
228
+
// Refresh README cache asynchronously if manifest has io.atcr.readme annotation
229
+
// This ensures fresh README content is available on repository pages
243
230
go func() {
244
231
defer func() {
245
232
if r := recover(); r != nil {
246
-
slog.Error("Panic in ensureRepoPage", "panic", r)
233
+
slog.Error("Panic in refreshReadmeCache", "panic", r)
247
234
}
248
235
}()
249
-
s.ensureRepoPage(context.Background(), manifestRecord)
236
+
s.refreshReadmeCache(context.Background(), manifestRecord)
250
237
}()
251
238
252
239
return dgst, nil
···
318
305
319
306
// notifyHoldAboutManifest notifies the hold service about a manifest upload
320
307
// This enables the hold to create layer records and Bluesky posts
321
-
func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.ManifestRecord, tag, manifestDigest string) error {
308
+
func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.Manifest, tag, manifestDigest string) error {
322
309
// Skip if no service token configured (e.g., anonymous pulls)
323
310
if s.ctx.ServiceToken == "" {
324
311
return nil
···
368
355
}
369
356
if m.Platform != nil {
370
357
mData["platform"] = map[string]any{
371
-
"os": m.Platform.OS,
358
+
"os": m.Platform.Os,
372
359
"architecture": m.Platform.Architecture,
373
360
}
374
361
}
···
425
412
return nil
426
413
}
427
414
428
-
// ensureRepoPage creates or updates a repo page record in the user's PDS if needed
429
-
// This syncs repository metadata from manifest annotations to the io.atcr.repo.page collection
430
-
// Only creates a new record if one doesn't exist (doesn't overwrite user's custom content)
431
-
func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) {
432
-
// Check if repo page already exists (don't overwrite user's custom content)
433
-
rkey := s.ctx.Repository
434
-
_, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey)
435
-
if err == nil {
436
-
// Record already exists - don't overwrite
437
-
slog.Debug("Repo page already exists, skipping creation", "did", s.ctx.DID, "repository", s.ctx.Repository)
438
-
return
439
-
}
440
-
441
-
// Only continue if it's a "not found" error - other errors mean we should skip
442
-
if !errors.Is(err, atproto.ErrRecordNotFound) {
443
-
slog.Warn("Failed to check for existing repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
444
-
return
445
-
}
446
-
447
-
// Get annotations (may be nil if image has no OCI labels)
448
-
annotations := manifestRecord.Annotations
449
-
if annotations == nil {
450
-
annotations = make(map[string]string)
451
-
}
452
-
453
-
// Try to fetch README content from external sources
454
-
// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description
455
-
description := s.fetchReadmeContent(ctx, annotations)
456
-
457
-
// If no README content could be fetched, fall back to description annotation
458
-
if description == "" {
459
-
description = annotations["org.opencontainers.image.description"]
460
-
}
461
-
462
-
// Try to fetch and upload icon from io.atcr.icon annotation
463
-
var avatarRef *atproto.ATProtoBlobRef
464
-
if iconURL := annotations["io.atcr.icon"]; iconURL != "" {
465
-
avatarRef = s.fetchAndUploadIcon(ctx, iconURL)
466
-
}
467
-
468
-
// Create new repo page record with description and optional avatar
469
-
repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef)
470
-
471
-
slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository, "descriptionLength", len(description), "hasAvatar", avatarRef != nil)
472
-
473
-
_, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.RepoPageCollection, rkey, repoPage)
474
-
if err != nil {
475
-
slog.Warn("Failed to create repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
415
+
// refreshReadmeCache refreshes the README cache for this manifest if it has io.atcr.readme annotation
416
+
// This should be called asynchronously after manifest push to keep README content fresh
417
+
// NOTE: Currently disabled because the generated Manifest_Annotations type doesn't support
418
+
// arbitrary key-value pairs. Would need to update lexicon schema with "unknown" type.
419
+
func (s *ManifestStore) refreshReadmeCache(ctx context.Context, manifestRecord *atproto.Manifest) {
420
+
// Skip if no README cache configured
421
+
if s.ctx.ReadmeCache == nil {
476
422
return
477
423
}
478
424
479
-
slog.Info("Repo page created successfully", "did", s.ctx.DID, "repository", s.ctx.Repository)
480
-
}
481
-
482
-
// fetchReadmeContent attempts to fetch README content from external sources
483
-
// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source
484
-
// Returns the raw markdown content, or empty string if not available
485
-
func (s *ManifestStore) fetchReadmeContent(ctx context.Context, annotations map[string]string) string {
486
-
if s.ctx.ReadmeFetcher == nil {
487
-
return ""
488
-
}
489
-
490
-
// Create a context with timeout for README fetching (don't block push too long)
491
-
fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
492
-
defer cancel()
493
-
494
-
// Priority 1: Direct README URL from io.atcr.readme annotation
495
-
if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" {
496
-
content, err := s.fetchRawReadme(fetchCtx, readmeURL)
497
-
if err != nil {
498
-
slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err)
499
-
} else if content != "" {
500
-
slog.Info("Fetched README from io.atcr.readme annotation", "url", readmeURL, "length", len(content))
501
-
return content
502
-
}
503
-
}
504
-
505
-
// Priority 2: Derive README URL from org.opencontainers.image.source
506
-
if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" {
507
-
// Try main branch first, then master
508
-
for _, branch := range []string{"main", "master"} {
509
-
readmeURL := readme.DeriveReadmeURL(sourceURL, branch)
510
-
if readmeURL == "" {
511
-
continue
512
-
}
513
-
514
-
content, err := s.fetchRawReadme(fetchCtx, readmeURL)
515
-
if err != nil {
516
-
// Only log non-404 errors (404 is expected when trying main vs master)
517
-
if !readme.Is404(err) {
518
-
slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err)
519
-
}
520
-
continue
521
-
}
522
-
523
-
if content != "" {
524
-
slog.Info("Fetched README from source URL", "sourceURL", sourceURL, "branch", branch, "length", len(content))
525
-
return content
526
-
}
527
-
}
528
-
}
529
-
530
-
return ""
531
-
}
532
-
533
-
// fetchRawReadme fetches raw markdown content from a URL
534
-
// Returns the raw markdown (not rendered HTML) for storage in the repo page record
535
-
func (s *ManifestStore) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) {
536
-
// Use a simple HTTP client to fetch raw content
537
-
// We want raw markdown, not rendered HTML (the Fetcher renders to HTML)
538
-
req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil)
539
-
if err != nil {
540
-
return "", fmt.Errorf("failed to create request: %w", err)
541
-
}
542
-
543
-
req.Header.Set("User-Agent", "ATCR-README-Fetcher/1.0")
544
-
545
-
client := &http.Client{
546
-
Timeout: 10 * time.Second,
547
-
CheckRedirect: func(req *http.Request, via []*http.Request) error {
548
-
if len(via) >= 5 {
549
-
return fmt.Errorf("too many redirects")
550
-
}
551
-
return nil
552
-
},
553
-
}
554
-
555
-
resp, err := client.Do(req)
556
-
if err != nil {
557
-
return "", fmt.Errorf("failed to fetch URL: %w", err)
558
-
}
559
-
defer resp.Body.Close()
560
-
561
-
if resp.StatusCode != http.StatusOK {
562
-
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
563
-
}
564
-
565
-
// Limit content size to 100KB (repo page description has 100KB limit in lexicon)
566
-
limitedReader := io.LimitReader(resp.Body, 100*1024)
567
-
content, err := io.ReadAll(limitedReader)
568
-
if err != nil {
569
-
return "", fmt.Errorf("failed to read response body: %w", err)
570
-
}
571
-
572
-
return string(content), nil
573
-
}
574
-
575
-
// fetchAndUploadIcon fetches an image from a URL and uploads it as a blob to the user's PDS
576
-
// Returns the blob reference for use in the repo page record, or nil on error
577
-
func (s *ManifestStore) fetchAndUploadIcon(ctx context.Context, iconURL string) *atproto.ATProtoBlobRef {
578
-
// Create a context with timeout for icon fetching
579
-
fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
580
-
defer cancel()
581
-
582
-
// Fetch the icon
583
-
req, err := http.NewRequestWithContext(fetchCtx, "GET", iconURL, nil)
584
-
if err != nil {
585
-
slog.Debug("Failed to create icon request", "url", iconURL, "error", err)
586
-
return nil
587
-
}
588
-
589
-
req.Header.Set("User-Agent", "ATCR-Icon-Fetcher/1.0")
590
-
591
-
client := &http.Client{
592
-
Timeout: 10 * time.Second,
593
-
CheckRedirect: func(req *http.Request, via []*http.Request) error {
594
-
if len(via) >= 5 {
595
-
return fmt.Errorf("too many redirects")
596
-
}
597
-
return nil
598
-
},
599
-
}
600
-
601
-
resp, err := client.Do(req)
602
-
if err != nil {
603
-
slog.Debug("Failed to fetch icon", "url", iconURL, "error", err)
604
-
return nil
605
-
}
606
-
defer resp.Body.Close()
607
-
608
-
if resp.StatusCode != http.StatusOK {
609
-
slog.Debug("Icon fetch returned non-OK status", "url", iconURL, "status", resp.StatusCode)
610
-
return nil
611
-
}
612
-
613
-
// Validate content type - only allow images
614
-
contentType := resp.Header.Get("Content-Type")
615
-
mimeType := detectImageMimeType(contentType, iconURL)
616
-
if mimeType == "" {
617
-
slog.Debug("Icon has unsupported content type", "url", iconURL, "contentType", contentType)
618
-
return nil
619
-
}
620
-
621
-
// Limit icon size to 3MB (matching lexicon maxSize)
622
-
limitedReader := io.LimitReader(resp.Body, 3*1024*1024)
623
-
iconData, err := io.ReadAll(limitedReader)
624
-
if err != nil {
625
-
slog.Debug("Failed to read icon data", "url", iconURL, "error", err)
626
-
return nil
627
-
}
628
-
629
-
if len(iconData) == 0 {
630
-
slog.Debug("Icon data is empty", "url", iconURL)
631
-
return nil
632
-
}
633
-
634
-
// Upload the icon as a blob to the user's PDS
635
-
blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, iconData, mimeType)
636
-
if err != nil {
637
-
slog.Warn("Failed to upload icon blob", "url", iconURL, "error", err)
638
-
return nil
639
-
}
640
-
641
-
slog.Info("Uploaded icon blob", "url", iconURL, "size", len(iconData), "mimeType", mimeType, "cid", blobRef.Ref.Link)
642
-
return blobRef
643
-
}
644
-
645
-
// detectImageMimeType determines the MIME type for an image
646
-
// Uses Content-Type header first, then falls back to extension-based detection
647
-
// Only allows types accepted by the lexicon: image/png, image/jpeg, image/webp
648
-
func detectImageMimeType(contentType, url string) string {
649
-
// Check Content-Type header first
650
-
switch {
651
-
case strings.HasPrefix(contentType, "image/png"):
652
-
return "image/png"
653
-
case strings.HasPrefix(contentType, "image/jpeg"):
654
-
return "image/jpeg"
655
-
case strings.HasPrefix(contentType, "image/webp"):
656
-
return "image/webp"
657
-
}
658
-
659
-
// Fall back to URL extension detection
660
-
lowerURL := strings.ToLower(url)
661
-
switch {
662
-
case strings.HasSuffix(lowerURL, ".png"):
663
-
return "image/png"
664
-
case strings.HasSuffix(lowerURL, ".jpg"), strings.HasSuffix(lowerURL, ".jpeg"):
665
-
return "image/jpeg"
666
-
case strings.HasSuffix(lowerURL, ".webp"):
667
-
return "image/webp"
668
-
}
669
-
670
-
// Unknown or unsupported type - reject
671
-
return ""
425
+
// TODO: Re-enable once lexicon supports annotations as map[string]string
426
+
// The generated Manifest_Annotations is an empty struct that doesn't support map access.
427
+
// For now, README cache refresh on push is disabled.
428
+
_ = manifestRecord // silence unused variable warning
672
429
}
+31
-25
pkg/appview/storage/manifest_store_test.go
+31
-25
pkg/appview/storage/manifest_store_test.go
···
171
171
store := NewManifestStore(ctx, nil)
172
172
173
173
// Simulate what happens in Get() when parsing a manifest record
174
-
var manifestRecord atproto.ManifestRecord
175
-
manifestRecord.HoldDID = tt.manifestHoldDID
176
-
manifestRecord.HoldEndpoint = tt.manifestHoldURL
174
+
var manifestRecord atproto.Manifest
175
+
if tt.manifestHoldDID != "" {
176
+
manifestRecord.HoldDid = &tt.manifestHoldDID
177
+
}
178
+
if tt.manifestHoldURL != "" {
179
+
manifestRecord.HoldEndpoint = &tt.manifestHoldURL
180
+
}
177
181
178
182
// Mimic the hold DID extraction logic from Get()
179
-
if manifestRecord.HoldDID != "" {
180
-
store.lastFetchedHoldDID = manifestRecord.HoldDID
181
-
} else if manifestRecord.HoldEndpoint != "" {
182
-
store.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
183
+
if manifestRecord.HoldDid != nil && *manifestRecord.HoldDid != "" {
184
+
store.lastFetchedHoldDID = *manifestRecord.HoldDid
185
+
} else if manifestRecord.HoldEndpoint != nil && *manifestRecord.HoldEndpoint != "" {
186
+
store.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(*manifestRecord.HoldEndpoint)
183
187
}
184
188
185
189
got := store.GetLastFetchedHoldDID()
···
368
372
name: "manifest exists",
369
373
digest: "sha256:abc123",
370
374
serverStatus: http.StatusOK,
371
-
serverResp: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{}}`,
375
+
serverResp: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`,
372
376
wantExists: true,
373
377
wantErr: false,
374
378
},
···
433
437
digest: "sha256:abc123",
434
438
serverResp: `{
435
439
"uri":"at://did:plc:test123/io.atcr.manifest/abc123",
436
-
"cid":"bafytest",
440
+
"cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku",
437
441
"value":{
438
442
"$type":"io.atcr.manifest",
439
443
"repository":"myapp",
···
443
447
"mediaType":"application/vnd.oci.image.manifest.v1+json",
444
448
"manifestBlob":{
445
449
"$type":"blob",
446
-
"ref":{"$link":"bafytest"},
450
+
"ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},
447
451
"mimeType":"application/vnd.oci.image.manifest.v1+json",
448
452
"size":100
449
453
}
···
477
481
"holdEndpoint":"https://hold02.atcr.io",
478
482
"mediaType":"application/vnd.oci.image.manifest.v1+json",
479
483
"manifestBlob":{
480
-
"ref":{"$link":"bafylegacy"},
484
+
"$type":"blob",
485
+
"ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},
486
+
"mimeType":"application/json",
481
487
"size":100
482
488
}
483
489
}
···
559
565
"holdDid":"did:web:hold01.atcr.io",
560
566
"holdEndpoint":"https://hold01.atcr.io",
561
567
"mediaType":"application/vnd.oci.image.manifest.v1+json",
562
-
"manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
568
+
"manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}
563
569
}
564
570
}`,
565
571
expectedHoldDID: "did:web:hold01.atcr.io",
···
572
578
"$type":"io.atcr.manifest",
573
579
"holdEndpoint":"https://hold02.atcr.io",
574
580
"mediaType":"application/vnd.oci.image.manifest.v1+json",
575
-
"manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
581
+
"manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}
576
582
}
577
583
}`,
578
584
expectedHoldDID: "did:web:hold02.atcr.io",
···
646
652
"$type":"io.atcr.manifest",
647
653
"holdDid":"did:web:hold01.atcr.io",
648
654
"mediaType":"application/vnd.oci.image.manifest.v1+json",
649
-
"manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
655
+
"manifestBlob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}
650
656
}
651
657
}`))
652
658
}))
···
754
760
// Handle uploadBlob
755
761
if r.URL.Path == atproto.RepoUploadBlob {
756
762
w.WriteHeader(http.StatusOK)
757
-
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"mimeType":"application/json","size":100}}`))
763
+
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}}`))
758
764
return
759
765
}
760
766
···
763
769
json.NewDecoder(r.Body).Decode(&lastBody)
764
770
w.WriteHeader(tt.serverStatus)
765
771
if tt.serverStatus == http.StatusOK {
766
-
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest"}`))
772
+
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
767
773
} else {
768
774
w.Write([]byte(`{"error":"ServerError"}`))
769
775
}
···
815
821
816
822
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
817
823
if r.URL.Path == atproto.RepoUploadBlob {
818
-
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"size":100}}`))
824
+
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"size":100}}`))
819
825
return
820
826
}
821
827
if r.URL.Path == atproto.RepoPutRecord {
822
-
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/config123","cid":"bafytest"}`))
828
+
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/config123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
823
829
return
824
830
}
825
831
w.WriteHeader(http.StatusOK)
···
870
876
name: "successful delete",
871
877
digest: "sha256:abc123",
872
878
serverStatus: http.StatusOK,
873
-
serverResp: `{"commit":{"cid":"bafytest","rev":"12345"}}`,
879
+
serverResp: `{"commit":{"cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","rev":"12345"}}`,
874
880
wantErr: false,
875
881
},
876
882
{
···
1027
1033
// Handle uploadBlob
1028
1034
if r.URL.Path == atproto.RepoUploadBlob {
1029
1035
w.WriteHeader(http.StatusOK)
1030
-
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"mimeType":"application/json","size":100}}`))
1036
+
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"mimeType":"application/json","size":100}}`))
1031
1037
return
1032
1038
}
1033
1039
···
1039
1045
// If child should exist, return it; otherwise return RecordNotFound
1040
1046
if tt.childExists || rkey == childDigest.Encoded() {
1041
1047
w.WriteHeader(http.StatusOK)
1042
-
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafytest","value":{}}`))
1048
+
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`))
1043
1049
} else {
1044
1050
w.WriteHeader(http.StatusBadRequest)
1045
1051
w.Write([]byte(`{"error":"RecordNotFound","message":"Record not found"}`))
···
1050
1056
// Handle putRecord
1051
1057
if r.URL.Path == atproto.RepoPutRecord {
1052
1058
w.WriteHeader(http.StatusOK)
1053
-
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafytest"}`))
1059
+
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
1054
1060
return
1055
1061
}
1056
1062
···
1111
1117
1112
1118
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1113
1119
if r.URL.Path == atproto.RepoUploadBlob {
1114
-
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafytest"},"size":100}}`))
1120
+
w.Write([]byte(`{"blob":{"$type":"blob","ref":{"$link":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},"size":100}}`))
1115
1121
return
1116
1122
}
1117
1123
1118
1124
if r.URL.Path == atproto.RepoGetRecord {
1119
1125
rkey := r.URL.Query().Get("rkey")
1120
1126
if existingManifests[rkey] {
1121
-
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafytest","value":{}}`))
1127
+
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/` + rkey + `","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku","value":{}}`))
1122
1128
} else {
1123
1129
w.WriteHeader(http.StatusBadRequest)
1124
1130
w.Write([]byte(`{"error":"RecordNotFound"}`))
···
1127
1133
}
1128
1134
1129
1135
if r.URL.Path == atproto.RepoPutRecord {
1130
-
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafytest"}`))
1136
+
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.manifest/test123","cid":"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}`))
1131
1137
return
1132
1138
}
1133
1139
+12
-10
pkg/appview/storage/profile.go
+12
-10
pkg/appview/storage/profile.go
···
54
54
// GetProfile retrieves the user's profile from their PDS
55
55
// Returns nil if profile doesn't exist
56
56
// Automatically migrates old URL-based defaultHold values to DIDs
57
-
func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfileRecord, error) {
57
+
func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfile, error) {
58
58
record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey)
59
59
if err != nil {
60
60
// Check if it's a 404 (profile doesn't exist)
···
65
65
}
66
66
67
67
// Parse the profile record
68
-
var profile atproto.SailorProfileRecord
68
+
var profile atproto.SailorProfile
69
69
if err := json.Unmarshal(record.Value, &profile); err != nil {
70
70
return nil, fmt.Errorf("failed to parse profile: %w", err)
71
71
}
72
72
73
73
// Migrate old URL-based defaultHold to DID format
74
74
// This ensures backward compatibility with profiles created before DID migration
75
-
if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
75
+
if profile.DefaultHold != nil && *profile.DefaultHold != "" && !atproto.IsDID(*profile.DefaultHold) {
76
76
// Convert URL to DID transparently
77
-
migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
78
-
profile.DefaultHold = migratedDID
77
+
migratedDID := atproto.ResolveHoldDIDFromURL(*profile.DefaultHold)
78
+
profile.DefaultHold = &migratedDID
79
79
80
80
// Persist the migration to PDS in a background goroutine
81
81
// Use a lock to ensure only one goroutine migrates this DID
···
94
94
defer cancel()
95
95
96
96
// Update the profile on the PDS
97
-
profile.UpdatedAt = time.Now()
97
+
now := time.Now().Format(time.RFC3339)
98
+
profile.UpdatedAt = &now
98
99
if err := UpdateProfile(ctx, client, &profile); err != nil {
99
100
slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err)
100
101
} else {
···
109
110
110
111
// UpdateProfile updates the user's profile
111
112
// Normalizes defaultHold to DID format before saving
112
-
func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfileRecord) error {
113
+
func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfile) error {
113
114
// Normalize defaultHold to DID if it's a URL
114
115
// This ensures we always store DIDs, even if user provides a URL
115
-
if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
116
-
profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
117
-
slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold)
116
+
if profile.DefaultHold != nil && *profile.DefaultHold != "" && !atproto.IsDID(*profile.DefaultHold) {
117
+
normalized := atproto.ResolveHoldDIDFromURL(*profile.DefaultHold)
118
+
profile.DefaultHold = &normalized
119
+
slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", normalized)
118
120
}
119
121
120
122
_, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile)
+46
-40
pkg/appview/storage/profile_test.go
+46
-40
pkg/appview/storage/profile_test.go
···
39
39
40
40
for _, tt := range tests {
41
41
t.Run(tt.name, func(t *testing.T) {
42
-
var createdProfile *atproto.SailorProfileRecord
42
+
var createdProfile *atproto.SailorProfile
43
43
44
44
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45
45
// First request: GetRecord (should 404)
···
95
95
t.Fatal("Profile was not created")
96
96
}
97
97
98
-
if createdProfile.Type != atproto.SailorProfileCollection {
99
-
t.Errorf("Type = %v, want %v", createdProfile.Type, atproto.SailorProfileCollection)
98
+
if createdProfile.LexiconTypeID != atproto.SailorProfileCollection {
99
+
t.Errorf("LexiconTypeID = %v, want %v", createdProfile.LexiconTypeID, atproto.SailorProfileCollection)
100
100
}
101
101
102
-
if createdProfile.DefaultHold != tt.wantNormalized {
103
-
t.Errorf("DefaultHold = %v, want %v", createdProfile.DefaultHold, tt.wantNormalized)
102
+
gotDefaultHold := ""
103
+
if createdProfile.DefaultHold != nil {
104
+
gotDefaultHold = *createdProfile.DefaultHold
105
+
}
106
+
if gotDefaultHold != tt.wantNormalized {
107
+
t.Errorf("DefaultHold = %v, want %v", gotDefaultHold, tt.wantNormalized)
104
108
}
105
109
})
106
110
}
···
154
158
name string
155
159
serverResponse string
156
160
serverStatus int
157
-
wantProfile *atproto.SailorProfileRecord
161
+
wantProfile *atproto.SailorProfile
158
162
wantNil bool
159
163
wantErr bool
160
164
expectMigration bool // Whether URL-to-DID migration should happen
···
265
269
}
266
270
267
271
// Check that defaultHold is migrated to DID in returned profile
268
-
if profile.DefaultHold != tt.expectedHoldDID {
269
-
t.Errorf("DefaultHold = %v, want %v", profile.DefaultHold, tt.expectedHoldDID)
272
+
gotDefaultHold := ""
273
+
if profile.DefaultHold != nil {
274
+
gotDefaultHold = *profile.DefaultHold
275
+
}
276
+
if gotDefaultHold != tt.expectedHoldDID {
277
+
t.Errorf("DefaultHold = %v, want %v", gotDefaultHold, tt.expectedHoldDID)
270
278
}
271
279
272
280
if tt.expectMigration {
···
366
374
}
367
375
}
368
376
377
+
// testSailorProfile creates a test profile with the given default hold
378
+
func testSailorProfile(defaultHold string) *atproto.SailorProfile {
379
+
now := time.Now().Format(time.RFC3339)
380
+
profile := &atproto.SailorProfile{
381
+
LexiconTypeID: atproto.SailorProfileCollection,
382
+
CreatedAt: now,
383
+
UpdatedAt: &now,
384
+
}
385
+
if defaultHold != "" {
386
+
profile.DefaultHold = &defaultHold
387
+
}
388
+
return profile
389
+
}
390
+
369
391
// TestUpdateProfile tests updating a user's profile
370
392
func TestUpdateProfile(t *testing.T) {
371
393
tests := []struct {
372
394
name string
373
-
profile *atproto.SailorProfileRecord
395
+
profile *atproto.SailorProfile
374
396
wantNormalized string // Expected defaultHold after normalization
375
397
wantErr bool
376
398
}{
377
399
{
378
-
name: "update with DID",
379
-
profile: &atproto.SailorProfileRecord{
380
-
Type: atproto.SailorProfileCollection,
381
-
DefaultHold: "did:web:hold02.atcr.io",
382
-
CreatedAt: time.Now(),
383
-
UpdatedAt: time.Now(),
384
-
},
400
+
name: "update with DID",
401
+
profile: testSailorProfile("did:web:hold02.atcr.io"),
385
402
wantNormalized: "did:web:hold02.atcr.io",
386
403
wantErr: false,
387
404
},
388
405
{
389
-
name: "update with URL - should normalize",
390
-
profile: &atproto.SailorProfileRecord{
391
-
Type: atproto.SailorProfileCollection,
392
-
DefaultHold: "https://hold02.atcr.io",
393
-
CreatedAt: time.Now(),
394
-
UpdatedAt: time.Now(),
395
-
},
406
+
name: "update with URL - should normalize",
407
+
profile: testSailorProfile("https://hold02.atcr.io"),
396
408
wantNormalized: "did:web:hold02.atcr.io",
397
409
wantErr: false,
398
410
},
399
411
{
400
-
name: "clear default hold",
401
-
profile: &atproto.SailorProfileRecord{
402
-
Type: atproto.SailorProfileCollection,
403
-
DefaultHold: "",
404
-
CreatedAt: time.Now(),
405
-
UpdatedAt: time.Now(),
406
-
},
412
+
name: "clear default hold",
413
+
profile: testSailorProfile(""),
407
414
wantNormalized: "",
408
415
wantErr: false,
409
416
},
···
454
461
}
455
462
456
463
// Verify normalization also updated the profile object
457
-
if tt.profile.DefaultHold != tt.wantNormalized {
458
-
t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", tt.profile.DefaultHold, tt.wantNormalized)
464
+
gotProfileHold := ""
465
+
if tt.profile.DefaultHold != nil {
466
+
gotProfileHold = *tt.profile.DefaultHold
467
+
}
468
+
if gotProfileHold != tt.wantNormalized {
469
+
t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", gotProfileHold, tt.wantNormalized)
459
470
}
460
471
}
461
472
})
···
539
550
t.Fatalf("GetProfile() error = %v", err)
540
551
}
541
552
542
-
if profile.DefaultHold != "" {
543
-
t.Errorf("DefaultHold = %v, want empty string", profile.DefaultHold)
553
+
if profile.DefaultHold != nil && *profile.DefaultHold != "" {
554
+
t.Errorf("DefaultHold = %v, want empty or nil", profile.DefaultHold)
544
555
}
545
556
}
546
557
···
553
564
defer server.Close()
554
565
555
566
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
556
-
profile := &atproto.SailorProfileRecord{
557
-
Type: atproto.SailorProfileCollection,
558
-
DefaultHold: "did:web:hold01.atcr.io",
559
-
CreatedAt: time.Now(),
560
-
UpdatedAt: time.Now(),
561
-
}
567
+
profile := testSailorProfile("did:web:hold01.atcr.io")
562
568
563
569
err := UpdateProfile(context.Background(), client, profile)
564
570
+3
-3
pkg/appview/storage/tag_store.go
+3
-3
pkg/appview/storage/tag_store.go
···
36
36
return distribution.Descriptor{}, distribution.ErrTagUnknown{Tag: tag}
37
37
}
38
38
39
-
var tagRecord atproto.TagRecord
39
+
var tagRecord atproto.Tag
40
40
if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
41
41
return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err)
42
42
}
···
91
91
92
92
var tags []string
93
93
for _, record := range records {
94
-
var tagRecord atproto.TagRecord
94
+
var tagRecord atproto.Tag
95
95
if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
96
96
// Skip invalid records
97
97
continue
···
116
116
117
117
var tags []string
118
118
for _, record := range records {
119
-
var tagRecord atproto.TagRecord
119
+
var tagRecord atproto.Tag
120
120
if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
121
121
// Skip invalid records
122
122
continue
+6
-6
pkg/appview/storage/tag_store_test.go
+6
-6
pkg/appview/storage/tag_store_test.go
···
229
229
230
230
for _, tt := range tests {
231
231
t.Run(tt.name, func(t *testing.T) {
232
-
var sentTagRecord *atproto.TagRecord
232
+
var sentTagRecord *atproto.Tag
233
233
234
234
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
235
235
if r.Method != "POST" {
···
254
254
// Parse and verify tag record
255
255
recordData := body["record"].(map[string]any)
256
256
recordBytes, _ := json.Marshal(recordData)
257
-
var tagRecord atproto.TagRecord
257
+
var tagRecord atproto.Tag
258
258
json.Unmarshal(recordBytes, &tagRecord)
259
259
sentTagRecord = &tagRecord
260
260
···
284
284
285
285
if !tt.wantErr && sentTagRecord != nil {
286
286
// Verify the tag record
287
-
if sentTagRecord.Type != atproto.TagCollection {
288
-
t.Errorf("Type = %v, want %v", sentTagRecord.Type, atproto.TagCollection)
287
+
if sentTagRecord.LexiconTypeID != atproto.TagCollection {
288
+
t.Errorf("LexiconTypeID = %v, want %v", sentTagRecord.LexiconTypeID, atproto.TagCollection)
289
289
}
290
290
if sentTagRecord.Repository != "myapp" {
291
291
t.Errorf("Repository = %v, want myapp", sentTagRecord.Repository)
···
295
295
}
296
296
// New records should have manifest field
297
297
expectedURI := atproto.BuildManifestURI("did:plc:test123", tt.digest.String())
298
-
if sentTagRecord.Manifest != expectedURI {
298
+
if sentTagRecord.Manifest == nil || *sentTagRecord.Manifest != expectedURI {
299
299
t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI)
300
300
}
301
301
// New records should NOT have manifestDigest field
302
-
if sentTagRecord.ManifestDigest != "" {
302
+
if sentTagRecord.ManifestDigest != nil && *sentTagRecord.ManifestDigest != "" {
303
303
t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest)
304
304
}
305
305
}
-22
pkg/appview/templates/pages/404.html
-22
pkg/appview/templates/pages/404.html
···
1
-
{{ define "404" }}
2
-
<!DOCTYPE html>
3
-
<html lang="en">
4
-
<head>
5
-
<title>404 - Lost at Sea | ATCR</title>
6
-
{{ template "head" . }}
7
-
</head>
8
-
<body>
9
-
{{ template "nav-simple" . }}
10
-
<main class="error-page">
11
-
<div class="error-content">
12
-
<i data-lucide="anchor" class="error-icon"></i>
13
-
<div class="error-code">404</div>
14
-
<h1>Lost at Sea</h1>
15
-
<p>The page you're looking for has drifted into uncharted waters.</p>
16
-
<a href="/" class="btn btn-primary">Return to Port</a>
17
-
</div>
18
-
</main>
19
-
<script>lucide.createIcons();</script>
20
-
</body>
21
-
</html>
22
-
{{ end }}
+5
-17
pkg/appview/templates/pages/repository.html
+5
-17
pkg/appview/templates/pages/repository.html
···
27
27
<!-- Repository Header -->
28
28
<div class="repository-header">
29
29
<div class="repo-hero">
30
-
<div class="repo-hero-icon-wrapper">
31
-
{{ if .Repository.IconURL }}
32
-
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon">
33
-
{{ else }}
34
-
<div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div>
35
-
{{ end }}
36
-
{{ if $.IsOwner }}
37
-
<label class="avatar-upload-overlay" for="avatar-upload">
38
-
<i data-lucide="plus"></i>
39
-
</label>
40
-
<input type="file" id="avatar-upload" accept="image/png,image/jpeg,image/webp"
41
-
onchange="uploadAvatar(this, '{{ .Repository.Name }}')" hidden>
42
-
{{ end }}
43
-
</div>
30
+
{{ if .Repository.IconURL }}
31
+
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon">
32
+
{{ else }}
33
+
<div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div>
34
+
{{ end }}
44
35
<div class="repo-hero-info">
45
36
<h1>
46
37
<a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a>
···
138
129
<span class="tag-name-large">{{ .Tag.Tag }}</span>
139
130
{{ if .IsMultiArch }}
140
131
<span class="badge-multi">Multi-arch</span>
141
-
{{ end }}
142
-
{{ if .HasAttestations }}
143
-
<span class="badge-attestation"><i data-lucide="shield-check"></i> Attestations</span>
144
132
{{ end }}
145
133
</div>
146
134
<div style="display: flex; gap: 1rem; align-items: center;">
+9
pkg/appview/templates/partials/push-list.html
+9
pkg/appview/templates/partials/push-list.html
···
44
44
</div>
45
45
{{ end }}
46
46
47
+
{{ if .HasMore }}
48
+
<button class="load-more"
49
+
hx-get="/api/recent-pushes?offset={{ .NextOffset }}"
50
+
hx-target="#push-list"
51
+
hx-swap="beforeend">
52
+
Load More
53
+
</button>
54
+
{{ end }}
55
+
47
56
{{ if eq (len .Pushes) 0 }}
48
57
<div class="empty-state">
49
58
<p>No pushes yet. Start using ATCR by pushing your first image!</p>
+2958
-126
pkg/atproto/cbor_gen.go
+2958
-126
pkg/atproto/cbor_gen.go
···
8
8
"math"
9
9
"sort"
10
10
11
+
util "github.com/bluesky-social/indigo/lex/util"
11
12
cid "github.com/ipfs/go-cid"
12
13
cbg "github.com/whyrusleeping/cbor-gen"
13
14
xerrors "golang.org/x/xerrors"
···
18
19
var _ = math.E
19
20
var _ = sort.Sort
20
21
21
-
func (t *CrewRecord) MarshalCBOR(w io.Writer) error {
22
+
func (t *Manifest) MarshalCBOR(w io.Writer) error {
22
23
if t == nil {
23
24
_, err := w.Write(cbg.CborNull)
24
25
return err
25
26
}
26
27
27
28
cw := cbg.NewCborWriter(w)
29
+
fieldCount := 14
28
30
29
-
if _, err := cw.Write([]byte{165}); err != nil {
30
-
return err
31
+
if t.Annotations == nil {
32
+
fieldCount--
33
+
}
34
+
35
+
if t.Config == nil {
36
+
fieldCount--
37
+
}
38
+
39
+
if t.HoldDid == nil {
40
+
fieldCount--
31
41
}
32
42
33
-
// t.Role (string) (string)
34
-
if len("role") > 8192 {
35
-
return xerrors.Errorf("Value in field \"role\" was too long")
43
+
if t.HoldEndpoint == nil {
44
+
fieldCount--
36
45
}
37
46
38
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("role"))); err != nil {
39
-
return err
47
+
if t.Layers == nil {
48
+
fieldCount--
40
49
}
41
-
if _, err := cw.WriteString(string("role")); err != nil {
42
-
return err
50
+
51
+
if t.ManifestBlob == nil {
52
+
fieldCount--
43
53
}
44
54
45
-
if len(t.Role) > 8192 {
46
-
return xerrors.Errorf("Value in field t.Role was too long")
55
+
if t.Manifests == nil {
56
+
fieldCount--
47
57
}
48
58
49
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Role))); err != nil {
50
-
return err
59
+
if t.Subject == nil {
60
+
fieldCount--
51
61
}
52
-
if _, err := cw.WriteString(string(t.Role)); err != nil {
62
+
63
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
53
64
return err
54
65
}
55
66
56
-
// t.Type (string) (string)
67
+
// t.LexiconTypeID (string) (string)
57
68
if len("$type") > 8192 {
58
69
return xerrors.Errorf("Value in field \"$type\" was too long")
59
70
}
···
65
76
return err
66
77
}
67
78
68
-
if len(t.Type) > 8192 {
69
-
return xerrors.Errorf("Value in field t.Type was too long")
79
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest"))); err != nil {
80
+
return err
81
+
}
82
+
if _, err := cw.WriteString(string("io.atcr.manifest")); err != nil {
83
+
return err
84
+
}
85
+
86
+
// t.Config (atproto.Manifest_BlobReference) (struct)
87
+
if t.Config != nil {
88
+
89
+
if len("config") > 8192 {
90
+
return xerrors.Errorf("Value in field \"config\" was too long")
91
+
}
92
+
93
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("config"))); err != nil {
94
+
return err
95
+
}
96
+
if _, err := cw.WriteString(string("config")); err != nil {
97
+
return err
98
+
}
99
+
100
+
if err := t.Config.MarshalCBOR(cw); err != nil {
101
+
return err
102
+
}
70
103
}
71
104
72
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
105
+
// t.Digest (string) (string)
106
+
if len("digest") > 8192 {
107
+
return xerrors.Errorf("Value in field \"digest\" was too long")
108
+
}
109
+
110
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil {
73
111
return err
74
112
}
75
-
if _, err := cw.WriteString(string(t.Type)); err != nil {
113
+
if _, err := cw.WriteString(string("digest")); err != nil {
76
114
return err
77
115
}
78
116
79
-
// t.Member (string) (string)
80
-
if len("member") > 8192 {
81
-
return xerrors.Errorf("Value in field \"member\" was too long")
117
+
if len(t.Digest) > 8192 {
118
+
return xerrors.Errorf("Value in field t.Digest was too long")
82
119
}
83
120
84
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil {
121
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil {
85
122
return err
86
123
}
87
-
if _, err := cw.WriteString(string("member")); err != nil {
124
+
if _, err := cw.WriteString(string(t.Digest)); err != nil {
88
125
return err
89
126
}
90
127
91
-
if len(t.Member) > 8192 {
92
-
return xerrors.Errorf("Value in field t.Member was too long")
128
+
// t.Layers ([]atproto.Manifest_BlobReference) (slice)
129
+
if t.Layers != nil {
130
+
131
+
if len("layers") > 8192 {
132
+
return xerrors.Errorf("Value in field \"layers\" was too long")
133
+
}
134
+
135
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("layers"))); err != nil {
136
+
return err
137
+
}
138
+
if _, err := cw.WriteString(string("layers")); err != nil {
139
+
return err
140
+
}
141
+
142
+
if len(t.Layers) > 8192 {
143
+
return xerrors.Errorf("Slice value in field t.Layers was too long")
144
+
}
145
+
146
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Layers))); err != nil {
147
+
return err
148
+
}
149
+
for _, v := range t.Layers {
150
+
if err := v.MarshalCBOR(cw); err != nil {
151
+
return err
152
+
}
153
+
154
+
}
93
155
}
94
156
95
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil {
157
+
// t.HoldDid (string) (string)
158
+
if t.HoldDid != nil {
159
+
160
+
if len("holdDid") > 8192 {
161
+
return xerrors.Errorf("Value in field \"holdDid\" was too long")
162
+
}
163
+
164
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("holdDid"))); err != nil {
165
+
return err
166
+
}
167
+
if _, err := cw.WriteString(string("holdDid")); err != nil {
168
+
return err
169
+
}
170
+
171
+
if t.HoldDid == nil {
172
+
if _, err := cw.Write(cbg.CborNull); err != nil {
173
+
return err
174
+
}
175
+
} else {
176
+
if len(*t.HoldDid) > 8192 {
177
+
return xerrors.Errorf("Value in field t.HoldDid was too long")
178
+
}
179
+
180
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.HoldDid))); err != nil {
181
+
return err
182
+
}
183
+
if _, err := cw.WriteString(string(*t.HoldDid)); err != nil {
184
+
return err
185
+
}
186
+
}
187
+
}
188
+
189
+
// t.Subject (atproto.Manifest_BlobReference) (struct)
190
+
if t.Subject != nil {
191
+
192
+
if len("subject") > 8192 {
193
+
return xerrors.Errorf("Value in field \"subject\" was too long")
194
+
}
195
+
196
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
197
+
return err
198
+
}
199
+
if _, err := cw.WriteString(string("subject")); err != nil {
200
+
return err
201
+
}
202
+
203
+
if err := t.Subject.MarshalCBOR(cw); err != nil {
204
+
return err
205
+
}
206
+
}
207
+
208
+
// t.CreatedAt (string) (string)
209
+
if len("createdAt") > 8192 {
210
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
211
+
}
212
+
213
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
96
214
return err
97
215
}
98
-
if _, err := cw.WriteString(string(t.Member)); err != nil {
216
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
99
217
return err
100
218
}
101
219
102
-
// t.AddedAt (string) (string)
103
-
if len("addedAt") > 8192 {
104
-
return xerrors.Errorf("Value in field \"addedAt\" was too long")
220
+
if len(t.CreatedAt) > 8192 {
221
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
105
222
}
106
223
107
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil {
224
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
108
225
return err
109
226
}
110
-
if _, err := cw.WriteString(string("addedAt")); err != nil {
227
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
111
228
return err
112
229
}
113
230
114
-
if len(t.AddedAt) > 8192 {
115
-
return xerrors.Errorf("Value in field t.AddedAt was too long")
231
+
// t.Manifests ([]atproto.Manifest_ManifestReference) (slice)
232
+
if t.Manifests != nil {
233
+
234
+
if len("manifests") > 8192 {
235
+
return xerrors.Errorf("Value in field \"manifests\" was too long")
236
+
}
237
+
238
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifests"))); err != nil {
239
+
return err
240
+
}
241
+
if _, err := cw.WriteString(string("manifests")); err != nil {
242
+
return err
243
+
}
244
+
245
+
if len(t.Manifests) > 8192 {
246
+
return xerrors.Errorf("Slice value in field t.Manifests was too long")
247
+
}
248
+
249
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Manifests))); err != nil {
250
+
return err
251
+
}
252
+
for _, v := range t.Manifests {
253
+
if err := v.MarshalCBOR(cw); err != nil {
254
+
return err
255
+
}
256
+
257
+
}
116
258
}
117
259
118
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.AddedAt))); err != nil {
260
+
// t.MediaType (string) (string)
261
+
if len("mediaType") > 8192 {
262
+
return xerrors.Errorf("Value in field \"mediaType\" was too long")
263
+
}
264
+
265
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil {
119
266
return err
120
267
}
121
-
if _, err := cw.WriteString(string(t.AddedAt)); err != nil {
268
+
if _, err := cw.WriteString(string("mediaType")); err != nil {
122
269
return err
123
270
}
124
271
125
-
// t.Permissions ([]string) (slice)
126
-
if len("permissions") > 8192 {
127
-
return xerrors.Errorf("Value in field \"permissions\" was too long")
272
+
if len(t.MediaType) > 8192 {
273
+
return xerrors.Errorf("Value in field t.MediaType was too long")
128
274
}
129
275
130
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("permissions"))); err != nil {
276
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil {
277
+
return err
278
+
}
279
+
if _, err := cw.WriteString(string(t.MediaType)); err != nil {
280
+
return err
281
+
}
282
+
283
+
// t.Repository (string) (string)
284
+
if len("repository") > 8192 {
285
+
return xerrors.Errorf("Value in field \"repository\" was too long")
286
+
}
287
+
288
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
131
289
return err
132
290
}
133
-
if _, err := cw.WriteString(string("permissions")); err != nil {
291
+
if _, err := cw.WriteString(string("repository")); err != nil {
134
292
return err
135
293
}
136
294
137
-
if len(t.Permissions) > 8192 {
138
-
return xerrors.Errorf("Slice value in field t.Permissions was too long")
295
+
if len(t.Repository) > 8192 {
296
+
return xerrors.Errorf("Value in field t.Repository was too long")
139
297
}
140
298
141
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Permissions))); err != nil {
299
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
142
300
return err
143
301
}
144
-
for _, v := range t.Permissions {
145
-
if len(v) > 8192 {
146
-
return xerrors.Errorf("Value in field v was too long")
302
+
if _, err := cw.WriteString(string(t.Repository)); err != nil {
303
+
return err
304
+
}
305
+
306
+
// t.Annotations (atproto.Manifest_Annotations) (struct)
307
+
if t.Annotations != nil {
308
+
309
+
if len("annotations") > 8192 {
310
+
return xerrors.Errorf("Value in field \"annotations\" was too long")
311
+
}
312
+
313
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil {
314
+
return err
315
+
}
316
+
if _, err := cw.WriteString(string("annotations")); err != nil {
317
+
return err
318
+
}
319
+
320
+
if err := t.Annotations.MarshalCBOR(cw); err != nil {
321
+
return err
322
+
}
323
+
}
324
+
325
+
// t.HoldEndpoint (string) (string)
326
+
if t.HoldEndpoint != nil {
327
+
328
+
if len("holdEndpoint") > 8192 {
329
+
return xerrors.Errorf("Value in field \"holdEndpoint\" was too long")
330
+
}
331
+
332
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("holdEndpoint"))); err != nil {
333
+
return err
334
+
}
335
+
if _, err := cw.WriteString(string("holdEndpoint")); err != nil {
336
+
return err
337
+
}
338
+
339
+
if t.HoldEndpoint == nil {
340
+
if _, err := cw.Write(cbg.CborNull); err != nil {
341
+
return err
342
+
}
343
+
} else {
344
+
if len(*t.HoldEndpoint) > 8192 {
345
+
return xerrors.Errorf("Value in field t.HoldEndpoint was too long")
346
+
}
347
+
348
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.HoldEndpoint))); err != nil {
349
+
return err
350
+
}
351
+
if _, err := cw.WriteString(string(*t.HoldEndpoint)); err != nil {
352
+
return err
353
+
}
147
354
}
355
+
}
148
356
149
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
357
+
// t.ManifestBlob (util.LexBlob) (struct)
358
+
if t.ManifestBlob != nil {
359
+
360
+
if len("manifestBlob") > 8192 {
361
+
return xerrors.Errorf("Value in field \"manifestBlob\" was too long")
362
+
}
363
+
364
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifestBlob"))); err != nil {
150
365
return err
151
366
}
152
-
if _, err := cw.WriteString(string(v)); err != nil {
367
+
if _, err := cw.WriteString(string("manifestBlob")); err != nil {
153
368
return err
154
369
}
155
370
371
+
if err := t.ManifestBlob.MarshalCBOR(cw); err != nil {
372
+
return err
373
+
}
156
374
}
375
+
376
+
// t.SchemaVersion (int64) (int64)
377
+
if len("schemaVersion") > 8192 {
378
+
return xerrors.Errorf("Value in field \"schemaVersion\" was too long")
379
+
}
380
+
381
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("schemaVersion"))); err != nil {
382
+
return err
383
+
}
384
+
if _, err := cw.WriteString(string("schemaVersion")); err != nil {
385
+
return err
386
+
}
387
+
388
+
if t.SchemaVersion >= 0 {
389
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.SchemaVersion)); err != nil {
390
+
return err
391
+
}
392
+
} else {
393
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.SchemaVersion-1)); err != nil {
394
+
return err
395
+
}
396
+
}
397
+
157
398
return nil
158
399
}
159
400
160
-
func (t *CrewRecord) UnmarshalCBOR(r io.Reader) (err error) {
161
-
*t = CrewRecord{}
401
+
func (t *Manifest) UnmarshalCBOR(r io.Reader) (err error) {
402
+
*t = Manifest{}
162
403
163
404
cr := cbg.NewCborReader(r)
164
405
···
177
418
}
178
419
179
420
if extra > cbg.MaxLength {
180
-
return fmt.Errorf("CrewRecord: map struct too large (%d)", extra)
421
+
return fmt.Errorf("Manifest: map struct too large (%d)", extra)
422
+
}
423
+
424
+
n := extra
425
+
426
+
nameBuf := make([]byte, 13)
427
+
for i := uint64(0); i < n; i++ {
428
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
429
+
if err != nil {
430
+
return err
431
+
}
432
+
433
+
if !ok {
434
+
// Field doesn't exist on this type, so ignore it
435
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
436
+
return err
437
+
}
438
+
continue
439
+
}
440
+
441
+
switch string(nameBuf[:nameLen]) {
442
+
// t.LexiconTypeID (string) (string)
443
+
case "$type":
444
+
445
+
{
446
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
447
+
if err != nil {
448
+
return err
449
+
}
450
+
451
+
t.LexiconTypeID = string(sval)
452
+
}
453
+
// t.Config (atproto.Manifest_BlobReference) (struct)
454
+
case "config":
455
+
456
+
{
457
+
458
+
b, err := cr.ReadByte()
459
+
if err != nil {
460
+
return err
461
+
}
462
+
if b != cbg.CborNull[0] {
463
+
if err := cr.UnreadByte(); err != nil {
464
+
return err
465
+
}
466
+
t.Config = new(Manifest_BlobReference)
467
+
if err := t.Config.UnmarshalCBOR(cr); err != nil {
468
+
return xerrors.Errorf("unmarshaling t.Config pointer: %w", err)
469
+
}
470
+
}
471
+
472
+
}
473
+
// t.Digest (string) (string)
474
+
case "digest":
475
+
476
+
{
477
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
478
+
if err != nil {
479
+
return err
480
+
}
481
+
482
+
t.Digest = string(sval)
483
+
}
484
+
// t.Layers ([]atproto.Manifest_BlobReference) (slice)
485
+
case "layers":
486
+
487
+
maj, extra, err = cr.ReadHeader()
488
+
if err != nil {
489
+
return err
490
+
}
491
+
492
+
if extra > 8192 {
493
+
return fmt.Errorf("t.Layers: array too large (%d)", extra)
494
+
}
495
+
496
+
if maj != cbg.MajArray {
497
+
return fmt.Errorf("expected cbor array")
498
+
}
499
+
500
+
if extra > 0 {
501
+
t.Layers = make([]Manifest_BlobReference, extra)
502
+
}
503
+
504
+
for i := 0; i < int(extra); i++ {
505
+
{
506
+
var maj byte
507
+
var extra uint64
508
+
var err error
509
+
_ = maj
510
+
_ = extra
511
+
_ = err
512
+
513
+
{
514
+
515
+
if err := t.Layers[i].UnmarshalCBOR(cr); err != nil {
516
+
return xerrors.Errorf("unmarshaling t.Layers[i]: %w", err)
517
+
}
518
+
519
+
}
520
+
521
+
}
522
+
}
523
+
// t.HoldDid (string) (string)
524
+
case "holdDid":
525
+
526
+
{
527
+
b, err := cr.ReadByte()
528
+
if err != nil {
529
+
return err
530
+
}
531
+
if b != cbg.CborNull[0] {
532
+
if err := cr.UnreadByte(); err != nil {
533
+
return err
534
+
}
535
+
536
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
537
+
if err != nil {
538
+
return err
539
+
}
540
+
541
+
t.HoldDid = (*string)(&sval)
542
+
}
543
+
}
544
+
// t.Subject (atproto.Manifest_BlobReference) (struct)
545
+
case "subject":
546
+
547
+
{
548
+
549
+
b, err := cr.ReadByte()
550
+
if err != nil {
551
+
return err
552
+
}
553
+
if b != cbg.CborNull[0] {
554
+
if err := cr.UnreadByte(); err != nil {
555
+
return err
556
+
}
557
+
t.Subject = new(Manifest_BlobReference)
558
+
if err := t.Subject.UnmarshalCBOR(cr); err != nil {
559
+
return xerrors.Errorf("unmarshaling t.Subject pointer: %w", err)
560
+
}
561
+
}
562
+
563
+
}
564
+
// t.CreatedAt (string) (string)
565
+
case "createdAt":
566
+
567
+
{
568
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
569
+
if err != nil {
570
+
return err
571
+
}
572
+
573
+
t.CreatedAt = string(sval)
574
+
}
575
+
// t.Manifests ([]atproto.Manifest_ManifestReference) (slice)
576
+
case "manifests":
577
+
578
+
maj, extra, err = cr.ReadHeader()
579
+
if err != nil {
580
+
return err
581
+
}
582
+
583
+
if extra > 8192 {
584
+
return fmt.Errorf("t.Manifests: array too large (%d)", extra)
585
+
}
586
+
587
+
if maj != cbg.MajArray {
588
+
return fmt.Errorf("expected cbor array")
589
+
}
590
+
591
+
if extra > 0 {
592
+
t.Manifests = make([]Manifest_ManifestReference, extra)
593
+
}
594
+
595
+
for i := 0; i < int(extra); i++ {
596
+
{
597
+
var maj byte
598
+
var extra uint64
599
+
var err error
600
+
_ = maj
601
+
_ = extra
602
+
_ = err
603
+
604
+
{
605
+
606
+
if err := t.Manifests[i].UnmarshalCBOR(cr); err != nil {
607
+
return xerrors.Errorf("unmarshaling t.Manifests[i]: %w", err)
608
+
}
609
+
610
+
}
611
+
612
+
}
613
+
}
614
+
// t.MediaType (string) (string)
615
+
case "mediaType":
616
+
617
+
{
618
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
619
+
if err != nil {
620
+
return err
621
+
}
622
+
623
+
t.MediaType = string(sval)
624
+
}
625
+
// t.Repository (string) (string)
626
+
case "repository":
627
+
628
+
{
629
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
630
+
if err != nil {
631
+
return err
632
+
}
633
+
634
+
t.Repository = string(sval)
635
+
}
636
+
// t.Annotations (atproto.Manifest_Annotations) (struct)
637
+
case "annotations":
638
+
639
+
{
640
+
641
+
b, err := cr.ReadByte()
642
+
if err != nil {
643
+
return err
644
+
}
645
+
if b != cbg.CborNull[0] {
646
+
if err := cr.UnreadByte(); err != nil {
647
+
return err
648
+
}
649
+
t.Annotations = new(Manifest_Annotations)
650
+
if err := t.Annotations.UnmarshalCBOR(cr); err != nil {
651
+
return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err)
652
+
}
653
+
}
654
+
655
+
}
656
+
// t.HoldEndpoint (string) (string)
657
+
case "holdEndpoint":
658
+
659
+
{
660
+
b, err := cr.ReadByte()
661
+
if err != nil {
662
+
return err
663
+
}
664
+
if b != cbg.CborNull[0] {
665
+
if err := cr.UnreadByte(); err != nil {
666
+
return err
667
+
}
668
+
669
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
670
+
if err != nil {
671
+
return err
672
+
}
673
+
674
+
t.HoldEndpoint = (*string)(&sval)
675
+
}
676
+
}
677
+
// t.ManifestBlob (util.LexBlob) (struct)
678
+
case "manifestBlob":
679
+
680
+
{
681
+
682
+
b, err := cr.ReadByte()
683
+
if err != nil {
684
+
return err
685
+
}
686
+
if b != cbg.CborNull[0] {
687
+
if err := cr.UnreadByte(); err != nil {
688
+
return err
689
+
}
690
+
t.ManifestBlob = new(util.LexBlob)
691
+
if err := t.ManifestBlob.UnmarshalCBOR(cr); err != nil {
692
+
return xerrors.Errorf("unmarshaling t.ManifestBlob pointer: %w", err)
693
+
}
694
+
}
695
+
696
+
}
697
+
// t.SchemaVersion (int64) (int64)
698
+
case "schemaVersion":
699
+
{
700
+
maj, extra, err := cr.ReadHeader()
701
+
if err != nil {
702
+
return err
703
+
}
704
+
var extraI int64
705
+
switch maj {
706
+
case cbg.MajUnsignedInt:
707
+
extraI = int64(extra)
708
+
if extraI < 0 {
709
+
return fmt.Errorf("int64 positive overflow")
710
+
}
711
+
case cbg.MajNegativeInt:
712
+
extraI = int64(extra)
713
+
if extraI < 0 {
714
+
return fmt.Errorf("int64 negative overflow")
715
+
}
716
+
extraI = -1 - extraI
717
+
default:
718
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
719
+
}
720
+
721
+
t.SchemaVersion = int64(extraI)
722
+
}
723
+
724
+
default:
725
+
// Field doesn't exist on this type, so ignore it
726
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
727
+
return err
728
+
}
729
+
}
730
+
}
731
+
732
+
return nil
733
+
}
734
+
func (t *Manifest_BlobReference) MarshalCBOR(w io.Writer) error {
735
+
if t == nil {
736
+
_, err := w.Write(cbg.CborNull)
737
+
return err
738
+
}
739
+
740
+
cw := cbg.NewCborWriter(w)
741
+
fieldCount := 6
742
+
743
+
if t.LexiconTypeID == "" {
744
+
fieldCount--
745
+
}
746
+
747
+
if t.Annotations == nil {
748
+
fieldCount--
749
+
}
750
+
751
+
if t.Urls == nil {
752
+
fieldCount--
753
+
}
754
+
755
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
756
+
return err
757
+
}
758
+
759
+
// t.Size (int64) (int64)
760
+
if len("size") > 8192 {
761
+
return xerrors.Errorf("Value in field \"size\" was too long")
762
+
}
763
+
764
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
765
+
return err
766
+
}
767
+
if _, err := cw.WriteString(string("size")); err != nil {
768
+
return err
769
+
}
770
+
771
+
if t.Size >= 0 {
772
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
773
+
return err
774
+
}
775
+
} else {
776
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
777
+
return err
778
+
}
779
+
}
780
+
781
+
// t.Urls ([]string) (slice)
782
+
if t.Urls != nil {
783
+
784
+
if len("urls") > 8192 {
785
+
return xerrors.Errorf("Value in field \"urls\" was too long")
786
+
}
787
+
788
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("urls"))); err != nil {
789
+
return err
790
+
}
791
+
if _, err := cw.WriteString(string("urls")); err != nil {
792
+
return err
793
+
}
794
+
795
+
if len(t.Urls) > 8192 {
796
+
return xerrors.Errorf("Slice value in field t.Urls was too long")
797
+
}
798
+
799
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Urls))); err != nil {
800
+
return err
801
+
}
802
+
for _, v := range t.Urls {
803
+
if len(v) > 8192 {
804
+
return xerrors.Errorf("Value in field v was too long")
805
+
}
806
+
807
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
808
+
return err
809
+
}
810
+
if _, err := cw.WriteString(string(v)); err != nil {
811
+
return err
812
+
}
813
+
814
+
}
815
+
}
816
+
817
+
// t.LexiconTypeID (string) (string)
818
+
if t.LexiconTypeID != "" {
819
+
820
+
if len("$type") > 8192 {
821
+
return xerrors.Errorf("Value in field \"$type\" was too long")
822
+
}
823
+
824
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
825
+
return err
826
+
}
827
+
if _, err := cw.WriteString(string("$type")); err != nil {
828
+
return err
829
+
}
830
+
831
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#blobReference"))); err != nil {
832
+
return err
833
+
}
834
+
if _, err := cw.WriteString(string("io.atcr.manifest#blobReference")); err != nil {
835
+
return err
836
+
}
837
+
}
838
+
839
+
// t.Digest (string) (string)
840
+
if len("digest") > 8192 {
841
+
return xerrors.Errorf("Value in field \"digest\" was too long")
842
+
}
843
+
844
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil {
845
+
return err
846
+
}
847
+
if _, err := cw.WriteString(string("digest")); err != nil {
848
+
return err
849
+
}
850
+
851
+
if len(t.Digest) > 8192 {
852
+
return xerrors.Errorf("Value in field t.Digest was too long")
853
+
}
854
+
855
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil {
856
+
return err
857
+
}
858
+
if _, err := cw.WriteString(string(t.Digest)); err != nil {
859
+
return err
860
+
}
861
+
862
+
// t.MediaType (string) (string)
863
+
if len("mediaType") > 8192 {
864
+
return xerrors.Errorf("Value in field \"mediaType\" was too long")
865
+
}
866
+
867
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil {
868
+
return err
869
+
}
870
+
if _, err := cw.WriteString(string("mediaType")); err != nil {
871
+
return err
872
+
}
873
+
874
+
if len(t.MediaType) > 8192 {
875
+
return xerrors.Errorf("Value in field t.MediaType was too long")
876
+
}
877
+
878
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil {
879
+
return err
880
+
}
881
+
if _, err := cw.WriteString(string(t.MediaType)); err != nil {
882
+
return err
883
+
}
884
+
885
+
// t.Annotations (atproto.Manifest_BlobReference_Annotations) (struct)
886
+
if t.Annotations != nil {
887
+
888
+
if len("annotations") > 8192 {
889
+
return xerrors.Errorf("Value in field \"annotations\" was too long")
890
+
}
891
+
892
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil {
893
+
return err
894
+
}
895
+
if _, err := cw.WriteString(string("annotations")); err != nil {
896
+
return err
897
+
}
898
+
899
+
if err := t.Annotations.MarshalCBOR(cw); err != nil {
900
+
return err
901
+
}
902
+
}
903
+
return nil
904
+
}
905
+
906
+
func (t *Manifest_BlobReference) UnmarshalCBOR(r io.Reader) (err error) {
907
+
*t = Manifest_BlobReference{}
908
+
909
+
cr := cbg.NewCborReader(r)
910
+
911
+
maj, extra, err := cr.ReadHeader()
912
+
if err != nil {
913
+
return err
914
+
}
915
+
defer func() {
916
+
if err == io.EOF {
917
+
err = io.ErrUnexpectedEOF
918
+
}
919
+
}()
920
+
921
+
if maj != cbg.MajMap {
922
+
return fmt.Errorf("cbor input should be of type map")
923
+
}
924
+
925
+
if extra > cbg.MaxLength {
926
+
return fmt.Errorf("Manifest_BlobReference: map struct too large (%d)", extra)
181
927
}
182
928
183
929
n := extra
···
198
944
}
199
945
200
946
switch string(nameBuf[:nameLen]) {
201
-
// t.Role (string) (string)
202
-
case "role":
947
+
// t.Size (int64) (int64)
948
+
case "size":
949
+
{
950
+
maj, extra, err := cr.ReadHeader()
951
+
if err != nil {
952
+
return err
953
+
}
954
+
var extraI int64
955
+
switch maj {
956
+
case cbg.MajUnsignedInt:
957
+
extraI = int64(extra)
958
+
if extraI < 0 {
959
+
return fmt.Errorf("int64 positive overflow")
960
+
}
961
+
case cbg.MajNegativeInt:
962
+
extraI = int64(extra)
963
+
if extraI < 0 {
964
+
return fmt.Errorf("int64 negative overflow")
965
+
}
966
+
extraI = -1 - extraI
967
+
default:
968
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
969
+
}
970
+
971
+
t.Size = int64(extraI)
972
+
}
973
+
// t.Urls ([]string) (slice)
974
+
case "urls":
975
+
976
+
maj, extra, err = cr.ReadHeader()
977
+
if err != nil {
978
+
return err
979
+
}
980
+
981
+
if extra > 8192 {
982
+
return fmt.Errorf("t.Urls: array too large (%d)", extra)
983
+
}
984
+
985
+
if maj != cbg.MajArray {
986
+
return fmt.Errorf("expected cbor array")
987
+
}
988
+
989
+
if extra > 0 {
990
+
t.Urls = make([]string, extra)
991
+
}
992
+
993
+
for i := 0; i < int(extra); i++ {
994
+
{
995
+
var maj byte
996
+
var extra uint64
997
+
var err error
998
+
_ = maj
999
+
_ = extra
1000
+
_ = err
1001
+
1002
+
{
1003
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1004
+
if err != nil {
1005
+
return err
1006
+
}
1007
+
1008
+
t.Urls[i] = string(sval)
1009
+
}
1010
+
1011
+
}
1012
+
}
1013
+
// t.LexiconTypeID (string) (string)
1014
+
case "$type":
203
1015
204
1016
{
205
1017
sval, err := cbg.ReadStringWithMax(cr, 8192)
···
207
1019
return err
208
1020
}
209
1021
210
-
t.Role = string(sval)
1022
+
t.LexiconTypeID = string(sval)
1023
+
}
1024
+
// t.Digest (string) (string)
1025
+
case "digest":
1026
+
1027
+
{
1028
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1029
+
if err != nil {
1030
+
return err
1031
+
}
1032
+
1033
+
t.Digest = string(sval)
1034
+
}
1035
+
// t.MediaType (string) (string)
1036
+
case "mediaType":
1037
+
1038
+
{
1039
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1040
+
if err != nil {
1041
+
return err
1042
+
}
1043
+
1044
+
t.MediaType = string(sval)
1045
+
}
1046
+
// t.Annotations (atproto.Manifest_BlobReference_Annotations) (struct)
1047
+
case "annotations":
1048
+
1049
+
{
1050
+
1051
+
b, err := cr.ReadByte()
1052
+
if err != nil {
1053
+
return err
1054
+
}
1055
+
if b != cbg.CborNull[0] {
1056
+
if err := cr.UnreadByte(); err != nil {
1057
+
return err
1058
+
}
1059
+
t.Annotations = new(Manifest_BlobReference_Annotations)
1060
+
if err := t.Annotations.UnmarshalCBOR(cr); err != nil {
1061
+
return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err)
1062
+
}
1063
+
}
1064
+
1065
+
}
1066
+
1067
+
default:
1068
+
// Field doesn't exist on this type, so ignore it
1069
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1070
+
return err
1071
+
}
1072
+
}
1073
+
}
1074
+
1075
+
return nil
1076
+
}
1077
+
func (t *Manifest_ManifestReference) MarshalCBOR(w io.Writer) error {
1078
+
if t == nil {
1079
+
_, err := w.Write(cbg.CborNull)
1080
+
return err
1081
+
}
1082
+
1083
+
cw := cbg.NewCborWriter(w)
1084
+
fieldCount := 6
1085
+
1086
+
if t.LexiconTypeID == "" {
1087
+
fieldCount--
1088
+
}
1089
+
1090
+
if t.Annotations == nil {
1091
+
fieldCount--
1092
+
}
1093
+
1094
+
if t.Platform == nil {
1095
+
fieldCount--
1096
+
}
1097
+
1098
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1099
+
return err
1100
+
}
1101
+
1102
+
// t.Size (int64) (int64)
1103
+
if len("size") > 8192 {
1104
+
return xerrors.Errorf("Value in field \"size\" was too long")
1105
+
}
1106
+
1107
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
1108
+
return err
1109
+
}
1110
+
if _, err := cw.WriteString(string("size")); err != nil {
1111
+
return err
1112
+
}
1113
+
1114
+
if t.Size >= 0 {
1115
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
1116
+
return err
1117
+
}
1118
+
} else {
1119
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
1120
+
return err
1121
+
}
1122
+
}
1123
+
1124
+
// t.LexiconTypeID (string) (string)
1125
+
if t.LexiconTypeID != "" {
1126
+
1127
+
if len("$type") > 8192 {
1128
+
return xerrors.Errorf("Value in field \"$type\" was too long")
1129
+
}
1130
+
1131
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
1132
+
return err
1133
+
}
1134
+
if _, err := cw.WriteString(string("$type")); err != nil {
1135
+
return err
1136
+
}
1137
+
1138
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#manifestReference"))); err != nil {
1139
+
return err
1140
+
}
1141
+
if _, err := cw.WriteString(string("io.atcr.manifest#manifestReference")); err != nil {
1142
+
return err
1143
+
}
1144
+
}
1145
+
1146
+
// t.Digest (string) (string)
1147
+
if len("digest") > 8192 {
1148
+
return xerrors.Errorf("Value in field \"digest\" was too long")
1149
+
}
1150
+
1151
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("digest"))); err != nil {
1152
+
return err
1153
+
}
1154
+
if _, err := cw.WriteString(string("digest")); err != nil {
1155
+
return err
1156
+
}
1157
+
1158
+
if len(t.Digest) > 8192 {
1159
+
return xerrors.Errorf("Value in field t.Digest was too long")
1160
+
}
1161
+
1162
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Digest))); err != nil {
1163
+
return err
1164
+
}
1165
+
if _, err := cw.WriteString(string(t.Digest)); err != nil {
1166
+
return err
1167
+
}
1168
+
1169
+
// t.Platform (atproto.Manifest_Platform) (struct)
1170
+
if t.Platform != nil {
1171
+
1172
+
if len("platform") > 8192 {
1173
+
return xerrors.Errorf("Value in field \"platform\" was too long")
1174
+
}
1175
+
1176
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("platform"))); err != nil {
1177
+
return err
1178
+
}
1179
+
if _, err := cw.WriteString(string("platform")); err != nil {
1180
+
return err
1181
+
}
1182
+
1183
+
if err := t.Platform.MarshalCBOR(cw); err != nil {
1184
+
return err
1185
+
}
1186
+
}
1187
+
1188
+
// t.MediaType (string) (string)
1189
+
if len("mediaType") > 8192 {
1190
+
return xerrors.Errorf("Value in field \"mediaType\" was too long")
1191
+
}
1192
+
1193
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mediaType"))); err != nil {
1194
+
return err
1195
+
}
1196
+
if _, err := cw.WriteString(string("mediaType")); err != nil {
1197
+
return err
1198
+
}
1199
+
1200
+
if len(t.MediaType) > 8192 {
1201
+
return xerrors.Errorf("Value in field t.MediaType was too long")
1202
+
}
1203
+
1204
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.MediaType))); err != nil {
1205
+
return err
1206
+
}
1207
+
if _, err := cw.WriteString(string(t.MediaType)); err != nil {
1208
+
return err
1209
+
}
1210
+
1211
+
// t.Annotations (atproto.Manifest_ManifestReference_Annotations) (struct)
1212
+
if t.Annotations != nil {
1213
+
1214
+
if len("annotations") > 8192 {
1215
+
return xerrors.Errorf("Value in field \"annotations\" was too long")
1216
+
}
1217
+
1218
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("annotations"))); err != nil {
1219
+
return err
1220
+
}
1221
+
if _, err := cw.WriteString(string("annotations")); err != nil {
1222
+
return err
1223
+
}
1224
+
1225
+
if err := t.Annotations.MarshalCBOR(cw); err != nil {
1226
+
return err
1227
+
}
1228
+
}
1229
+
return nil
1230
+
}
1231
+
1232
+
func (t *Manifest_ManifestReference) UnmarshalCBOR(r io.Reader) (err error) {
1233
+
*t = Manifest_ManifestReference{}
1234
+
1235
+
cr := cbg.NewCborReader(r)
1236
+
1237
+
maj, extra, err := cr.ReadHeader()
1238
+
if err != nil {
1239
+
return err
1240
+
}
1241
+
defer func() {
1242
+
if err == io.EOF {
1243
+
err = io.ErrUnexpectedEOF
1244
+
}
1245
+
}()
1246
+
1247
+
if maj != cbg.MajMap {
1248
+
return fmt.Errorf("cbor input should be of type map")
1249
+
}
1250
+
1251
+
if extra > cbg.MaxLength {
1252
+
return fmt.Errorf("Manifest_ManifestReference: map struct too large (%d)", extra)
1253
+
}
1254
+
1255
+
n := extra
1256
+
1257
+
nameBuf := make([]byte, 11)
1258
+
for i := uint64(0); i < n; i++ {
1259
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
1260
+
if err != nil {
1261
+
return err
1262
+
}
1263
+
1264
+
if !ok {
1265
+
// Field doesn't exist on this type, so ignore it
1266
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1267
+
return err
1268
+
}
1269
+
continue
1270
+
}
1271
+
1272
+
switch string(nameBuf[:nameLen]) {
1273
+
// t.Size (int64) (int64)
1274
+
case "size":
1275
+
{
1276
+
maj, extra, err := cr.ReadHeader()
1277
+
if err != nil {
1278
+
return err
1279
+
}
1280
+
var extraI int64
1281
+
switch maj {
1282
+
case cbg.MajUnsignedInt:
1283
+
extraI = int64(extra)
1284
+
if extraI < 0 {
1285
+
return fmt.Errorf("int64 positive overflow")
1286
+
}
1287
+
case cbg.MajNegativeInt:
1288
+
extraI = int64(extra)
1289
+
if extraI < 0 {
1290
+
return fmt.Errorf("int64 negative overflow")
1291
+
}
1292
+
extraI = -1 - extraI
1293
+
default:
1294
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
1295
+
}
1296
+
1297
+
t.Size = int64(extraI)
211
1298
}
212
-
// t.Type (string) (string)
1299
+
// t.LexiconTypeID (string) (string)
213
1300
case "$type":
214
1301
215
1302
{
···
218
1305
return err
219
1306
}
220
1307
221
-
t.Type = string(sval)
1308
+
t.LexiconTypeID = string(sval)
1309
+
}
1310
+
// t.Digest (string) (string)
1311
+
case "digest":
1312
+
1313
+
{
1314
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1315
+
if err != nil {
1316
+
return err
1317
+
}
1318
+
1319
+
t.Digest = string(sval)
1320
+
}
1321
+
// t.Platform (atproto.Manifest_Platform) (struct)
1322
+
case "platform":
1323
+
1324
+
{
1325
+
1326
+
b, err := cr.ReadByte()
1327
+
if err != nil {
1328
+
return err
1329
+
}
1330
+
if b != cbg.CborNull[0] {
1331
+
if err := cr.UnreadByte(); err != nil {
1332
+
return err
1333
+
}
1334
+
t.Platform = new(Manifest_Platform)
1335
+
if err := t.Platform.UnmarshalCBOR(cr); err != nil {
1336
+
return xerrors.Errorf("unmarshaling t.Platform pointer: %w", err)
1337
+
}
1338
+
}
1339
+
1340
+
}
1341
+
// t.MediaType (string) (string)
1342
+
case "mediaType":
1343
+
1344
+
{
1345
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1346
+
if err != nil {
1347
+
return err
1348
+
}
1349
+
1350
+
t.MediaType = string(sval)
1351
+
}
1352
+
// t.Annotations (atproto.Manifest_ManifestReference_Annotations) (struct)
1353
+
case "annotations":
1354
+
1355
+
{
1356
+
1357
+
b, err := cr.ReadByte()
1358
+
if err != nil {
1359
+
return err
1360
+
}
1361
+
if b != cbg.CborNull[0] {
1362
+
if err := cr.UnreadByte(); err != nil {
1363
+
return err
1364
+
}
1365
+
t.Annotations = new(Manifest_ManifestReference_Annotations)
1366
+
if err := t.Annotations.UnmarshalCBOR(cr); err != nil {
1367
+
return xerrors.Errorf("unmarshaling t.Annotations pointer: %w", err)
1368
+
}
1369
+
}
1370
+
1371
+
}
1372
+
1373
+
default:
1374
+
// Field doesn't exist on this type, so ignore it
1375
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1376
+
return err
1377
+
}
1378
+
}
1379
+
}
1380
+
1381
+
return nil
1382
+
}
1383
+
func (t *Manifest_Platform) MarshalCBOR(w io.Writer) error {
1384
+
if t == nil {
1385
+
_, err := w.Write(cbg.CborNull)
1386
+
return err
1387
+
}
1388
+
1389
+
cw := cbg.NewCborWriter(w)
1390
+
fieldCount := 6
1391
+
1392
+
if t.LexiconTypeID == "" {
1393
+
fieldCount--
1394
+
}
1395
+
1396
+
if t.OsFeatures == nil {
1397
+
fieldCount--
1398
+
}
1399
+
1400
+
if t.OsVersion == nil {
1401
+
fieldCount--
1402
+
}
1403
+
1404
+
if t.Variant == nil {
1405
+
fieldCount--
1406
+
}
1407
+
1408
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1409
+
return err
1410
+
}
1411
+
1412
+
// t.Os (string) (string)
1413
+
if len("os") > 8192 {
1414
+
return xerrors.Errorf("Value in field \"os\" was too long")
1415
+
}
1416
+
1417
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("os"))); err != nil {
1418
+
return err
1419
+
}
1420
+
if _, err := cw.WriteString(string("os")); err != nil {
1421
+
return err
1422
+
}
1423
+
1424
+
if len(t.Os) > 8192 {
1425
+
return xerrors.Errorf("Value in field t.Os was too long")
1426
+
}
1427
+
1428
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Os))); err != nil {
1429
+
return err
1430
+
}
1431
+
if _, err := cw.WriteString(string(t.Os)); err != nil {
1432
+
return err
1433
+
}
1434
+
1435
+
// t.LexiconTypeID (string) (string)
1436
+
if t.LexiconTypeID != "" {
1437
+
1438
+
if len("$type") > 8192 {
1439
+
return xerrors.Errorf("Value in field \"$type\" was too long")
1440
+
}
1441
+
1442
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
1443
+
return err
1444
+
}
1445
+
if _, err := cw.WriteString(string("$type")); err != nil {
1446
+
return err
1447
+
}
1448
+
1449
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.manifest#platform"))); err != nil {
1450
+
return err
1451
+
}
1452
+
if _, err := cw.WriteString(string("io.atcr.manifest#platform")); err != nil {
1453
+
return err
1454
+
}
1455
+
}
1456
+
1457
+
// t.Variant (string) (string)
1458
+
if t.Variant != nil {
1459
+
1460
+
if len("variant") > 8192 {
1461
+
return xerrors.Errorf("Value in field \"variant\" was too long")
1462
+
}
1463
+
1464
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("variant"))); err != nil {
1465
+
return err
1466
+
}
1467
+
if _, err := cw.WriteString(string("variant")); err != nil {
1468
+
return err
1469
+
}
1470
+
1471
+
if t.Variant == nil {
1472
+
if _, err := cw.Write(cbg.CborNull); err != nil {
1473
+
return err
222
1474
}
223
-
// t.Member (string) (string)
224
-
case "member":
1475
+
} else {
1476
+
if len(*t.Variant) > 8192 {
1477
+
return xerrors.Errorf("Value in field t.Variant was too long")
1478
+
}
1479
+
1480
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Variant))); err != nil {
1481
+
return err
1482
+
}
1483
+
if _, err := cw.WriteString(string(*t.Variant)); err != nil {
1484
+
return err
1485
+
}
1486
+
}
1487
+
}
1488
+
1489
+
// t.OsVersion (string) (string)
1490
+
if t.OsVersion != nil {
1491
+
1492
+
if len("osVersion") > 8192 {
1493
+
return xerrors.Errorf("Value in field \"osVersion\" was too long")
1494
+
}
1495
+
1496
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("osVersion"))); err != nil {
1497
+
return err
1498
+
}
1499
+
if _, err := cw.WriteString(string("osVersion")); err != nil {
1500
+
return err
1501
+
}
1502
+
1503
+
if t.OsVersion == nil {
1504
+
if _, err := cw.Write(cbg.CborNull); err != nil {
1505
+
return err
1506
+
}
1507
+
} else {
1508
+
if len(*t.OsVersion) > 8192 {
1509
+
return xerrors.Errorf("Value in field t.OsVersion was too long")
1510
+
}
1511
+
1512
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.OsVersion))); err != nil {
1513
+
return err
1514
+
}
1515
+
if _, err := cw.WriteString(string(*t.OsVersion)); err != nil {
1516
+
return err
1517
+
}
1518
+
}
1519
+
}
1520
+
1521
+
// t.OsFeatures ([]string) (slice)
1522
+
if t.OsFeatures != nil {
1523
+
1524
+
if len("osFeatures") > 8192 {
1525
+
return xerrors.Errorf("Value in field \"osFeatures\" was too long")
1526
+
}
1527
+
1528
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("osFeatures"))); err != nil {
1529
+
return err
1530
+
}
1531
+
if _, err := cw.WriteString(string("osFeatures")); err != nil {
1532
+
return err
1533
+
}
1534
+
1535
+
if len(t.OsFeatures) > 8192 {
1536
+
return xerrors.Errorf("Slice value in field t.OsFeatures was too long")
1537
+
}
1538
+
1539
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.OsFeatures))); err != nil {
1540
+
return err
1541
+
}
1542
+
for _, v := range t.OsFeatures {
1543
+
if len(v) > 8192 {
1544
+
return xerrors.Errorf("Value in field v was too long")
1545
+
}
1546
+
1547
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
1548
+
return err
1549
+
}
1550
+
if _, err := cw.WriteString(string(v)); err != nil {
1551
+
return err
1552
+
}
1553
+
1554
+
}
1555
+
}
1556
+
1557
+
// t.Architecture (string) (string)
1558
+
if len("architecture") > 8192 {
1559
+
return xerrors.Errorf("Value in field \"architecture\" was too long")
1560
+
}
1561
+
1562
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("architecture"))); err != nil {
1563
+
return err
1564
+
}
1565
+
if _, err := cw.WriteString(string("architecture")); err != nil {
1566
+
return err
1567
+
}
1568
+
1569
+
if len(t.Architecture) > 8192 {
1570
+
return xerrors.Errorf("Value in field t.Architecture was too long")
1571
+
}
1572
+
1573
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Architecture))); err != nil {
1574
+
return err
1575
+
}
1576
+
if _, err := cw.WriteString(string(t.Architecture)); err != nil {
1577
+
return err
1578
+
}
1579
+
return nil
1580
+
}
1581
+
1582
+
func (t *Manifest_Platform) UnmarshalCBOR(r io.Reader) (err error) {
1583
+
*t = Manifest_Platform{}
1584
+
1585
+
cr := cbg.NewCborReader(r)
1586
+
1587
+
maj, extra, err := cr.ReadHeader()
1588
+
if err != nil {
1589
+
return err
1590
+
}
1591
+
defer func() {
1592
+
if err == io.EOF {
1593
+
err = io.ErrUnexpectedEOF
1594
+
}
1595
+
}()
1596
+
1597
+
if maj != cbg.MajMap {
1598
+
return fmt.Errorf("cbor input should be of type map")
1599
+
}
1600
+
1601
+
if extra > cbg.MaxLength {
1602
+
return fmt.Errorf("Manifest_Platform: map struct too large (%d)", extra)
1603
+
}
1604
+
1605
+
n := extra
1606
+
1607
+
nameBuf := make([]byte, 12)
1608
+
for i := uint64(0); i < n; i++ {
1609
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
1610
+
if err != nil {
1611
+
return err
1612
+
}
1613
+
1614
+
if !ok {
1615
+
// Field doesn't exist on this type, so ignore it
1616
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1617
+
return err
1618
+
}
1619
+
continue
1620
+
}
1621
+
1622
+
switch string(nameBuf[:nameLen]) {
1623
+
// t.Os (string) (string)
1624
+
case "os":
225
1625
226
1626
{
227
1627
sval, err := cbg.ReadStringWithMax(cr, 8192)
···
229
1629
return err
230
1630
}
231
1631
232
-
t.Member = string(sval)
1632
+
t.Os = string(sval)
233
1633
}
234
-
// t.AddedAt (string) (string)
235
-
case "addedAt":
1634
+
// t.LexiconTypeID (string) (string)
1635
+
case "$type":
236
1636
237
1637
{
238
1638
sval, err := cbg.ReadStringWithMax(cr, 8192)
···
240
1640
return err
241
1641
}
242
1642
243
-
t.AddedAt = string(sval)
1643
+
t.LexiconTypeID = string(sval)
244
1644
}
245
-
// t.Permissions ([]string) (slice)
246
-
case "permissions":
1645
+
// t.Variant (string) (string)
1646
+
case "variant":
1647
+
1648
+
{
1649
+
b, err := cr.ReadByte()
1650
+
if err != nil {
1651
+
return err
1652
+
}
1653
+
if b != cbg.CborNull[0] {
1654
+
if err := cr.UnreadByte(); err != nil {
1655
+
return err
1656
+
}
1657
+
1658
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1659
+
if err != nil {
1660
+
return err
1661
+
}
1662
+
1663
+
t.Variant = (*string)(&sval)
1664
+
}
1665
+
}
1666
+
// t.OsVersion (string) (string)
1667
+
case "osVersion":
1668
+
1669
+
{
1670
+
b, err := cr.ReadByte()
1671
+
if err != nil {
1672
+
return err
1673
+
}
1674
+
if b != cbg.CborNull[0] {
1675
+
if err := cr.UnreadByte(); err != nil {
1676
+
return err
1677
+
}
1678
+
1679
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1680
+
if err != nil {
1681
+
return err
1682
+
}
1683
+
1684
+
t.OsVersion = (*string)(&sval)
1685
+
}
1686
+
}
1687
+
// t.OsFeatures ([]string) (slice)
1688
+
case "osFeatures":
247
1689
248
1690
maj, extra, err = cr.ReadHeader()
249
1691
if err != nil {
···
251
1693
}
252
1694
253
1695
if extra > 8192 {
254
-
return fmt.Errorf("t.Permissions: array too large (%d)", extra)
1696
+
return fmt.Errorf("t.OsFeatures: array too large (%d)", extra)
255
1697
}
256
1698
257
1699
if maj != cbg.MajArray {
···
259
1701
}
260
1702
261
1703
if extra > 0 {
262
-
t.Permissions = make([]string, extra)
1704
+
t.OsFeatures = make([]string, extra)
263
1705
}
264
1706
265
1707
for i := 0; i < int(extra); i++ {
···
277
1719
return err
278
1720
}
279
1721
280
-
t.Permissions[i] = string(sval)
1722
+
t.OsFeatures[i] = string(sval)
1723
+
}
1724
+
1725
+
}
1726
+
}
1727
+
// t.Architecture (string) (string)
1728
+
case "architecture":
1729
+
1730
+
{
1731
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
1732
+
if err != nil {
1733
+
return err
1734
+
}
1735
+
1736
+
t.Architecture = string(sval)
1737
+
}
1738
+
1739
+
default:
1740
+
// Field doesn't exist on this type, so ignore it
1741
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1742
+
return err
1743
+
}
1744
+
}
1745
+
}
1746
+
1747
+
return nil
1748
+
}
1749
+
func (t *Manifest_Annotations) MarshalCBOR(w io.Writer) error {
1750
+
if t == nil {
1751
+
_, err := w.Write(cbg.CborNull)
1752
+
return err
1753
+
}
1754
+
1755
+
cw := cbg.NewCborWriter(w)
1756
+
1757
+
if _, err := cw.Write([]byte{160}); err != nil {
1758
+
return err
1759
+
}
1760
+
return nil
1761
+
}
1762
+
1763
+
func (t *Manifest_Annotations) UnmarshalCBOR(r io.Reader) (err error) {
1764
+
*t = Manifest_Annotations{}
1765
+
1766
+
cr := cbg.NewCborReader(r)
1767
+
1768
+
maj, extra, err := cr.ReadHeader()
1769
+
if err != nil {
1770
+
return err
1771
+
}
1772
+
defer func() {
1773
+
if err == io.EOF {
1774
+
err = io.ErrUnexpectedEOF
1775
+
}
1776
+
}()
1777
+
1778
+
if maj != cbg.MajMap {
1779
+
return fmt.Errorf("cbor input should be of type map")
1780
+
}
1781
+
1782
+
if extra > cbg.MaxLength {
1783
+
return fmt.Errorf("Manifest_Annotations: map struct too large (%d)", extra)
1784
+
}
1785
+
1786
+
n := extra
1787
+
1788
+
nameBuf := make([]byte, 0)
1789
+
for i := uint64(0); i < n; i++ {
1790
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
1791
+
if err != nil {
1792
+
return err
1793
+
}
1794
+
1795
+
if !ok {
1796
+
// Field doesn't exist on this type, so ignore it
1797
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1798
+
return err
1799
+
}
1800
+
continue
1801
+
}
1802
+
1803
+
switch string(nameBuf[:nameLen]) {
1804
+
1805
+
default:
1806
+
// Field doesn't exist on this type, so ignore it
1807
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1808
+
return err
1809
+
}
1810
+
}
1811
+
}
1812
+
1813
+
return nil
1814
+
}
1815
+
func (t *Manifest_BlobReference_Annotations) MarshalCBOR(w io.Writer) error {
1816
+
if t == nil {
1817
+
_, err := w.Write(cbg.CborNull)
1818
+
return err
1819
+
}
1820
+
1821
+
cw := cbg.NewCborWriter(w)
1822
+
1823
+
if _, err := cw.Write([]byte{160}); err != nil {
1824
+
return err
1825
+
}
1826
+
return nil
1827
+
}
1828
+
1829
+
func (t *Manifest_BlobReference_Annotations) UnmarshalCBOR(r io.Reader) (err error) {
1830
+
*t = Manifest_BlobReference_Annotations{}
1831
+
1832
+
cr := cbg.NewCborReader(r)
1833
+
1834
+
maj, extra, err := cr.ReadHeader()
1835
+
if err != nil {
1836
+
return err
1837
+
}
1838
+
defer func() {
1839
+
if err == io.EOF {
1840
+
err = io.ErrUnexpectedEOF
1841
+
}
1842
+
}()
1843
+
1844
+
if maj != cbg.MajMap {
1845
+
return fmt.Errorf("cbor input should be of type map")
1846
+
}
1847
+
1848
+
if extra > cbg.MaxLength {
1849
+
return fmt.Errorf("Manifest_BlobReference_Annotations: map struct too large (%d)", extra)
1850
+
}
1851
+
1852
+
n := extra
1853
+
1854
+
nameBuf := make([]byte, 0)
1855
+
for i := uint64(0); i < n; i++ {
1856
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
1857
+
if err != nil {
1858
+
return err
1859
+
}
1860
+
1861
+
if !ok {
1862
+
// Field doesn't exist on this type, so ignore it
1863
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1864
+
return err
1865
+
}
1866
+
continue
1867
+
}
1868
+
1869
+
switch string(nameBuf[:nameLen]) {
1870
+
1871
+
default:
1872
+
// Field doesn't exist on this type, so ignore it
1873
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1874
+
return err
1875
+
}
1876
+
}
1877
+
}
1878
+
1879
+
return nil
1880
+
}
1881
+
func (t *Manifest_ManifestReference_Annotations) MarshalCBOR(w io.Writer) error {
1882
+
if t == nil {
1883
+
_, err := w.Write(cbg.CborNull)
1884
+
return err
1885
+
}
1886
+
1887
+
cw := cbg.NewCborWriter(w)
1888
+
1889
+
if _, err := cw.Write([]byte{160}); err != nil {
1890
+
return err
1891
+
}
1892
+
return nil
1893
+
}
1894
+
1895
+
func (t *Manifest_ManifestReference_Annotations) UnmarshalCBOR(r io.Reader) (err error) {
1896
+
*t = Manifest_ManifestReference_Annotations{}
1897
+
1898
+
cr := cbg.NewCborReader(r)
1899
+
1900
+
maj, extra, err := cr.ReadHeader()
1901
+
if err != nil {
1902
+
return err
1903
+
}
1904
+
defer func() {
1905
+
if err == io.EOF {
1906
+
err = io.ErrUnexpectedEOF
1907
+
}
1908
+
}()
1909
+
1910
+
if maj != cbg.MajMap {
1911
+
return fmt.Errorf("cbor input should be of type map")
1912
+
}
1913
+
1914
+
if extra > cbg.MaxLength {
1915
+
return fmt.Errorf("Manifest_ManifestReference_Annotations: map struct too large (%d)", extra)
1916
+
}
1917
+
1918
+
n := extra
1919
+
1920
+
nameBuf := make([]byte, 0)
1921
+
for i := uint64(0); i < n; i++ {
1922
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
1923
+
if err != nil {
1924
+
return err
1925
+
}
1926
+
1927
+
if !ok {
1928
+
// Field doesn't exist on this type, so ignore it
1929
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1930
+
return err
1931
+
}
1932
+
continue
1933
+
}
1934
+
1935
+
switch string(nameBuf[:nameLen]) {
1936
+
1937
+
default:
1938
+
// Field doesn't exist on this type, so ignore it
1939
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1940
+
return err
1941
+
}
1942
+
}
1943
+
}
1944
+
1945
+
return nil
1946
+
}
1947
+
func (t *Tag) MarshalCBOR(w io.Writer) error {
1948
+
if t == nil {
1949
+
_, err := w.Write(cbg.CborNull)
1950
+
return err
1951
+
}
1952
+
1953
+
cw := cbg.NewCborWriter(w)
1954
+
fieldCount := 6
1955
+
1956
+
if t.Manifest == nil {
1957
+
fieldCount--
1958
+
}
1959
+
1960
+
if t.ManifestDigest == nil {
1961
+
fieldCount--
1962
+
}
1963
+
1964
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1965
+
return err
1966
+
}
1967
+
1968
+
// t.Tag (string) (string)
1969
+
if len("tag") > 8192 {
1970
+
return xerrors.Errorf("Value in field \"tag\" was too long")
1971
+
}
1972
+
1973
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tag"))); err != nil {
1974
+
return err
1975
+
}
1976
+
if _, err := cw.WriteString(string("tag")); err != nil {
1977
+
return err
1978
+
}
1979
+
1980
+
if len(t.Tag) > 8192 {
1981
+
return xerrors.Errorf("Value in field t.Tag was too long")
1982
+
}
1983
+
1984
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Tag))); err != nil {
1985
+
return err
1986
+
}
1987
+
if _, err := cw.WriteString(string(t.Tag)); err != nil {
1988
+
return err
1989
+
}
1990
+
1991
+
// t.LexiconTypeID (string) (string)
1992
+
if len("$type") > 8192 {
1993
+
return xerrors.Errorf("Value in field \"$type\" was too long")
1994
+
}
1995
+
1996
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
1997
+
return err
1998
+
}
1999
+
if _, err := cw.WriteString(string("$type")); err != nil {
2000
+
return err
2001
+
}
2002
+
2003
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.tag"))); err != nil {
2004
+
return err
2005
+
}
2006
+
if _, err := cw.WriteString(string("io.atcr.tag")); err != nil {
2007
+
return err
2008
+
}
2009
+
2010
+
// t.Manifest (string) (string)
2011
+
if t.Manifest != nil {
2012
+
2013
+
if len("manifest") > 8192 {
2014
+
return xerrors.Errorf("Value in field \"manifest\" was too long")
2015
+
}
2016
+
2017
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifest"))); err != nil {
2018
+
return err
2019
+
}
2020
+
if _, err := cw.WriteString(string("manifest")); err != nil {
2021
+
return err
2022
+
}
2023
+
2024
+
if t.Manifest == nil {
2025
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2026
+
return err
2027
+
}
2028
+
} else {
2029
+
if len(*t.Manifest) > 8192 {
2030
+
return xerrors.Errorf("Value in field t.Manifest was too long")
2031
+
}
2032
+
2033
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Manifest))); err != nil {
2034
+
return err
2035
+
}
2036
+
if _, err := cw.WriteString(string(*t.Manifest)); err != nil {
2037
+
return err
2038
+
}
2039
+
}
2040
+
}
2041
+
2042
+
// t.CreatedAt (string) (string)
2043
+
if len("createdAt") > 8192 {
2044
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2045
+
}
2046
+
2047
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2048
+
return err
2049
+
}
2050
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2051
+
return err
2052
+
}
2053
+
2054
+
if len(t.CreatedAt) > 8192 {
2055
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2056
+
}
2057
+
2058
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2059
+
return err
2060
+
}
2061
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2062
+
return err
2063
+
}
2064
+
2065
+
// t.Repository (string) (string)
2066
+
if len("repository") > 8192 {
2067
+
return xerrors.Errorf("Value in field \"repository\" was too long")
2068
+
}
2069
+
2070
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
2071
+
return err
2072
+
}
2073
+
if _, err := cw.WriteString(string("repository")); err != nil {
2074
+
return err
2075
+
}
2076
+
2077
+
if len(t.Repository) > 8192 {
2078
+
return xerrors.Errorf("Value in field t.Repository was too long")
2079
+
}
2080
+
2081
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
2082
+
return err
2083
+
}
2084
+
if _, err := cw.WriteString(string(t.Repository)); err != nil {
2085
+
return err
2086
+
}
2087
+
2088
+
// t.ManifestDigest (string) (string)
2089
+
if t.ManifestDigest != nil {
2090
+
2091
+
if len("manifestDigest") > 8192 {
2092
+
return xerrors.Errorf("Value in field \"manifestDigest\" was too long")
2093
+
}
2094
+
2095
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifestDigest"))); err != nil {
2096
+
return err
2097
+
}
2098
+
if _, err := cw.WriteString(string("manifestDigest")); err != nil {
2099
+
return err
2100
+
}
2101
+
2102
+
if t.ManifestDigest == nil {
2103
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2104
+
return err
2105
+
}
2106
+
} else {
2107
+
if len(*t.ManifestDigest) > 8192 {
2108
+
return xerrors.Errorf("Value in field t.ManifestDigest was too long")
2109
+
}
2110
+
2111
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ManifestDigest))); err != nil {
2112
+
return err
2113
+
}
2114
+
if _, err := cw.WriteString(string(*t.ManifestDigest)); err != nil {
2115
+
return err
2116
+
}
2117
+
}
2118
+
}
2119
+
return nil
2120
+
}
2121
+
2122
+
func (t *Tag) UnmarshalCBOR(r io.Reader) (err error) {
2123
+
*t = Tag{}
2124
+
2125
+
cr := cbg.NewCborReader(r)
2126
+
2127
+
maj, extra, err := cr.ReadHeader()
2128
+
if err != nil {
2129
+
return err
2130
+
}
2131
+
defer func() {
2132
+
if err == io.EOF {
2133
+
err = io.ErrUnexpectedEOF
2134
+
}
2135
+
}()
2136
+
2137
+
if maj != cbg.MajMap {
2138
+
return fmt.Errorf("cbor input should be of type map")
2139
+
}
2140
+
2141
+
if extra > cbg.MaxLength {
2142
+
return fmt.Errorf("Tag: map struct too large (%d)", extra)
2143
+
}
2144
+
2145
+
n := extra
2146
+
2147
+
nameBuf := make([]byte, 14)
2148
+
for i := uint64(0); i < n; i++ {
2149
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
2150
+
if err != nil {
2151
+
return err
2152
+
}
2153
+
2154
+
if !ok {
2155
+
// Field doesn't exist on this type, so ignore it
2156
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2157
+
return err
2158
+
}
2159
+
continue
2160
+
}
2161
+
2162
+
switch string(nameBuf[:nameLen]) {
2163
+
// t.Tag (string) (string)
2164
+
case "tag":
2165
+
2166
+
{
2167
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2168
+
if err != nil {
2169
+
return err
2170
+
}
2171
+
2172
+
t.Tag = string(sval)
2173
+
}
2174
+
// t.LexiconTypeID (string) (string)
2175
+
case "$type":
2176
+
2177
+
{
2178
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2179
+
if err != nil {
2180
+
return err
2181
+
}
2182
+
2183
+
t.LexiconTypeID = string(sval)
2184
+
}
2185
+
// t.Manifest (string) (string)
2186
+
case "manifest":
2187
+
2188
+
{
2189
+
b, err := cr.ReadByte()
2190
+
if err != nil {
2191
+
return err
2192
+
}
2193
+
if b != cbg.CborNull[0] {
2194
+
if err := cr.UnreadByte(); err != nil {
2195
+
return err
281
2196
}
282
2197
2198
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2199
+
if err != nil {
2200
+
return err
2201
+
}
2202
+
2203
+
t.Manifest = (*string)(&sval)
2204
+
}
2205
+
}
2206
+
// t.CreatedAt (string) (string)
2207
+
case "createdAt":
2208
+
2209
+
{
2210
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2211
+
if err != nil {
2212
+
return err
2213
+
}
2214
+
2215
+
t.CreatedAt = string(sval)
2216
+
}
2217
+
// t.Repository (string) (string)
2218
+
case "repository":
2219
+
2220
+
{
2221
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2222
+
if err != nil {
2223
+
return err
2224
+
}
2225
+
2226
+
t.Repository = string(sval)
2227
+
}
2228
+
// t.ManifestDigest (string) (string)
2229
+
case "manifestDigest":
2230
+
2231
+
{
2232
+
b, err := cr.ReadByte()
2233
+
if err != nil {
2234
+
return err
2235
+
}
2236
+
if b != cbg.CborNull[0] {
2237
+
if err := cr.UnreadByte(); err != nil {
2238
+
return err
2239
+
}
2240
+
2241
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2242
+
if err != nil {
2243
+
return err
2244
+
}
2245
+
2246
+
t.ManifestDigest = (*string)(&sval)
283
2247
}
284
2248
}
285
2249
···
293
2257
294
2258
return nil
295
2259
}
296
-
func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
2260
+
func (t *SailorProfile) MarshalCBOR(w io.Writer) error {
297
2261
if t == nil {
298
2262
_, err := w.Write(cbg.CborNull)
299
2263
return err
300
2264
}
301
2265
302
2266
cw := cbg.NewCborWriter(w)
303
-
fieldCount := 8
2267
+
fieldCount := 4
304
2268
305
-
if t.Region == "" {
2269
+
if t.DefaultHold == nil {
306
2270
fieldCount--
307
2271
}
308
2272
309
-
if t.Provider == "" {
2273
+
if t.UpdatedAt == nil {
310
2274
fieldCount--
311
2275
}
312
2276
···
314
2278
return err
315
2279
}
316
2280
317
-
// t.Type (string) (string)
2281
+
// t.LexiconTypeID (string) (string)
318
2282
if len("$type") > 8192 {
319
2283
return xerrors.Errorf("Value in field \"$type\" was too long")
320
2284
}
···
326
2290
return err
327
2291
}
328
2292
329
-
if len(t.Type) > 8192 {
330
-
return xerrors.Errorf("Value in field t.Type was too long")
2293
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.profile"))); err != nil {
2294
+
return err
2295
+
}
2296
+
if _, err := cw.WriteString(string("io.atcr.sailor.profile")); err != nil {
2297
+
return err
2298
+
}
2299
+
2300
+
// t.CreatedAt (string) (string)
2301
+
if len("createdAt") > 8192 {
2302
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
331
2303
}
332
2304
333
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
2305
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
334
2306
return err
335
2307
}
336
-
if _, err := cw.WriteString(string(t.Type)); err != nil {
2308
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2309
+
return err
2310
+
}
2311
+
2312
+
if len(t.CreatedAt) > 8192 {
2313
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2314
+
}
2315
+
2316
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2317
+
return err
2318
+
}
2319
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2320
+
return err
2321
+
}
2322
+
2323
+
// t.UpdatedAt (string) (string)
2324
+
if t.UpdatedAt != nil {
2325
+
2326
+
if len("updatedAt") > 8192 {
2327
+
return xerrors.Errorf("Value in field \"updatedAt\" was too long")
2328
+
}
2329
+
2330
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updatedAt"))); err != nil {
2331
+
return err
2332
+
}
2333
+
if _, err := cw.WriteString(string("updatedAt")); err != nil {
2334
+
return err
2335
+
}
2336
+
2337
+
if t.UpdatedAt == nil {
2338
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2339
+
return err
2340
+
}
2341
+
} else {
2342
+
if len(*t.UpdatedAt) > 8192 {
2343
+
return xerrors.Errorf("Value in field t.UpdatedAt was too long")
2344
+
}
2345
+
2346
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.UpdatedAt))); err != nil {
2347
+
return err
2348
+
}
2349
+
if _, err := cw.WriteString(string(*t.UpdatedAt)); err != nil {
2350
+
return err
2351
+
}
2352
+
}
2353
+
}
2354
+
2355
+
// t.DefaultHold (string) (string)
2356
+
if t.DefaultHold != nil {
2357
+
2358
+
if len("defaultHold") > 8192 {
2359
+
return xerrors.Errorf("Value in field \"defaultHold\" was too long")
2360
+
}
2361
+
2362
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("defaultHold"))); err != nil {
2363
+
return err
2364
+
}
2365
+
if _, err := cw.WriteString(string("defaultHold")); err != nil {
2366
+
return err
2367
+
}
2368
+
2369
+
if t.DefaultHold == nil {
2370
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2371
+
return err
2372
+
}
2373
+
} else {
2374
+
if len(*t.DefaultHold) > 8192 {
2375
+
return xerrors.Errorf("Value in field t.DefaultHold was too long")
2376
+
}
2377
+
2378
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.DefaultHold))); err != nil {
2379
+
return err
2380
+
}
2381
+
if _, err := cw.WriteString(string(*t.DefaultHold)); err != nil {
2382
+
return err
2383
+
}
2384
+
}
2385
+
}
2386
+
return nil
2387
+
}
2388
+
2389
+
func (t *SailorProfile) UnmarshalCBOR(r io.Reader) (err error) {
2390
+
*t = SailorProfile{}
2391
+
2392
+
cr := cbg.NewCborReader(r)
2393
+
2394
+
maj, extra, err := cr.ReadHeader()
2395
+
if err != nil {
2396
+
return err
2397
+
}
2398
+
defer func() {
2399
+
if err == io.EOF {
2400
+
err = io.ErrUnexpectedEOF
2401
+
}
2402
+
}()
2403
+
2404
+
if maj != cbg.MajMap {
2405
+
return fmt.Errorf("cbor input should be of type map")
2406
+
}
2407
+
2408
+
if extra > cbg.MaxLength {
2409
+
return fmt.Errorf("SailorProfile: map struct too large (%d)", extra)
2410
+
}
2411
+
2412
+
n := extra
2413
+
2414
+
nameBuf := make([]byte, 11)
2415
+
for i := uint64(0); i < n; i++ {
2416
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
2417
+
if err != nil {
2418
+
return err
2419
+
}
2420
+
2421
+
if !ok {
2422
+
// Field doesn't exist on this type, so ignore it
2423
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2424
+
return err
2425
+
}
2426
+
continue
2427
+
}
2428
+
2429
+
switch string(nameBuf[:nameLen]) {
2430
+
// t.LexiconTypeID (string) (string)
2431
+
case "$type":
2432
+
2433
+
{
2434
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2435
+
if err != nil {
2436
+
return err
2437
+
}
2438
+
2439
+
t.LexiconTypeID = string(sval)
2440
+
}
2441
+
// t.CreatedAt (string) (string)
2442
+
case "createdAt":
2443
+
2444
+
{
2445
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2446
+
if err != nil {
2447
+
return err
2448
+
}
2449
+
2450
+
t.CreatedAt = string(sval)
2451
+
}
2452
+
// t.UpdatedAt (string) (string)
2453
+
case "updatedAt":
2454
+
2455
+
{
2456
+
b, err := cr.ReadByte()
2457
+
if err != nil {
2458
+
return err
2459
+
}
2460
+
if b != cbg.CborNull[0] {
2461
+
if err := cr.UnreadByte(); err != nil {
2462
+
return err
2463
+
}
2464
+
2465
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2466
+
if err != nil {
2467
+
return err
2468
+
}
2469
+
2470
+
t.UpdatedAt = (*string)(&sval)
2471
+
}
2472
+
}
2473
+
// t.DefaultHold (string) (string)
2474
+
case "defaultHold":
2475
+
2476
+
{
2477
+
b, err := cr.ReadByte()
2478
+
if err != nil {
2479
+
return err
2480
+
}
2481
+
if b != cbg.CborNull[0] {
2482
+
if err := cr.UnreadByte(); err != nil {
2483
+
return err
2484
+
}
2485
+
2486
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2487
+
if err != nil {
2488
+
return err
2489
+
}
2490
+
2491
+
t.DefaultHold = (*string)(&sval)
2492
+
}
2493
+
}
2494
+
2495
+
default:
2496
+
// Field doesn't exist on this type, so ignore it
2497
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2498
+
return err
2499
+
}
2500
+
}
2501
+
}
2502
+
2503
+
return nil
2504
+
}
2505
+
func (t *SailorStar) MarshalCBOR(w io.Writer) error {
2506
+
if t == nil {
2507
+
_, err := w.Write(cbg.CborNull)
2508
+
return err
2509
+
}
2510
+
2511
+
cw := cbg.NewCborWriter(w)
2512
+
2513
+
if _, err := cw.Write([]byte{163}); err != nil {
2514
+
return err
2515
+
}
2516
+
2517
+
// t.LexiconTypeID (string) (string)
2518
+
if len("$type") > 8192 {
2519
+
return xerrors.Errorf("Value in field \"$type\" was too long")
2520
+
}
2521
+
2522
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
2523
+
return err
2524
+
}
2525
+
if _, err := cw.WriteString(string("$type")); err != nil {
2526
+
return err
2527
+
}
2528
+
2529
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.star"))); err != nil {
2530
+
return err
2531
+
}
2532
+
if _, err := cw.WriteString(string("io.atcr.sailor.star")); err != nil {
2533
+
return err
2534
+
}
2535
+
2536
+
// t.Subject (atproto.SailorStar_Subject) (struct)
2537
+
if len("subject") > 8192 {
2538
+
return xerrors.Errorf("Value in field \"subject\" was too long")
2539
+
}
2540
+
2541
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
2542
+
return err
2543
+
}
2544
+
if _, err := cw.WriteString(string("subject")); err != nil {
2545
+
return err
2546
+
}
2547
+
2548
+
if err := t.Subject.MarshalCBOR(cw); err != nil {
2549
+
return err
2550
+
}
2551
+
2552
+
// t.CreatedAt (string) (string)
2553
+
if len("createdAt") > 8192 {
2554
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2555
+
}
2556
+
2557
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2558
+
return err
2559
+
}
2560
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2561
+
return err
2562
+
}
2563
+
2564
+
if len(t.CreatedAt) > 8192 {
2565
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2566
+
}
2567
+
2568
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2569
+
return err
2570
+
}
2571
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2572
+
return err
2573
+
}
2574
+
return nil
2575
+
}
2576
+
2577
+
func (t *SailorStar) UnmarshalCBOR(r io.Reader) (err error) {
2578
+
*t = SailorStar{}
2579
+
2580
+
cr := cbg.NewCborReader(r)
2581
+
2582
+
maj, extra, err := cr.ReadHeader()
2583
+
if err != nil {
2584
+
return err
2585
+
}
2586
+
defer func() {
2587
+
if err == io.EOF {
2588
+
err = io.ErrUnexpectedEOF
2589
+
}
2590
+
}()
2591
+
2592
+
if maj != cbg.MajMap {
2593
+
return fmt.Errorf("cbor input should be of type map")
2594
+
}
2595
+
2596
+
if extra > cbg.MaxLength {
2597
+
return fmt.Errorf("SailorStar: map struct too large (%d)", extra)
2598
+
}
2599
+
2600
+
n := extra
2601
+
2602
+
nameBuf := make([]byte, 9)
2603
+
for i := uint64(0); i < n; i++ {
2604
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
2605
+
if err != nil {
2606
+
return err
2607
+
}
2608
+
2609
+
if !ok {
2610
+
// Field doesn't exist on this type, so ignore it
2611
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2612
+
return err
2613
+
}
2614
+
continue
2615
+
}
2616
+
2617
+
switch string(nameBuf[:nameLen]) {
2618
+
// t.LexiconTypeID (string) (string)
2619
+
case "$type":
2620
+
2621
+
{
2622
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2623
+
if err != nil {
2624
+
return err
2625
+
}
2626
+
2627
+
t.LexiconTypeID = string(sval)
2628
+
}
2629
+
// t.Subject (atproto.SailorStar_Subject) (struct)
2630
+
case "subject":
2631
+
2632
+
{
2633
+
2634
+
if err := t.Subject.UnmarshalCBOR(cr); err != nil {
2635
+
return xerrors.Errorf("unmarshaling t.Subject: %w", err)
2636
+
}
2637
+
2638
+
}
2639
+
// t.CreatedAt (string) (string)
2640
+
case "createdAt":
2641
+
2642
+
{
2643
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2644
+
if err != nil {
2645
+
return err
2646
+
}
2647
+
2648
+
t.CreatedAt = string(sval)
2649
+
}
2650
+
2651
+
default:
2652
+
// Field doesn't exist on this type, so ignore it
2653
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2654
+
return err
2655
+
}
2656
+
}
2657
+
}
2658
+
2659
+
return nil
2660
+
}
2661
+
func (t *SailorStar_Subject) MarshalCBOR(w io.Writer) error {
2662
+
if t == nil {
2663
+
_, err := w.Write(cbg.CborNull)
2664
+
return err
2665
+
}
2666
+
2667
+
cw := cbg.NewCborWriter(w)
2668
+
fieldCount := 3
2669
+
2670
+
if t.LexiconTypeID == "" {
2671
+
fieldCount--
2672
+
}
2673
+
2674
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2675
+
return err
2676
+
}
2677
+
2678
+
// t.Did (string) (string)
2679
+
if len("did") > 8192 {
2680
+
return xerrors.Errorf("Value in field \"did\" was too long")
2681
+
}
2682
+
2683
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("did"))); err != nil {
2684
+
return err
2685
+
}
2686
+
if _, err := cw.WriteString(string("did")); err != nil {
2687
+
return err
2688
+
}
2689
+
2690
+
if len(t.Did) > 8192 {
2691
+
return xerrors.Errorf("Value in field t.Did was too long")
2692
+
}
2693
+
2694
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Did))); err != nil {
2695
+
return err
2696
+
}
2697
+
if _, err := cw.WriteString(string(t.Did)); err != nil {
2698
+
return err
2699
+
}
2700
+
2701
+
// t.LexiconTypeID (string) (string)
2702
+
if t.LexiconTypeID != "" {
2703
+
2704
+
if len("$type") > 8192 {
2705
+
return xerrors.Errorf("Value in field \"$type\" was too long")
2706
+
}
2707
+
2708
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
2709
+
return err
2710
+
}
2711
+
if _, err := cw.WriteString(string("$type")); err != nil {
2712
+
return err
2713
+
}
2714
+
2715
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.sailor.star#subject"))); err != nil {
2716
+
return err
2717
+
}
2718
+
if _, err := cw.WriteString(string("io.atcr.sailor.star#subject")); err != nil {
2719
+
return err
2720
+
}
2721
+
}
2722
+
2723
+
// t.Repository (string) (string)
2724
+
if len("repository") > 8192 {
2725
+
return xerrors.Errorf("Value in field \"repository\" was too long")
2726
+
}
2727
+
2728
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
2729
+
return err
2730
+
}
2731
+
if _, err := cw.WriteString(string("repository")); err != nil {
2732
+
return err
2733
+
}
2734
+
2735
+
if len(t.Repository) > 8192 {
2736
+
return xerrors.Errorf("Value in field t.Repository was too long")
2737
+
}
2738
+
2739
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
2740
+
return err
2741
+
}
2742
+
if _, err := cw.WriteString(string(t.Repository)); err != nil {
2743
+
return err
2744
+
}
2745
+
return nil
2746
+
}
2747
+
2748
+
func (t *SailorStar_Subject) UnmarshalCBOR(r io.Reader) (err error) {
2749
+
*t = SailorStar_Subject{}
2750
+
2751
+
cr := cbg.NewCborReader(r)
2752
+
2753
+
maj, extra, err := cr.ReadHeader()
2754
+
if err != nil {
2755
+
return err
2756
+
}
2757
+
defer func() {
2758
+
if err == io.EOF {
2759
+
err = io.ErrUnexpectedEOF
2760
+
}
2761
+
}()
2762
+
2763
+
if maj != cbg.MajMap {
2764
+
return fmt.Errorf("cbor input should be of type map")
2765
+
}
2766
+
2767
+
if extra > cbg.MaxLength {
2768
+
return fmt.Errorf("SailorStar_Subject: map struct too large (%d)", extra)
2769
+
}
2770
+
2771
+
n := extra
2772
+
2773
+
nameBuf := make([]byte, 10)
2774
+
for i := uint64(0); i < n; i++ {
2775
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
2776
+
if err != nil {
2777
+
return err
2778
+
}
2779
+
2780
+
if !ok {
2781
+
// Field doesn't exist on this type, so ignore it
2782
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2783
+
return err
2784
+
}
2785
+
continue
2786
+
}
2787
+
2788
+
switch string(nameBuf[:nameLen]) {
2789
+
// t.Did (string) (string)
2790
+
case "did":
2791
+
2792
+
{
2793
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2794
+
if err != nil {
2795
+
return err
2796
+
}
2797
+
2798
+
t.Did = string(sval)
2799
+
}
2800
+
// t.LexiconTypeID (string) (string)
2801
+
case "$type":
2802
+
2803
+
{
2804
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2805
+
if err != nil {
2806
+
return err
2807
+
}
2808
+
2809
+
t.LexiconTypeID = string(sval)
2810
+
}
2811
+
// t.Repository (string) (string)
2812
+
case "repository":
2813
+
2814
+
{
2815
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
2816
+
if err != nil {
2817
+
return err
2818
+
}
2819
+
2820
+
t.Repository = string(sval)
2821
+
}
2822
+
2823
+
default:
2824
+
// Field doesn't exist on this type, so ignore it
2825
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2826
+
return err
2827
+
}
2828
+
}
2829
+
}
2830
+
2831
+
return nil
2832
+
}
2833
+
func (t *HoldCaptain) MarshalCBOR(w io.Writer) error {
2834
+
if t == nil {
2835
+
_, err := w.Write(cbg.CborNull)
2836
+
return err
2837
+
}
2838
+
2839
+
cw := cbg.NewCborWriter(w)
2840
+
fieldCount := 8
2841
+
2842
+
if t.Provider == nil {
2843
+
fieldCount--
2844
+
}
2845
+
2846
+
if t.Region == nil {
2847
+
fieldCount--
2848
+
}
2849
+
2850
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2851
+
return err
2852
+
}
2853
+
2854
+
// t.LexiconTypeID (string) (string)
2855
+
if len("$type") > 8192 {
2856
+
return xerrors.Errorf("Value in field \"$type\" was too long")
2857
+
}
2858
+
2859
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
2860
+
return err
2861
+
}
2862
+
if _, err := cw.WriteString(string("$type")); err != nil {
2863
+
return err
2864
+
}
2865
+
2866
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.captain"))); err != nil {
2867
+
return err
2868
+
}
2869
+
if _, err := cw.WriteString(string("io.atcr.hold.captain")); err != nil {
337
2870
return err
338
2871
}
339
2872
···
377
2910
}
378
2911
379
2912
// t.Region (string) (string)
380
-
if t.Region != "" {
2913
+
if t.Region != nil {
381
2914
382
2915
if len("region") > 8192 {
383
2916
return xerrors.Errorf("Value in field \"region\" was too long")
···
390
2923
return err
391
2924
}
392
2925
393
-
if len(t.Region) > 8192 {
394
-
return xerrors.Errorf("Value in field t.Region was too long")
395
-
}
2926
+
if t.Region == nil {
2927
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2928
+
return err
2929
+
}
2930
+
} else {
2931
+
if len(*t.Region) > 8192 {
2932
+
return xerrors.Errorf("Value in field t.Region was too long")
2933
+
}
396
2934
397
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Region))); err != nil {
398
-
return err
399
-
}
400
-
if _, err := cw.WriteString(string(t.Region)); err != nil {
401
-
return err
2935
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Region))); err != nil {
2936
+
return err
2937
+
}
2938
+
if _, err := cw.WriteString(string(*t.Region)); err != nil {
2939
+
return err
2940
+
}
402
2941
}
403
2942
}
404
2943
405
2944
// t.Provider (string) (string)
406
-
if t.Provider != "" {
2945
+
if t.Provider != nil {
407
2946
408
2947
if len("provider") > 8192 {
409
2948
return xerrors.Errorf("Value in field \"provider\" was too long")
···
416
2955
return err
417
2956
}
418
2957
419
-
if len(t.Provider) > 8192 {
420
-
return xerrors.Errorf("Value in field t.Provider was too long")
421
-
}
2958
+
if t.Provider == nil {
2959
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2960
+
return err
2961
+
}
2962
+
} else {
2963
+
if len(*t.Provider) > 8192 {
2964
+
return xerrors.Errorf("Value in field t.Provider was too long")
2965
+
}
422
2966
423
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Provider))); err != nil {
424
-
return err
425
-
}
426
-
if _, err := cw.WriteString(string(t.Provider)); err != nil {
427
-
return err
2967
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Provider))); err != nil {
2968
+
return err
2969
+
}
2970
+
if _, err := cw.WriteString(string(*t.Provider)); err != nil {
2971
+
return err
2972
+
}
428
2973
}
429
2974
}
430
2975
···
485
3030
return nil
486
3031
}
487
3032
488
-
func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
489
-
*t = CaptainRecord{}
3033
+
func (t *HoldCaptain) UnmarshalCBOR(r io.Reader) (err error) {
3034
+
*t = HoldCaptain{}
490
3035
491
3036
cr := cbg.NewCborReader(r)
492
3037
···
505
3050
}
506
3051
507
3052
if extra > cbg.MaxLength {
508
-
return fmt.Errorf("CaptainRecord: map struct too large (%d)", extra)
3053
+
return fmt.Errorf("HoldCaptain: map struct too large (%d)", extra)
509
3054
}
510
3055
511
3056
n := extra
···
526
3071
}
527
3072
528
3073
switch string(nameBuf[:nameLen]) {
529
-
// t.Type (string) (string)
3074
+
// t.LexiconTypeID (string) (string)
530
3075
case "$type":
531
3076
532
3077
{
···
535
3080
return err
536
3081
}
537
3082
538
-
t.Type = string(sval)
3083
+
t.LexiconTypeID = string(sval)
539
3084
}
540
3085
// t.Owner (string) (string)
541
3086
case "owner":
···
570
3115
case "region":
571
3116
572
3117
{
573
-
sval, err := cbg.ReadStringWithMax(cr, 8192)
3118
+
b, err := cr.ReadByte()
574
3119
if err != nil {
575
3120
return err
576
3121
}
3122
+
if b != cbg.CborNull[0] {
3123
+
if err := cr.UnreadByte(); err != nil {
3124
+
return err
3125
+
}
577
3126
578
-
t.Region = string(sval)
3127
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
3128
+
if err != nil {
3129
+
return err
3130
+
}
3131
+
3132
+
t.Region = (*string)(&sval)
3133
+
}
579
3134
}
580
3135
// t.Provider (string) (string)
581
3136
case "provider":
582
3137
583
3138
{
584
-
sval, err := cbg.ReadStringWithMax(cr, 8192)
3139
+
b, err := cr.ReadByte()
585
3140
if err != nil {
586
3141
return err
587
3142
}
3143
+
if b != cbg.CborNull[0] {
3144
+
if err := cr.UnreadByte(); err != nil {
3145
+
return err
3146
+
}
588
3147
589
-
t.Provider = string(sval)
3148
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
3149
+
if err != nil {
3150
+
return err
3151
+
}
3152
+
3153
+
t.Provider = (*string)(&sval)
3154
+
}
590
3155
}
591
3156
// t.DeployedAt (string) (string)
592
3157
case "deployedAt":
···
646
3211
647
3212
return nil
648
3213
}
649
-
func (t *LayerRecord) MarshalCBOR(w io.Writer) error {
3214
+
func (t *HoldCrew) MarshalCBOR(w io.Writer) error {
3215
+
if t == nil {
3216
+
_, err := w.Write(cbg.CborNull)
3217
+
return err
3218
+
}
3219
+
3220
+
cw := cbg.NewCborWriter(w)
3221
+
3222
+
if _, err := cw.Write([]byte{165}); err != nil {
3223
+
return err
3224
+
}
3225
+
3226
+
// t.Role (string) (string)
3227
+
if len("role") > 8192 {
3228
+
return xerrors.Errorf("Value in field \"role\" was too long")
3229
+
}
3230
+
3231
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("role"))); err != nil {
3232
+
return err
3233
+
}
3234
+
if _, err := cw.WriteString(string("role")); err != nil {
3235
+
return err
3236
+
}
3237
+
3238
+
if len(t.Role) > 8192 {
3239
+
return xerrors.Errorf("Value in field t.Role was too long")
3240
+
}
3241
+
3242
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Role))); err != nil {
3243
+
return err
3244
+
}
3245
+
if _, err := cw.WriteString(string(t.Role)); err != nil {
3246
+
return err
3247
+
}
3248
+
3249
+
// t.LexiconTypeID (string) (string)
3250
+
if len("$type") > 8192 {
3251
+
return xerrors.Errorf("Value in field \"$type\" was too long")
3252
+
}
3253
+
3254
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
3255
+
return err
3256
+
}
3257
+
if _, err := cw.WriteString(string("$type")); err != nil {
3258
+
return err
3259
+
}
3260
+
3261
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.crew"))); err != nil {
3262
+
return err
3263
+
}
3264
+
if _, err := cw.WriteString(string("io.atcr.hold.crew")); err != nil {
3265
+
return err
3266
+
}
3267
+
3268
+
// t.Member (string) (string)
3269
+
if len("member") > 8192 {
3270
+
return xerrors.Errorf("Value in field \"member\" was too long")
3271
+
}
3272
+
3273
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil {
3274
+
return err
3275
+
}
3276
+
if _, err := cw.WriteString(string("member")); err != nil {
3277
+
return err
3278
+
}
3279
+
3280
+
if len(t.Member) > 8192 {
3281
+
return xerrors.Errorf("Value in field t.Member was too long")
3282
+
}
3283
+
3284
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil {
3285
+
return err
3286
+
}
3287
+
if _, err := cw.WriteString(string(t.Member)); err != nil {
3288
+
return err
3289
+
}
3290
+
3291
+
// t.AddedAt (string) (string)
3292
+
if len("addedAt") > 8192 {
3293
+
return xerrors.Errorf("Value in field \"addedAt\" was too long")
3294
+
}
3295
+
3296
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil {
3297
+
return err
3298
+
}
3299
+
if _, err := cw.WriteString(string("addedAt")); err != nil {
3300
+
return err
3301
+
}
3302
+
3303
+
if len(t.AddedAt) > 8192 {
3304
+
return xerrors.Errorf("Value in field t.AddedAt was too long")
3305
+
}
3306
+
3307
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.AddedAt))); err != nil {
3308
+
return err
3309
+
}
3310
+
if _, err := cw.WriteString(string(t.AddedAt)); err != nil {
3311
+
return err
3312
+
}
3313
+
3314
+
// t.Permissions ([]string) (slice)
3315
+
if len("permissions") > 8192 {
3316
+
return xerrors.Errorf("Value in field \"permissions\" was too long")
3317
+
}
3318
+
3319
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("permissions"))); err != nil {
3320
+
return err
3321
+
}
3322
+
if _, err := cw.WriteString(string("permissions")); err != nil {
3323
+
return err
3324
+
}
3325
+
3326
+
if len(t.Permissions) > 8192 {
3327
+
return xerrors.Errorf("Slice value in field t.Permissions was too long")
3328
+
}
3329
+
3330
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Permissions))); err != nil {
3331
+
return err
3332
+
}
3333
+
for _, v := range t.Permissions {
3334
+
if len(v) > 8192 {
3335
+
return xerrors.Errorf("Value in field v was too long")
3336
+
}
3337
+
3338
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
3339
+
return err
3340
+
}
3341
+
if _, err := cw.WriteString(string(v)); err != nil {
3342
+
return err
3343
+
}
3344
+
3345
+
}
3346
+
return nil
3347
+
}
3348
+
3349
+
func (t *HoldCrew) UnmarshalCBOR(r io.Reader) (err error) {
3350
+
*t = HoldCrew{}
3351
+
3352
+
cr := cbg.NewCborReader(r)
3353
+
3354
+
maj, extra, err := cr.ReadHeader()
3355
+
if err != nil {
3356
+
return err
3357
+
}
3358
+
defer func() {
3359
+
if err == io.EOF {
3360
+
err = io.ErrUnexpectedEOF
3361
+
}
3362
+
}()
3363
+
3364
+
if maj != cbg.MajMap {
3365
+
return fmt.Errorf("cbor input should be of type map")
3366
+
}
3367
+
3368
+
if extra > cbg.MaxLength {
3369
+
return fmt.Errorf("HoldCrew: map struct too large (%d)", extra)
3370
+
}
3371
+
3372
+
n := extra
3373
+
3374
+
nameBuf := make([]byte, 11)
3375
+
for i := uint64(0); i < n; i++ {
3376
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
3377
+
if err != nil {
3378
+
return err
3379
+
}
3380
+
3381
+
if !ok {
3382
+
// Field doesn't exist on this type, so ignore it
3383
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3384
+
return err
3385
+
}
3386
+
continue
3387
+
}
3388
+
3389
+
switch string(nameBuf[:nameLen]) {
3390
+
// t.Role (string) (string)
3391
+
case "role":
3392
+
3393
+
{
3394
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
3395
+
if err != nil {
3396
+
return err
3397
+
}
3398
+
3399
+
t.Role = string(sval)
3400
+
}
3401
+
// t.LexiconTypeID (string) (string)
3402
+
case "$type":
3403
+
3404
+
{
3405
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
3406
+
if err != nil {
3407
+
return err
3408
+
}
3409
+
3410
+
t.LexiconTypeID = string(sval)
3411
+
}
3412
+
// t.Member (string) (string)
3413
+
case "member":
3414
+
3415
+
{
3416
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
3417
+
if err != nil {
3418
+
return err
3419
+
}
3420
+
3421
+
t.Member = string(sval)
3422
+
}
3423
+
// t.AddedAt (string) (string)
3424
+
case "addedAt":
3425
+
3426
+
{
3427
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
3428
+
if err != nil {
3429
+
return err
3430
+
}
3431
+
3432
+
t.AddedAt = string(sval)
3433
+
}
3434
+
// t.Permissions ([]string) (slice)
3435
+
case "permissions":
3436
+
3437
+
maj, extra, err = cr.ReadHeader()
3438
+
if err != nil {
3439
+
return err
3440
+
}
3441
+
3442
+
if extra > 8192 {
3443
+
return fmt.Errorf("t.Permissions: array too large (%d)", extra)
3444
+
}
3445
+
3446
+
if maj != cbg.MajArray {
3447
+
return fmt.Errorf("expected cbor array")
3448
+
}
3449
+
3450
+
if extra > 0 {
3451
+
t.Permissions = make([]string, extra)
3452
+
}
3453
+
3454
+
for i := 0; i < int(extra); i++ {
3455
+
{
3456
+
var maj byte
3457
+
var extra uint64
3458
+
var err error
3459
+
_ = maj
3460
+
_ = extra
3461
+
_ = err
3462
+
3463
+
{
3464
+
sval, err := cbg.ReadStringWithMax(cr, 8192)
3465
+
if err != nil {
3466
+
return err
3467
+
}
3468
+
3469
+
t.Permissions[i] = string(sval)
3470
+
}
3471
+
3472
+
}
3473
+
}
3474
+
3475
+
default:
3476
+
// Field doesn't exist on this type, so ignore it
3477
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3478
+
return err
3479
+
}
3480
+
}
3481
+
}
3482
+
3483
+
return nil
3484
+
}
3485
+
func (t *HoldLayer) MarshalCBOR(w io.Writer) error {
650
3486
if t == nil {
651
3487
_, err := w.Write(cbg.CborNull)
652
3488
return err
···
680
3516
}
681
3517
}
682
3518
683
-
// t.Type (string) (string)
3519
+
// t.LexiconTypeID (string) (string)
684
3520
if len("$type") > 8192 {
685
3521
return xerrors.Errorf("Value in field \"$type\" was too long")
686
3522
}
···
692
3528
return err
693
3529
}
694
3530
695
-
if len(t.Type) > 8192 {
696
-
return xerrors.Errorf("Value in field t.Type was too long")
697
-
}
698
-
699
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
3531
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("io.atcr.hold.layer"))); err != nil {
700
3532
return err
701
3533
}
702
-
if _, err := cw.WriteString(string(t.Type)); err != nil {
3534
+
if _, err := cw.WriteString(string("io.atcr.hold.layer")); err != nil {
703
3535
return err
704
3536
}
705
3537
···
726
3558
return err
727
3559
}
728
3560
729
-
// t.UserDID (string) (string)
3561
+
// t.UserDid (string) (string)
730
3562
if len("userDid") > 8192 {
731
3563
return xerrors.Errorf("Value in field \"userDid\" was too long")
732
3564
}
···
738
3570
return err
739
3571
}
740
3572
741
-
if len(t.UserDID) > 8192 {
742
-
return xerrors.Errorf("Value in field t.UserDID was too long")
3573
+
if len(t.UserDid) > 8192 {
3574
+
return xerrors.Errorf("Value in field t.UserDid was too long")
743
3575
}
744
3576
745
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil {
3577
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDid))); err != nil {
746
3578
return err
747
3579
}
748
-
if _, err := cw.WriteString(string(t.UserDID)); err != nil {
3580
+
if _, err := cw.WriteString(string(t.UserDid)); err != nil {
749
3581
return err
750
3582
}
751
3583
···
843
3675
return nil
844
3676
}
845
3677
846
-
func (t *LayerRecord) UnmarshalCBOR(r io.Reader) (err error) {
847
-
*t = LayerRecord{}
3678
+
func (t *HoldLayer) UnmarshalCBOR(r io.Reader) (err error) {
3679
+
*t = HoldLayer{}
848
3680
849
3681
cr := cbg.NewCborReader(r)
850
3682
···
863
3695
}
864
3696
865
3697
if extra > cbg.MaxLength {
866
-
return fmt.Errorf("LayerRecord: map struct too large (%d)", extra)
3698
+
return fmt.Errorf("HoldLayer: map struct too large (%d)", extra)
867
3699
}
868
3700
869
3701
n := extra
···
910
3742
911
3743
t.Size = int64(extraI)
912
3744
}
913
-
// t.Type (string) (string)
3745
+
// t.LexiconTypeID (string) (string)
914
3746
case "$type":
915
3747
916
3748
{
···
919
3751
return err
920
3752
}
921
3753
922
-
t.Type = string(sval)
3754
+
t.LexiconTypeID = string(sval)
923
3755
}
924
3756
// t.Digest (string) (string)
925
3757
case "digest":
···
932
3764
933
3765
t.Digest = string(sval)
934
3766
}
935
-
// t.UserDID (string) (string)
3767
+
// t.UserDid (string) (string)
936
3768
case "userDid":
937
3769
938
3770
{
···
941
3773
return err
942
3774
}
943
3775
944
-
t.UserDID = string(sval)
3776
+
t.UserDid = string(sval)
945
3777
}
946
3778
// t.CreatedAt (string) (string)
947
3779
case "createdAt":
+21
-7
pkg/atproto/client.go
+21
-7
pkg/atproto/client.go
···
13
13
14
14
"github.com/bluesky-social/indigo/atproto/atclient"
15
15
indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
16
+
lexutil "github.com/bluesky-social/indigo/lex/util"
17
+
"github.com/ipfs/go-cid"
16
18
)
17
19
18
20
// Sentinel errors
···
301
303
}
302
304
303
305
// UploadBlob uploads binary data to the PDS and returns a blob reference
304
-
func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) {
306
+
func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*lexutil.LexBlob, error) {
305
307
// Use session provider (locked OAuth with DPoP) - prevents nonce races
306
308
if c.sessionProvider != nil {
307
309
var result struct {
···
310
312
311
313
err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
312
314
apiClient := session.APIClient()
313
-
// IMPORTANT: Use io.Reader for blob uploads
314
-
// LexDo JSON-encodes []byte (base64), but streams io.Reader as raw bytes
315
-
// Use the actual MIME type so PDS can validate against blob:image/* scope
316
315
return apiClient.LexDo(ctx,
317
316
"POST",
318
317
mimeType,
319
318
"com.atproto.repo.uploadBlob",
320
319
nil,
321
-
bytes.NewReader(data),
320
+
data,
322
321
&result,
323
322
)
324
323
})
···
326
325
return nil, fmt.Errorf("uploadBlob failed: %w", err)
327
326
}
328
327
329
-
return &result.Blob, nil
328
+
return atProtoBlobRefToLexBlob(&result.Blob)
330
329
}
331
330
332
331
// Basic Auth (app passwords)
···
357
356
return nil, fmt.Errorf("failed to decode response: %w", err)
358
357
}
359
358
360
-
return &result.Blob, nil
359
+
return atProtoBlobRefToLexBlob(&result.Blob)
360
+
}
361
+
362
+
// atProtoBlobRefToLexBlob converts an ATProtoBlobRef to a lexutil.LexBlob
363
+
func atProtoBlobRefToLexBlob(ref *ATProtoBlobRef) (*lexutil.LexBlob, error) {
364
+
// Parse the CID string from the $link field
365
+
c, err := cid.Decode(ref.Ref.Link)
366
+
if err != nil {
367
+
return nil, fmt.Errorf("failed to parse blob CID %q: %w", ref.Ref.Link, err)
368
+
}
369
+
370
+
return &lexutil.LexBlob{
371
+
Ref: lexutil.LexLink(c),
372
+
MimeType: ref.MimeType,
373
+
Size: ref.Size,
374
+
}, nil
361
375
}
362
376
363
377
// GetBlob downloads a blob by its CID from the PDS
+8
-6
pkg/atproto/client_test.go
+8
-6
pkg/atproto/client_test.go
···
386
386
t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType)
387
387
}
388
388
389
-
// Send response
389
+
// Send response - use a valid CIDv1 in base32 format
390
390
response := `{
391
391
"blob": {
392
392
"$type": "blob",
393
-
"ref": {"$link": "bafytest123"},
393
+
"ref": {"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"},
394
394
"mimeType": "application/octet-stream",
395
395
"size": 17
396
396
}
···
406
406
t.Fatalf("UploadBlob() error = %v", err)
407
407
}
408
408
409
-
if blobRef.Type != "blob" {
410
-
t.Errorf("Type = %v, want blob", blobRef.Type)
409
+
if blobRef.MimeType != mimeType {
410
+
t.Errorf("MimeType = %v, want %v", blobRef.MimeType, mimeType)
411
411
}
412
412
413
-
if blobRef.Ref.Link != "bafytest123" {
414
-
t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link)
413
+
// LexBlob.Ref is a LexLink (cid.Cid alias), use .String() to get the CID string
414
+
expectedCID := "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
415
+
if blobRef.Ref.String() != expectedCID {
416
+
t.Errorf("Ref.String() = %v, want %v", blobRef.Ref.String(), expectedCID)
415
417
}
416
418
417
419
if blobRef.Size != 17 {
+255
-11
pkg/atproto/generate.go
+255
-11
pkg/atproto/generate.go
···
3
3
4
4
package main
5
5
6
-
// CBOR Code Generator
6
+
// Lexicon and CBOR Code Generator
7
7
//
8
-
// This generates optimized CBOR marshaling code for ATProto records.
8
+
// This generates:
9
+
// 1. Go types from lexicon JSON files (via lex/lexgen library)
10
+
// 2. CBOR marshaling code for ATProto records (via cbor-gen)
11
+
// 3. Type registration for lexutil (register.go)
9
12
//
10
13
// Usage:
11
14
// go generate ./pkg/atproto/...
12
15
//
13
-
// This creates pkg/atproto/cbor_gen.go which should be committed to git.
14
-
// Only re-run when you modify types in pkg/atproto/types.go
15
-
//
16
-
// The //go:generate directive is in lexicon.go
16
+
// Key insight: We use RegisterLexiconTypeID: false to avoid generating init()
17
+
// blocks that require CBORMarshaler. This breaks the circular dependency between
18
+
// lexgen and cbor-gen. See: https://github.com/bluesky-social/indigo/issues/931
19
+
20
+
import (
21
+
"bytes"
22
+
"encoding/json"
23
+
"fmt"
24
+
"os"
25
+
"os/exec"
26
+
"path/filepath"
27
+
"strings"
28
+
29
+
"github.com/bluesky-social/indigo/atproto/lexicon"
30
+
"github.com/bluesky-social/indigo/lex/lexgen"
31
+
"golang.org/x/tools/imports"
32
+
)
33
+
34
+
func main() {
35
+
// Find repo root
36
+
repoRoot, err := findRepoRoot()
37
+
if err != nil {
38
+
fmt.Printf("failed to find repo root: %v\n", err)
39
+
os.Exit(1)
40
+
}
41
+
42
+
pkgDir := filepath.Join(repoRoot, "pkg/atproto")
43
+
lexDir := filepath.Join(repoRoot, "lexicons")
44
+
45
+
// Step 0: Clean up old register.go to avoid conflicts
46
+
// (It will be regenerated at the end)
47
+
os.Remove(filepath.Join(pkgDir, "register.go"))
48
+
49
+
// Step 1: Load all lexicon schemas into catalog (for cross-references)
50
+
fmt.Println("Loading lexicons...")
51
+
cat := lexicon.NewBaseCatalog()
52
+
if err := cat.LoadDirectory(lexDir); err != nil {
53
+
fmt.Printf("failed to load lexicons: %v\n", err)
54
+
os.Exit(1)
55
+
}
56
+
57
+
// Step 2: Generate Go code for each lexicon file
58
+
fmt.Println("Running lexgen...")
59
+
config := &lexgen.GenConfig{
60
+
RegisterLexiconTypeID: false, // KEY: no init() blocks generated
61
+
UnknownType: "map-string-any",
62
+
WarningText: "Code generated by generate.go; DO NOT EDIT.",
63
+
}
64
+
65
+
// Track generated types for register.go
66
+
var registeredTypes []typeInfo
67
+
68
+
// Walk lexicon directory and generate code for each file
69
+
err = filepath.Walk(lexDir, func(path string, info os.FileInfo, err error) error {
70
+
if err != nil {
71
+
return err
72
+
}
73
+
if info.IsDir() || !strings.HasSuffix(path, ".json") {
74
+
return nil
75
+
}
76
+
77
+
// Load and parse the schema file
78
+
data, err := os.ReadFile(path)
79
+
if err != nil {
80
+
return fmt.Errorf("failed to read %s: %w", path, err)
81
+
}
82
+
83
+
var sf lexicon.SchemaFile
84
+
if err := json.Unmarshal(data, &sf); err != nil {
85
+
return fmt.Errorf("failed to parse %s: %w", path, err)
86
+
}
87
+
88
+
if err := sf.FinishParse(); err != nil {
89
+
return fmt.Errorf("failed to finish parse %s: %w", path, err)
90
+
}
91
+
92
+
// Flatten the schema
93
+
flat, err := lexgen.FlattenSchemaFile(&sf)
94
+
if err != nil {
95
+
return fmt.Errorf("failed to flatten schema %s: %w", path, err)
96
+
}
97
+
98
+
// Generate code
99
+
var buf bytes.Buffer
100
+
gen := &lexgen.CodeGenerator{
101
+
Config: config,
102
+
Lex: flat,
103
+
Cat: &cat,
104
+
Out: &buf,
105
+
}
106
+
107
+
if err := gen.WriteLexicon(); err != nil {
108
+
return fmt.Errorf("failed to generate code for %s: %w", path, err)
109
+
}
110
+
111
+
// Fix package name: lexgen generates "ioatcr" but we want "atproto"
112
+
code := bytes.Replace(buf.Bytes(), []byte("package ioatcr"), []byte("package atproto"), 1)
113
+
114
+
// Format with goimports
115
+
fileName := gen.FileName()
116
+
formatted, err := imports.Process(fileName, code, nil)
117
+
if err != nil {
118
+
// Write unformatted for debugging
119
+
outPath := filepath.Join(pkgDir, fileName)
120
+
os.WriteFile(outPath+".broken", code, 0644)
121
+
return fmt.Errorf("failed to format %s: %w (wrote to %s.broken)", fileName, err, outPath)
122
+
}
123
+
124
+
// Write output file
125
+
outPath := filepath.Join(pkgDir, fileName)
126
+
if err := os.WriteFile(outPath, formatted, 0644); err != nil {
127
+
return fmt.Errorf("failed to write %s: %w", outPath, err)
128
+
}
129
+
130
+
fmt.Printf(" Generated %s\n", fileName)
131
+
132
+
// Track type for registration - compute type name from NSID
133
+
typeName := nsidToTypeName(sf.ID)
134
+
registeredTypes = append(registeredTypes, typeInfo{
135
+
NSID: sf.ID,
136
+
TypeName: typeName,
137
+
})
138
+
139
+
return nil
140
+
})
141
+
if err != nil {
142
+
fmt.Printf("lexgen failed: %v\n", err)
143
+
os.Exit(1)
144
+
}
145
+
146
+
// Step 3: Run cbor-gen via exec.Command
147
+
// This must be a separate process so it can compile the freshly generated types
148
+
fmt.Println("Running cbor-gen...")
149
+
if err := runCborGen(repoRoot, pkgDir); err != nil {
150
+
fmt.Printf("cbor-gen failed: %v\n", err)
151
+
os.Exit(1)
152
+
}
153
+
154
+
// Step 4: Generate register.go
155
+
fmt.Println("Generating register.go...")
156
+
if err := generateRegisterFile(pkgDir, registeredTypes); err != nil {
157
+
fmt.Printf("failed to generate register.go: %v\n", err)
158
+
os.Exit(1)
159
+
}
160
+
161
+
fmt.Println("Code generation complete!")
162
+
}
163
+
164
+
type typeInfo struct {
165
+
NSID string
166
+
TypeName string
167
+
}
168
+
169
+
// nsidToTypeName converts an NSID to a Go type name
170
+
// io.atcr.manifest โ Manifest
171
+
// io.atcr.hold.captain โ HoldCaptain
172
+
// io.atcr.sailor.profile โ SailorProfile
173
+
func nsidToTypeName(nsid string) string {
174
+
parts := strings.Split(nsid, ".")
175
+
if len(parts) < 3 {
176
+
return ""
177
+
}
178
+
// Skip the first two parts (authority, e.g., "io.atcr")
179
+
// and capitalize each remaining part
180
+
var result string
181
+
for _, part := range parts[2:] {
182
+
if len(part) > 0 {
183
+
result += strings.ToUpper(part[:1]) + part[1:]
184
+
}
185
+
}
186
+
return result
187
+
}
188
+
189
+
func runCborGen(repoRoot, pkgDir string) error {
190
+
// Create a temporary Go file that runs cbor-gen
191
+
cborGenCode := `//go:build ignore
192
+
193
+
package main
17
194
18
195
import (
19
196
"fmt"
···
25
202
)
26
203
27
204
func main() {
28
-
// Generate map-style encoders for CrewRecord, CaptainRecord, LayerRecord, and TangledProfileRecord
29
205
if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
30
-
atproto.CrewRecord{},
31
-
atproto.CaptainRecord{},
32
-
atproto.LayerRecord{},
206
+
// Manifest types
207
+
atproto.Manifest{},
208
+
atproto.Manifest_BlobReference{},
209
+
atproto.Manifest_ManifestReference{},
210
+
atproto.Manifest_Platform{},
211
+
atproto.Manifest_Annotations{},
212
+
atproto.Manifest_BlobReference_Annotations{},
213
+
atproto.Manifest_ManifestReference_Annotations{},
214
+
// Tag
215
+
atproto.Tag{},
216
+
// Sailor types
217
+
atproto.SailorProfile{},
218
+
atproto.SailorStar{},
219
+
atproto.SailorStar_Subject{},
220
+
// Hold types
221
+
atproto.HoldCaptain{},
222
+
atproto.HoldCrew{},
223
+
atproto.HoldLayer{},
224
+
// External types
33
225
atproto.TangledProfileRecord{},
34
226
); err != nil {
35
-
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
227
+
fmt.Printf("cbor-gen failed: %v\n", err)
36
228
os.Exit(1)
37
229
}
38
230
}
231
+
`
232
+
233
+
// Write temp file
234
+
tmpFile := filepath.Join(pkgDir, "cborgen_tmp.go")
235
+
if err := os.WriteFile(tmpFile, []byte(cborGenCode), 0644); err != nil {
236
+
return fmt.Errorf("failed to write temp cbor-gen file: %w", err)
237
+
}
238
+
defer os.Remove(tmpFile)
239
+
240
+
// Run it
241
+
cmd := exec.Command("go", "run", tmpFile)
242
+
cmd.Dir = pkgDir
243
+
cmd.Stdout = os.Stdout
244
+
cmd.Stderr = os.Stderr
245
+
return cmd.Run()
246
+
}
247
+
248
+
func generateRegisterFile(pkgDir string, types []typeInfo) error {
249
+
var buf bytes.Buffer
250
+
251
+
buf.WriteString("// Code generated by generate.go; DO NOT EDIT.\n\n")
252
+
buf.WriteString("package atproto\n\n")
253
+
buf.WriteString("import lexutil \"github.com/bluesky-social/indigo/lex/util\"\n\n")
254
+
buf.WriteString("func init() {\n")
255
+
256
+
for _, t := range types {
257
+
fmt.Fprintf(&buf, "\tlexutil.RegisterType(%q, &%s{})\n", t.NSID, t.TypeName)
258
+
}
259
+
260
+
buf.WriteString("}\n")
261
+
262
+
outPath := filepath.Join(pkgDir, "register.go")
263
+
return os.WriteFile(outPath, buf.Bytes(), 0644)
264
+
}
265
+
266
+
func findRepoRoot() (string, error) {
267
+
dir, err := os.Getwd()
268
+
if err != nil {
269
+
return "", err
270
+
}
271
+
272
+
for {
273
+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
274
+
return dir, nil
275
+
}
276
+
parent := filepath.Dir(dir)
277
+
if parent == dir {
278
+
return "", fmt.Errorf("go.mod not found")
279
+
}
280
+
dir = parent
281
+
}
282
+
}
+24
pkg/atproto/holdcaptain.go
+24
pkg/atproto/holdcaptain.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
// Lexicon schema: io.atcr.hold.captain
4
+
5
+
package atproto
6
+
7
+
// Represents the hold's ownership and metadata. Stored as a singleton record at rkey 'self' in the hold's embedded PDS.
8
+
type HoldCaptain struct {
9
+
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.captain"`
10
+
// allowAllCrew: Allow any authenticated user to register as crew
11
+
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"`
12
+
// deployedAt: RFC3339 timestamp of when the hold was deployed
13
+
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"`
14
+
// enableBlueskyPosts: Enable Bluesky posts when manifests are pushed
15
+
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"`
16
+
// owner: DID of the hold owner
17
+
Owner string `json:"owner" cborgen:"owner"`
18
+
// provider: Deployment provider (e.g., fly.io, aws, etc.)
19
+
Provider *string `json:"provider,omitempty" cborgen:"provider,omitempty"`
20
+
// public: Whether this hold allows public blob reads (pulls) without authentication
21
+
Public bool `json:"public" cborgen:"public"`
22
+
// region: S3 region where blobs are stored
23
+
Region *string `json:"region,omitempty" cborgen:"region,omitempty"`
24
+
}
+18
pkg/atproto/holdcrew.go
+18
pkg/atproto/holdcrew.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
// Lexicon schema: io.atcr.hold.crew
4
+
5
+
package atproto
6
+
7
+
// Crew member in a hold's embedded PDS. Grants access permissions to push blobs to the hold. Stored in the hold's embedded PDS (one record per member).
8
+
type HoldCrew struct {
9
+
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.crew"`
10
+
// addedAt: RFC3339 timestamp of when the member was added
11
+
AddedAt string `json:"addedAt" cborgen:"addedAt"`
12
+
// member: DID of the crew member
13
+
Member string `json:"member" cborgen:"member"`
14
+
// permissions: Specific permissions granted to this member
15
+
Permissions []string `json:"permissions" cborgen:"permissions"`
16
+
// role: Member's role in the hold
17
+
Role string `json:"role" cborgen:"role"`
18
+
}
+24
pkg/atproto/holdlayer.go
+24
pkg/atproto/holdlayer.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
// Lexicon schema: io.atcr.hold.layer
4
+
5
+
package atproto
6
+
7
+
// Represents metadata about a container layer stored in the hold. Stored in the hold's embedded PDS for tracking and analytics.
8
+
type HoldLayer struct {
9
+
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.hold.layer"`
10
+
// createdAt: RFC3339 timestamp of when the layer was uploaded
11
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
12
+
// digest: Layer digest (e.g., sha256:abc123...)
13
+
Digest string `json:"digest" cborgen:"digest"`
14
+
// mediaType: Media type (e.g., application/vnd.oci.image.layer.v1.tar+gzip)
15
+
MediaType string `json:"mediaType" cborgen:"mediaType"`
16
+
// repository: Repository this layer belongs to
17
+
Repository string `json:"repository" cborgen:"repository"`
18
+
// size: Size in bytes
19
+
Size int64 `json:"size" cborgen:"size"`
20
+
// userDid: DID of user who uploaded this layer
21
+
UserDid string `json:"userDid" cborgen:"userDid"`
22
+
// userHandle: Handle of user (for display purposes)
23
+
UserHandle string `json:"userHandle" cborgen:"userHandle"`
24
+
}
+16
-40
pkg/atproto/lexicon.go
+16
-40
pkg/atproto/lexicon.go
···
18
18
// TagCollection is the collection name for image tags
19
19
TagCollection = "io.atcr.tag"
20
20
21
+
// HoldCollection is the collection name for storage holds (BYOS)
22
+
HoldCollection = "io.atcr.hold"
23
+
21
24
// HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model
22
25
// Stored in owner's PDS for BYOS holds
23
26
HoldCrewCollection = "io.atcr.hold.crew"
···
38
41
// TangledProfileCollection is the collection name for tangled profiles
39
42
// Stored in hold's embedded PDS (singleton record at rkey "self")
40
43
TangledProfileCollection = "sh.tangled.actor.profile"
44
+
45
+
// BskyPostCollection is the collection name for Bluesky posts
46
+
BskyPostCollection = "app.bsky.feed.post"
41
47
42
48
// BskyPostCollection is the collection name for Bluesky posts
43
49
BskyPostCollection = "app.bsky.feed.post"
···
47
53
48
54
// StarCollection is the collection name for repository stars
49
55
StarCollection = "io.atcr.sailor.star"
50
-
51
-
// RepoPageCollection is the collection name for repository page metadata
52
-
// Stored in user's PDS with rkey = repository name
53
-
RepoPageCollection = "io.atcr.repo.page"
54
56
)
55
57
56
58
// ManifestRecord represents a container image manifest stored in ATProto
···
310
312
CreatedAt time.Time `json:"createdAt"`
311
313
}
312
314
315
+
// NewHoldRecord creates a new hold record
316
+
func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord {
317
+
return &HoldRecord{
318
+
Type: HoldCollection,
319
+
Endpoint: endpoint,
320
+
Owner: owner,
321
+
Public: public,
322
+
CreatedAt: time.Now(),
323
+
}
324
+
}
313
325
314
326
// SailorProfileRecord represents a user's profile with registry preferences
315
327
// Stored in the user's PDS to configure default hold and other settings
···
336
348
return &SailorProfileRecord{
337
349
Type: SailorProfileCollection,
338
350
DefaultHold: defaultHold,
339
-
CreatedAt: now,
340
-
UpdatedAt: now,
341
-
}
342
-
}
343
-
344
-
// RepoPageRecord represents repository page metadata (description + avatar)
345
-
// Stored in the user's PDS with rkey = repository name
346
-
// Users can edit this directly in their PDS to customize their repository page
347
-
type RepoPageRecord struct {
348
-
// Type should be "io.atcr.repo.page"
349
-
Type string `json:"$type"`
350
-
351
-
// Repository is the name of the repository (e.g., "myapp")
352
-
Repository string `json:"repository"`
353
-
354
-
// Description is the markdown README/description content
355
-
Description string `json:"description,omitempty"`
356
-
357
-
// Avatar is the repository avatar/icon blob reference
358
-
Avatar *ATProtoBlobRef `json:"avatar,omitempty"`
359
-
360
-
// CreatedAt timestamp
361
-
CreatedAt time.Time `json:"createdAt"`
362
-
363
-
// UpdatedAt timestamp
364
-
UpdatedAt time.Time `json:"updatedAt"`
365
-
}
366
-
367
-
// NewRepoPageRecord creates a new repo page record
368
-
func NewRepoPageRecord(repository, description string, avatar *ATProtoBlobRef) *RepoPageRecord {
369
-
now := time.Now()
370
-
return &RepoPageRecord{
371
-
Type: RepoPageCollection,
372
-
Repository: repository,
373
-
Description: description,
374
-
Avatar: avatar,
375
351
CreatedAt: now,
376
352
UpdatedAt: now,
377
353
}
+18
pkg/atproto/lexicon_embedded.go
+18
pkg/atproto/lexicon_embedded.go
···
1
+
package atproto
2
+
3
+
// This file contains ATProto record types that are NOT generated from our lexicons.
4
+
// These are either external schemas or special types that require manual definition.
5
+
6
+
// TangledProfileRecord represents a Tangled profile for the hold
7
+
// Collection: sh.tangled.actor.profile (external schema - not controlled by ATCR)
8
+
// Stored in hold's embedded PDS (singleton record at rkey "self")
9
+
// Uses CBOR encoding for efficient storage in hold's carstore
10
+
type TangledProfileRecord struct {
11
+
Type string `json:"$type" cborgen:"$type"`
12
+
Links []string `json:"links" cborgen:"links"`
13
+
Stats []string `json:"stats" cborgen:"stats"`
14
+
Bluesky bool `json:"bluesky" cborgen:"bluesky"`
15
+
Location string `json:"location" cborgen:"location"`
16
+
Description string `json:"description" cborgen:"description"`
17
+
PinnedRepositories []string `json:"pinnedRepositories" cborgen:"pinnedRepositories"`
18
+
}
+360
pkg/atproto/lexicon_helpers.go
+360
pkg/atproto/lexicon_helpers.go
···
1
+
package atproto
2
+
3
+
//go:generate go run generate.go
4
+
5
+
import (
6
+
"encoding/base64"
7
+
"encoding/json"
8
+
"fmt"
9
+
"strings"
10
+
"time"
11
+
)
12
+
13
+
// Collection names for ATProto records
14
+
const (
15
+
// ManifestCollection is the collection name for container manifests
16
+
ManifestCollection = "io.atcr.manifest"
17
+
18
+
// TagCollection is the collection name for image tags
19
+
TagCollection = "io.atcr.tag"
20
+
21
+
// HoldCollection is the collection name for storage holds (BYOS) - LEGACY
22
+
HoldCollection = "io.atcr.hold"
23
+
24
+
// HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model
25
+
// Stored in owner's PDS for BYOS holds
26
+
HoldCrewCollection = "io.atcr.hold.crew"
27
+
28
+
// CaptainCollection is the collection name for captain records (hold ownership) - EMBEDDED PDS model
29
+
// Stored in hold's embedded PDS (singleton record at rkey "self")
30
+
CaptainCollection = "io.atcr.hold.captain"
31
+
32
+
// CrewCollection is the collection name for crew records (access control) - EMBEDDED PDS model
33
+
// Stored in hold's embedded PDS (one record per member)
34
+
// Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS)
35
+
CrewCollection = "io.atcr.hold.crew"
36
+
37
+
// LayerCollection is the collection name for container layer metadata
38
+
// Stored in hold's embedded PDS to track which layers are stored
39
+
LayerCollection = "io.atcr.hold.layer"
40
+
41
+
// TangledProfileCollection is the collection name for tangled profiles
42
+
// Stored in hold's embedded PDS (singleton record at rkey "self")
43
+
TangledProfileCollection = "sh.tangled.actor.profile"
44
+
45
+
// BskyPostCollection is the collection name for Bluesky posts
46
+
BskyPostCollection = "app.bsky.feed.post"
47
+
48
+
// SailorProfileCollection is the collection name for user profiles
49
+
SailorProfileCollection = "io.atcr.sailor.profile"
50
+
51
+
// StarCollection is the collection name for repository stars
52
+
StarCollection = "io.atcr.sailor.star"
53
+
)
54
+
55
+
// NewManifestRecord creates a new manifest record from OCI manifest JSON
56
+
func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest, error) {
57
+
// Parse the OCI manifest
58
+
var ociData struct {
59
+
SchemaVersion int `json:"schemaVersion"`
60
+
MediaType string `json:"mediaType"`
61
+
Config json.RawMessage `json:"config,omitempty"`
62
+
Layers []json.RawMessage `json:"layers,omitempty"`
63
+
Manifests []json.RawMessage `json:"manifests,omitempty"`
64
+
Subject json.RawMessage `json:"subject,omitempty"`
65
+
Annotations map[string]string `json:"annotations,omitempty"`
66
+
}
67
+
68
+
if err := json.Unmarshal(ociManifest, &ociData); err != nil {
69
+
return nil, err
70
+
}
71
+
72
+
// Detect manifest type based on media type
73
+
isManifestList := strings.Contains(ociData.MediaType, "manifest.list") ||
74
+
strings.Contains(ociData.MediaType, "image.index")
75
+
76
+
// Validate: must have either (config+layers) OR (manifests), never both
77
+
hasImageFields := len(ociData.Config) > 0 || len(ociData.Layers) > 0
78
+
hasIndexFields := len(ociData.Manifests) > 0
79
+
80
+
if hasImageFields && hasIndexFields {
81
+
return nil, fmt.Errorf("manifest cannot have both image fields (config/layers) and index fields (manifests)")
82
+
}
83
+
if !hasImageFields && !hasIndexFields {
84
+
return nil, fmt.Errorf("manifest must have either image fields (config/layers) or index fields (manifests)")
85
+
}
86
+
87
+
record := &Manifest{
88
+
LexiconTypeID: ManifestCollection,
89
+
Repository: repository,
90
+
Digest: digest,
91
+
MediaType: ociData.MediaType,
92
+
SchemaVersion: int64(ociData.SchemaVersion),
93
+
// ManifestBlob will be set by the caller after uploading to blob storage
94
+
CreatedAt: time.Now().Format(time.RFC3339),
95
+
}
96
+
97
+
// Handle annotations - Manifest_Annotations is an empty struct in generated code
98
+
// We don't copy ociData.Annotations since the generated type doesn't support arbitrary keys
99
+
100
+
if isManifestList {
101
+
// Parse manifest list/index
102
+
record.Manifests = make([]Manifest_ManifestReference, len(ociData.Manifests))
103
+
for i, m := range ociData.Manifests {
104
+
var ref struct {
105
+
MediaType string `json:"mediaType"`
106
+
Digest string `json:"digest"`
107
+
Size int64 `json:"size"`
108
+
Platform *Manifest_Platform `json:"platform,omitempty"`
109
+
Annotations map[string]string `json:"annotations,omitempty"`
110
+
}
111
+
if err := json.Unmarshal(m, &ref); err != nil {
112
+
return nil, fmt.Errorf("failed to parse manifest reference %d: %w", i, err)
113
+
}
114
+
record.Manifests[i] = Manifest_ManifestReference{
115
+
MediaType: ref.MediaType,
116
+
Digest: ref.Digest,
117
+
Size: ref.Size,
118
+
Platform: ref.Platform,
119
+
}
120
+
}
121
+
} else {
122
+
// Parse image manifest
123
+
if len(ociData.Config) > 0 {
124
+
var config Manifest_BlobReference
125
+
if err := json.Unmarshal(ociData.Config, &config); err != nil {
126
+
return nil, fmt.Errorf("failed to parse config: %w", err)
127
+
}
128
+
record.Config = &config
129
+
}
130
+
131
+
// Parse layers
132
+
record.Layers = make([]Manifest_BlobReference, len(ociData.Layers))
133
+
for i, layer := range ociData.Layers {
134
+
if err := json.Unmarshal(layer, &record.Layers[i]); err != nil {
135
+
return nil, fmt.Errorf("failed to parse layer %d: %w", i, err)
136
+
}
137
+
}
138
+
}
139
+
140
+
// Parse subject if present (works for both types)
141
+
if len(ociData.Subject) > 0 {
142
+
var subject Manifest_BlobReference
143
+
if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
144
+
return nil, err
145
+
}
146
+
record.Subject = &subject
147
+
}
148
+
149
+
return record, nil
150
+
}
151
+
152
+
// NewTagRecord creates a new tag record with manifest AT-URI
153
+
// did: The DID of the user (e.g., "did:plc:xyz123")
154
+
// repository: The repository name (e.g., "myapp")
155
+
// tag: The tag name (e.g., "latest", "v1.0.0")
156
+
// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
157
+
func NewTagRecord(did, repository, tag, manifestDigest string) *Tag {
158
+
// Build AT-URI for the manifest
159
+
// Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
160
+
manifestURI := BuildManifestURI(did, manifestDigest)
161
+
162
+
return &Tag{
163
+
LexiconTypeID: TagCollection,
164
+
Repository: repository,
165
+
Tag: tag,
166
+
Manifest: &manifestURI,
167
+
// Note: ManifestDigest is not set for new records (only for backward compat with old records)
168
+
CreatedAt: time.Now().Format(time.RFC3339),
169
+
}
170
+
}
171
+
172
+
// NewSailorProfileRecord creates a new sailor profile record
173
+
func NewSailorProfileRecord(defaultHold string) *SailorProfile {
174
+
now := time.Now().Format(time.RFC3339)
175
+
var holdPtr *string
176
+
if defaultHold != "" {
177
+
holdPtr = &defaultHold
178
+
}
179
+
return &SailorProfile{
180
+
LexiconTypeID: SailorProfileCollection,
181
+
DefaultHold: holdPtr,
182
+
CreatedAt: now,
183
+
UpdatedAt: &now,
184
+
}
185
+
}
186
+
187
+
// NewStarRecord creates a new star record
188
+
func NewStarRecord(ownerDID, repository string) *SailorStar {
189
+
return &SailorStar{
190
+
LexiconTypeID: StarCollection,
191
+
Subject: SailorStar_Subject{
192
+
Did: ownerDID,
193
+
Repository: repository,
194
+
},
195
+
CreatedAt: time.Now().Format(time.RFC3339),
196
+
}
197
+
}
198
+
199
+
// NewLayerRecord creates a new layer record
200
+
func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *HoldLayer {
201
+
return &HoldLayer{
202
+
LexiconTypeID: LayerCollection,
203
+
Digest: digest,
204
+
Size: size,
205
+
MediaType: mediaType,
206
+
Repository: repository,
207
+
UserDid: userDID,
208
+
UserHandle: userHandle,
209
+
CreatedAt: time.Now().Format(time.RFC3339),
210
+
}
211
+
}
212
+
213
+
// StarRecordKey generates a record key for a star
214
+
// Uses a simple hash to ensure uniqueness and prevent duplicate stars
215
+
func StarRecordKey(ownerDID, repository string) string {
216
+
// Use base64 encoding of "ownerDID/repository" as the record key
217
+
// This is deterministic and prevents duplicate stars
218
+
combined := ownerDID + "/" + repository
219
+
return base64.RawURLEncoding.EncodeToString([]byte(combined))
220
+
}
221
+
222
+
// ParseStarRecordKey decodes a star record key back to ownerDID and repository
223
+
func ParseStarRecordKey(rkey string) (ownerDID, repository string, err error) {
224
+
decoded, err := base64.RawURLEncoding.DecodeString(rkey)
225
+
if err != nil {
226
+
return "", "", fmt.Errorf("failed to decode star rkey: %w", err)
227
+
}
228
+
229
+
parts := strings.SplitN(string(decoded), "/", 2)
230
+
if len(parts) != 2 {
231
+
return "", "", fmt.Errorf("invalid star rkey format: %s", string(decoded))
232
+
}
233
+
234
+
return parts[0], parts[1], nil
235
+
}
236
+
237
+
// ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID
238
+
// This ensures that different representations of the same hold are deduplicated:
239
+
// - http://172.28.0.3:8080 โ did:web:172.28.0.3:8080
240
+
// - http://hold01.atcr.io โ did:web:hold01.atcr.io
241
+
// - https://hold01.atcr.io โ did:web:hold01.atcr.io
242
+
// - did:web:hold01.atcr.io โ did:web:hold01.atcr.io (passthrough)
243
+
func ResolveHoldDIDFromURL(holdURL string) string {
244
+
// Handle empty URLs
245
+
if holdURL == "" {
246
+
return ""
247
+
}
248
+
249
+
// If already a DID, return as-is
250
+
if IsDID(holdURL) {
251
+
return holdURL
252
+
}
253
+
254
+
// Parse URL to get hostname
255
+
holdURL = strings.TrimPrefix(holdURL, "http://")
256
+
holdURL = strings.TrimPrefix(holdURL, "https://")
257
+
holdURL = strings.TrimSuffix(holdURL, "/")
258
+
259
+
// Extract hostname (remove path if present)
260
+
parts := strings.Split(holdURL, "/")
261
+
hostname := parts[0]
262
+
263
+
// Convert to did:web
264
+
// did:web uses hostname directly (port included if non-standard)
265
+
return "did:web:" + hostname
266
+
}
267
+
268
+
// IsDID checks if a string is a DID (starts with "did:")
269
+
func IsDID(s string) bool {
270
+
return len(s) > 4 && s[:4] == "did:"
271
+
}
272
+
273
+
// RepositoryTagToRKey converts a repository and tag to an ATProto record key
274
+
// ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$
275
+
func RepositoryTagToRKey(repository, tag string) string {
276
+
// Combine repository and tag to create a unique key
277
+
// Replace invalid characters: slashes become tildes (~)
278
+
// We use tilde instead of dash to avoid ambiguity with repository names that contain hyphens
279
+
key := fmt.Sprintf("%s_%s", repository, tag)
280
+
281
+
// Replace / with ~ (slash not allowed in rkeys, tilde is allowed and unlikely in repo names)
282
+
key = strings.ReplaceAll(key, "/", "~")
283
+
284
+
return key
285
+
}
286
+
287
+
// RKeyToRepositoryTag converts an ATProto record key back to repository and tag
288
+
// This is the inverse of RepositoryTagToRKey
289
+
// Note: If the tag contains underscores, this will split on the LAST underscore
290
+
func RKeyToRepositoryTag(rkey string) (repository, tag string) {
291
+
// Find the last underscore to split repository and tag
292
+
lastUnderscore := strings.LastIndex(rkey, "_")
293
+
if lastUnderscore == -1 {
294
+
// No underscore found - treat entire string as tag with empty repository
295
+
return "", rkey
296
+
}
297
+
298
+
repository = rkey[:lastUnderscore]
299
+
tag = rkey[lastUnderscore+1:]
300
+
301
+
// Convert tildes back to slashes in repository (tilde was used to encode slashes)
302
+
repository = strings.ReplaceAll(repository, "~", "/")
303
+
304
+
return repository, tag
305
+
}
306
+
307
+
// BuildManifestURI creates an AT-URI for a manifest record
308
+
// did: The DID of the user (e.g., "did:plc:xyz123")
309
+
// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
310
+
// Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
311
+
func BuildManifestURI(did, manifestDigest string) string {
312
+
// Remove the "sha256:" prefix from the digest to get the rkey
313
+
rkey := strings.TrimPrefix(manifestDigest, "sha256:")
314
+
return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey)
315
+
}
316
+
317
+
// ParseManifestURI extracts the digest from a manifest AT-URI
318
+
// manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
319
+
// Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...")
320
+
func ParseManifestURI(manifestURI string) (string, error) {
321
+
// Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey>
322
+
if !strings.HasPrefix(manifestURI, "at://") {
323
+
return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'")
324
+
}
325
+
326
+
// Remove "at://" prefix
327
+
remainder := strings.TrimPrefix(manifestURI, "at://")
328
+
329
+
// Split by "/"
330
+
parts := strings.Split(remainder, "/")
331
+
if len(parts) != 3 {
332
+
return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts))
333
+
}
334
+
335
+
// Validate collection
336
+
if parts[1] != ManifestCollection {
337
+
return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1])
338
+
}
339
+
340
+
// The rkey is the digest without the "sha256:" prefix
341
+
// Add it back to get the full digest
342
+
rkey := parts[2]
343
+
return "sha256:" + rkey, nil
344
+
}
345
+
346
+
// GetManifestDigest extracts the digest from a Tag, preferring the manifest field
347
+
// Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...")
348
+
func (t *Tag) GetManifestDigest() (string, error) {
349
+
// Prefer the new manifest field
350
+
if t.Manifest != nil && *t.Manifest != "" {
351
+
return ParseManifestURI(*t.Manifest)
352
+
}
353
+
354
+
// Fall back to the legacy manifestDigest field
355
+
if t.ManifestDigest != nil && *t.ManifestDigest != "" {
356
+
return *t.ManifestDigest, nil
357
+
}
358
+
359
+
return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field")
360
+
}
+109
-215
pkg/atproto/lexicon_test.go
+109
-215
pkg/atproto/lexicon_test.go
···
104
104
digest string
105
105
ociManifest string
106
106
wantErr bool
107
-
checkFunc func(*testing.T, *ManifestRecord)
107
+
checkFunc func(*testing.T, *Manifest)
108
108
}{
109
109
{
110
110
name: "valid OCI manifest",
···
112
112
digest: "sha256:abc123",
113
113
ociManifest: validOCIManifest,
114
114
wantErr: false,
115
-
checkFunc: func(t *testing.T, record *ManifestRecord) {
116
-
if record.Type != ManifestCollection {
117
-
t.Errorf("Type = %v, want %v", record.Type, ManifestCollection)
115
+
checkFunc: func(t *testing.T, record *Manifest) {
116
+
if record.LexiconTypeID != ManifestCollection {
117
+
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, ManifestCollection)
118
118
}
119
119
if record.Repository != "myapp" {
120
120
t.Errorf("Repository = %v, want myapp", record.Repository)
···
143
143
if record.Layers[1].Digest != "sha256:layer2" {
144
144
t.Errorf("Layers[1].Digest = %v, want sha256:layer2", record.Layers[1].Digest)
145
145
}
146
-
if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" {
147
-
t.Errorf("Annotations missing expected key")
148
-
}
149
-
if record.CreatedAt.IsZero() {
150
-
t.Error("CreatedAt should not be zero")
146
+
// Note: Annotations are not copied to generated type (empty struct)
147
+
if record.CreatedAt == "" {
148
+
t.Error("CreatedAt should not be empty")
151
149
}
152
150
if record.Subject != nil {
153
151
t.Error("Subject should be nil")
···
160
158
digest: "sha256:abc123",
161
159
ociManifest: manifestWithSubject,
162
160
wantErr: false,
163
-
checkFunc: func(t *testing.T, record *ManifestRecord) {
161
+
checkFunc: func(t *testing.T, record *Manifest) {
164
162
if record.Subject == nil {
165
163
t.Fatal("Subject should not be nil")
166
164
}
···
192
190
digest: "sha256:multiarch",
193
191
ociManifest: manifestList,
194
192
wantErr: false,
195
-
checkFunc: func(t *testing.T, record *ManifestRecord) {
193
+
checkFunc: func(t *testing.T, record *Manifest) {
196
194
if record.MediaType != "application/vnd.oci.image.index.v1+json" {
197
195
t.Errorf("MediaType = %v, want application/vnd.oci.image.index.v1+json", record.MediaType)
198
196
}
···
219
217
if record.Manifests[0].Platform.Architecture != "amd64" {
220
218
t.Errorf("Platform.Architecture = %v, want amd64", record.Manifests[0].Platform.Architecture)
221
219
}
222
-
if record.Manifests[0].Platform.OS != "linux" {
223
-
t.Errorf("Platform.OS = %v, want linux", record.Manifests[0].Platform.OS)
220
+
if record.Manifests[0].Platform.Os != "linux" {
221
+
t.Errorf("Platform.Os = %v, want linux", record.Manifests[0].Platform.Os)
224
222
}
225
223
226
224
// Check second manifest (arm64)
···
230
228
if record.Manifests[1].Platform.Architecture != "arm64" {
231
229
t.Errorf("Platform.Architecture = %v, want arm64", record.Manifests[1].Platform.Architecture)
232
230
}
233
-
if record.Manifests[1].Platform.Variant != "v8" {
231
+
if record.Manifests[1].Platform.Variant == nil || *record.Manifests[1].Platform.Variant != "v8" {
234
232
t.Errorf("Platform.Variant = %v, want v8", record.Manifests[1].Platform.Variant)
235
233
}
236
234
},
···
268
266
269
267
func TestNewTagRecord(t *testing.T) {
270
268
did := "did:plc:test123"
271
-
before := time.Now()
269
+
// Truncate to second precision since RFC3339 doesn't have sub-second precision
270
+
before := time.Now().Truncate(time.Second)
272
271
record := NewTagRecord(did, "myapp", "latest", "sha256:abc123")
273
-
after := time.Now()
272
+
after := time.Now().Truncate(time.Second).Add(time.Second)
274
273
275
-
if record.Type != TagCollection {
276
-
t.Errorf("Type = %v, want %v", record.Type, TagCollection)
274
+
if record.LexiconTypeID != TagCollection {
275
+
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, TagCollection)
277
276
}
278
277
279
278
if record.Repository != "myapp" {
···
286
285
287
286
// New records should have manifest field (AT-URI)
288
287
expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123"
289
-
if record.Manifest != expectedURI {
288
+
if record.Manifest == nil || *record.Manifest != expectedURI {
290
289
t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI)
291
290
}
292
291
293
292
// New records should NOT have manifestDigest field
294
-
if record.ManifestDigest != "" {
295
-
t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest)
293
+
if record.ManifestDigest != nil && *record.ManifestDigest != "" {
294
+
t.Errorf("ManifestDigest should be nil for new records, got %v", record.ManifestDigest)
296
295
}
297
296
298
-
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
299
-
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
297
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
298
+
if err != nil {
299
+
t.Errorf("CreatedAt is not valid RFC3339: %v", err)
300
+
}
301
+
if createdAt.Before(before) || createdAt.After(after) {
302
+
t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
300
303
}
301
304
}
302
305
···
391
394
}
392
395
393
396
func TestTagRecord_GetManifestDigest(t *testing.T) {
397
+
manifestURI := "at://did:plc:test123/io.atcr.manifest/abc123"
398
+
digestValue := "sha256:def456"
399
+
394
400
tests := []struct {
395
401
name string
396
-
record TagRecord
402
+
record Tag
397
403
want string
398
404
wantErr bool
399
405
}{
400
406
{
401
407
name: "new record with manifest field",
402
-
record: TagRecord{
403
-
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
408
+
record: Tag{
409
+
Manifest: &manifestURI,
404
410
},
405
411
want: "sha256:abc123",
406
412
wantErr: false,
407
413
},
408
414
{
409
415
name: "old record with manifestDigest field",
410
-
record: TagRecord{
411
-
ManifestDigest: "sha256:def456",
416
+
record: Tag{
417
+
ManifestDigest: &digestValue,
412
418
},
413
419
want: "sha256:def456",
414
420
wantErr: false,
415
421
},
416
422
{
417
423
name: "prefers manifest over manifestDigest",
418
-
record: TagRecord{
419
-
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
420
-
ManifestDigest: "sha256:def456",
424
+
record: Tag{
425
+
Manifest: &manifestURI,
426
+
ManifestDigest: &digestValue,
421
427
},
422
428
want: "sha256:abc123",
423
429
wantErr: false,
424
430
},
425
431
{
426
432
name: "no fields set",
427
-
record: TagRecord{},
433
+
record: Tag{},
428
434
want: "",
429
435
wantErr: true,
430
436
},
431
437
{
432
438
name: "invalid manifest URI",
433
-
record: TagRecord{
434
-
Manifest: "invalid-uri",
439
+
record: Tag{
440
+
Manifest: func() *string { s := "invalid-uri"; return &s }(),
435
441
},
436
442
want: "",
437
443
wantErr: true,
···
451
457
})
452
458
}
453
459
}
460
+
461
+
// TestNewHoldRecord is removed - HoldRecord is no longer supported (legacy BYOS)
454
462
455
463
func TestNewSailorProfileRecord(t *testing.T) {
456
464
tests := []struct {
···
473
481
474
482
for _, tt := range tests {
475
483
t.Run(tt.name, func(t *testing.T) {
476
-
before := time.Now()
484
+
// Truncate to second precision since RFC3339 doesn't have sub-second precision
485
+
before := time.Now().Truncate(time.Second)
477
486
record := NewSailorProfileRecord(tt.defaultHold)
478
-
after := time.Now()
487
+
after := time.Now().Truncate(time.Second).Add(time.Second)
479
488
480
-
if record.Type != SailorProfileCollection {
481
-
t.Errorf("Type = %v, want %v", record.Type, SailorProfileCollection)
489
+
if record.LexiconTypeID != SailorProfileCollection {
490
+
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, SailorProfileCollection)
482
491
}
483
492
484
-
if record.DefaultHold != tt.defaultHold {
485
-
t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold)
493
+
if tt.defaultHold == "" {
494
+
if record.DefaultHold != nil {
495
+
t.Errorf("DefaultHold = %v, want nil", record.DefaultHold)
496
+
}
497
+
} else {
498
+
if record.DefaultHold == nil || *record.DefaultHold != tt.defaultHold {
499
+
t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold)
500
+
}
486
501
}
487
502
488
-
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
489
-
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
503
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
504
+
if err != nil {
505
+
t.Errorf("CreatedAt is not valid RFC3339: %v", err)
490
506
}
491
-
492
-
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
493
-
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
507
+
if createdAt.Before(before) || createdAt.After(after) {
508
+
t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
494
509
}
495
510
496
-
// CreatedAt and UpdatedAt should be equal for new records
497
-
if !record.CreatedAt.Equal(record.UpdatedAt) {
498
-
t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt)
511
+
if record.UpdatedAt == nil {
512
+
t.Error("UpdatedAt should not be nil")
513
+
} else {
514
+
updatedAt, err := time.Parse(time.RFC3339, *record.UpdatedAt)
515
+
if err != nil {
516
+
t.Errorf("UpdatedAt is not valid RFC3339: %v", err)
517
+
}
518
+
if updatedAt.Before(before) || updatedAt.After(after) {
519
+
t.Errorf("UpdatedAt = %v, want between %v and %v", updatedAt, before, after)
520
+
}
499
521
}
500
522
})
501
523
}
502
524
}
503
525
504
526
func TestNewStarRecord(t *testing.T) {
505
-
before := time.Now()
527
+
// Truncate to second precision since RFC3339 doesn't have sub-second precision
528
+
before := time.Now().Truncate(time.Second)
506
529
record := NewStarRecord("did:plc:alice123", "myapp")
507
-
after := time.Now()
530
+
after := time.Now().Truncate(time.Second).Add(time.Second)
508
531
509
-
if record.Type != StarCollection {
510
-
t.Errorf("Type = %v, want %v", record.Type, StarCollection)
532
+
if record.LexiconTypeID != StarCollection {
533
+
t.Errorf("LexiconTypeID = %v, want %v", record.LexiconTypeID, StarCollection)
511
534
}
512
535
513
-
if record.Subject.DID != "did:plc:alice123" {
514
-
t.Errorf("Subject.DID = %v, want did:plc:alice123", record.Subject.DID)
536
+
if record.Subject.Did != "did:plc:alice123" {
537
+
t.Errorf("Subject.Did = %v, want did:plc:alice123", record.Subject.Did)
515
538
}
516
539
517
540
if record.Subject.Repository != "myapp" {
518
541
t.Errorf("Subject.Repository = %v, want myapp", record.Subject.Repository)
519
542
}
520
543
521
-
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
522
-
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
544
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
545
+
if err != nil {
546
+
t.Errorf("CreatedAt is not valid RFC3339: %v", err)
547
+
}
548
+
if createdAt.Before(before) || createdAt.After(after) {
549
+
t.Errorf("CreatedAt = %v, want between %v and %v", createdAt, before, after)
523
550
}
524
551
}
525
552
···
807
834
}
808
835
809
836
// Add hold DID
810
-
record.HoldDID = "did:web:hold01.atcr.io"
837
+
holdDID := "did:web:hold01.atcr.io"
838
+
record.HoldDid = &holdDID
811
839
812
840
// Serialize to JSON
813
841
jsonData, err := json.Marshal(record)
···
816
844
}
817
845
818
846
// Deserialize from JSON
819
-
var decoded ManifestRecord
847
+
var decoded Manifest
820
848
if err := json.Unmarshal(jsonData, &decoded); err != nil {
821
849
t.Fatalf("json.Unmarshal() error = %v", err)
822
850
}
823
851
824
852
// Verify fields
825
-
if decoded.Type != record.Type {
826
-
t.Errorf("Type = %v, want %v", decoded.Type, record.Type)
853
+
if decoded.LexiconTypeID != record.LexiconTypeID {
854
+
t.Errorf("LexiconTypeID = %v, want %v", decoded.LexiconTypeID, record.LexiconTypeID)
827
855
}
828
856
if decoded.Repository != record.Repository {
829
857
t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository)
···
831
859
if decoded.Digest != record.Digest {
832
860
t.Errorf("Digest = %v, want %v", decoded.Digest, record.Digest)
833
861
}
834
-
if decoded.HoldDID != record.HoldDID {
835
-
t.Errorf("HoldDID = %v, want %v", decoded.HoldDID, record.HoldDID)
862
+
if decoded.HoldDid == nil || *decoded.HoldDid != *record.HoldDid {
863
+
t.Errorf("HoldDid = %v, want %v", decoded.HoldDid, record.HoldDid)
836
864
}
837
865
if decoded.Config.Digest != record.Config.Digest {
838
866
t.Errorf("Config.Digest = %v, want %v", decoded.Config.Digest, record.Config.Digest)
···
843
871
}
844
872
845
873
func TestBlobReference_JSONSerialization(t *testing.T) {
846
-
blob := BlobReference{
874
+
blob := Manifest_BlobReference{
847
875
MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
848
876
Digest: "sha256:abc123",
849
877
Size: 12345,
850
-
URLs: []string{"https://s3.example.com/blob"},
851
-
Annotations: map[string]string{
852
-
"key": "value",
853
-
},
878
+
Urls: []string{"https://s3.example.com/blob"},
879
+
// Note: Annotations is now an empty struct, not a map
854
880
}
855
881
856
882
// Serialize
···
860
886
}
861
887
862
888
// Deserialize
863
-
var decoded BlobReference
889
+
var decoded Manifest_BlobReference
864
890
if err := json.Unmarshal(jsonData, &decoded); err != nil {
865
891
t.Fatalf("json.Unmarshal() error = %v", err)
866
892
}
···
878
904
}
879
905
880
906
func TestStarSubject_JSONSerialization(t *testing.T) {
881
-
subject := StarSubject{
882
-
DID: "did:plc:alice123",
907
+
subject := SailorStar_Subject{
908
+
Did: "did:plc:alice123",
883
909
Repository: "myapp",
884
910
}
885
911
···
890
916
}
891
917
892
918
// Deserialize
893
-
var decoded StarSubject
919
+
var decoded SailorStar_Subject
894
920
if err := json.Unmarshal(jsonData, &decoded); err != nil {
895
921
t.Fatalf("json.Unmarshal() error = %v", err)
896
922
}
897
923
898
924
// Verify
899
-
if decoded.DID != subject.DID {
900
-
t.Errorf("DID = %v, want %v", decoded.DID, subject.DID)
925
+
if decoded.Did != subject.Did {
926
+
t.Errorf("Did = %v, want %v", decoded.Did, subject.Did)
901
927
}
902
928
if decoded.Repository != subject.Repository {
903
929
t.Errorf("Repository = %v, want %v", decoded.Repository, subject.Repository)
···
1144
1170
t.Fatal("NewLayerRecord() returned nil")
1145
1171
}
1146
1172
1147
-
if record.Type != LayerCollection {
1148
-
t.Errorf("Type = %q, want %q", record.Type, LayerCollection)
1173
+
if record.LexiconTypeID != LayerCollection {
1174
+
t.Errorf("LexiconTypeID = %q, want %q", record.LexiconTypeID, LayerCollection)
1149
1175
}
1150
1176
1151
1177
if record.Digest != tt.digest {
···
1164
1190
t.Errorf("Repository = %q, want %q", record.Repository, tt.repository)
1165
1191
}
1166
1192
1167
-
if record.UserDID != tt.userDID {
1168
-
t.Errorf("UserDID = %q, want %q", record.UserDID, tt.userDID)
1193
+
if record.UserDid != tt.userDID {
1194
+
t.Errorf("UserDid = %q, want %q", record.UserDid, tt.userDID)
1169
1195
}
1170
1196
1171
1197
if record.UserHandle != tt.userHandle {
···
1187
1213
}
1188
1214
1189
1215
func TestNewLayerRecordJSON(t *testing.T) {
1190
-
// Test that LayerRecord can be marshaled/unmarshaled to/from JSON
1216
+
// Test that HoldLayer can be marshaled/unmarshaled to/from JSON
1191
1217
record := NewLayerRecord(
1192
1218
"sha256:abc123",
1193
1219
1024,
···
1204
1230
}
1205
1231
1206
1232
// Unmarshal back
1207
-
var decoded LayerRecord
1233
+
var decoded HoldLayer
1208
1234
if err := json.Unmarshal(jsonData, &decoded); err != nil {
1209
1235
t.Fatalf("json.Unmarshal() error = %v", err)
1210
1236
}
1211
1237
1212
1238
// Verify fields match
1213
-
if decoded.Type != record.Type {
1214
-
t.Errorf("Type = %q, want %q", decoded.Type, record.Type)
1239
+
if decoded.LexiconTypeID != record.LexiconTypeID {
1240
+
t.Errorf("LexiconTypeID = %q, want %q", decoded.LexiconTypeID, record.LexiconTypeID)
1215
1241
}
1216
1242
if decoded.Digest != record.Digest {
1217
1243
t.Errorf("Digest = %q, want %q", decoded.Digest, record.Digest)
···
1225
1251
if decoded.Repository != record.Repository {
1226
1252
t.Errorf("Repository = %q, want %q", decoded.Repository, record.Repository)
1227
1253
}
1228
-
if decoded.UserDID != record.UserDID {
1229
-
t.Errorf("UserDID = %q, want %q", decoded.UserDID, record.UserDID)
1254
+
if decoded.UserDid != record.UserDid {
1255
+
t.Errorf("UserDid = %q, want %q", decoded.UserDid, record.UserDid)
1230
1256
}
1231
1257
if decoded.UserHandle != record.UserHandle {
1232
1258
t.Errorf("UserHandle = %q, want %q", decoded.UserHandle, record.UserHandle)
···
1235
1261
t.Errorf("CreatedAt = %q, want %q", decoded.CreatedAt, record.CreatedAt)
1236
1262
}
1237
1263
}
1238
-
1239
-
func TestNewRepoPageRecord(t *testing.T) {
1240
-
tests := []struct {
1241
-
name string
1242
-
repository string
1243
-
description string
1244
-
avatar *ATProtoBlobRef
1245
-
}{
1246
-
{
1247
-
name: "with description only",
1248
-
repository: "myapp",
1249
-
description: "# My App\n\nA cool container image.",
1250
-
avatar: nil,
1251
-
},
1252
-
{
1253
-
name: "with avatar only",
1254
-
repository: "another-app",
1255
-
description: "",
1256
-
avatar: &ATProtoBlobRef{
1257
-
Type: "blob",
1258
-
Ref: Link{Link: "bafyreiabc123"},
1259
-
MimeType: "image/png",
1260
-
Size: 1024,
1261
-
},
1262
-
},
1263
-
{
1264
-
name: "with both description and avatar",
1265
-
repository: "full-app",
1266
-
description: "This is a full description.",
1267
-
avatar: &ATProtoBlobRef{
1268
-
Type: "blob",
1269
-
Ref: Link{Link: "bafyreiabc456"},
1270
-
MimeType: "image/jpeg",
1271
-
Size: 2048,
1272
-
},
1273
-
},
1274
-
{
1275
-
name: "empty values",
1276
-
repository: "",
1277
-
description: "",
1278
-
avatar: nil,
1279
-
},
1280
-
}
1281
-
1282
-
for _, tt := range tests {
1283
-
t.Run(tt.name, func(t *testing.T) {
1284
-
before := time.Now()
1285
-
record := NewRepoPageRecord(tt.repository, tt.description, tt.avatar)
1286
-
after := time.Now()
1287
-
1288
-
if record.Type != RepoPageCollection {
1289
-
t.Errorf("Type = %v, want %v", record.Type, RepoPageCollection)
1290
-
}
1291
-
1292
-
if record.Repository != tt.repository {
1293
-
t.Errorf("Repository = %v, want %v", record.Repository, tt.repository)
1294
-
}
1295
-
1296
-
if record.Description != tt.description {
1297
-
t.Errorf("Description = %v, want %v", record.Description, tt.description)
1298
-
}
1299
-
1300
-
if tt.avatar == nil && record.Avatar != nil {
1301
-
t.Error("Avatar should be nil")
1302
-
}
1303
-
1304
-
if tt.avatar != nil {
1305
-
if record.Avatar == nil {
1306
-
t.Fatal("Avatar should not be nil")
1307
-
}
1308
-
if record.Avatar.Ref.Link != tt.avatar.Ref.Link {
1309
-
t.Errorf("Avatar.Ref.Link = %v, want %v", record.Avatar.Ref.Link, tt.avatar.Ref.Link)
1310
-
}
1311
-
}
1312
-
1313
-
if record.CreatedAt.Before(before) || record.CreatedAt.After(after) {
1314
-
t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after)
1315
-
}
1316
-
1317
-
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
1318
-
t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after)
1319
-
}
1320
-
1321
-
// CreatedAt and UpdatedAt should be equal for new records
1322
-
if !record.CreatedAt.Equal(record.UpdatedAt) {
1323
-
t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt)
1324
-
}
1325
-
})
1326
-
}
1327
-
}
1328
-
1329
-
func TestRepoPageRecord_JSONSerialization(t *testing.T) {
1330
-
record := NewRepoPageRecord(
1331
-
"myapp",
1332
-
"# My App\n\nA description with **markdown**.",
1333
-
&ATProtoBlobRef{
1334
-
Type: "blob",
1335
-
Ref: Link{Link: "bafyreiabc123"},
1336
-
MimeType: "image/png",
1337
-
Size: 1024,
1338
-
},
1339
-
)
1340
-
1341
-
// Serialize to JSON
1342
-
jsonData, err := json.Marshal(record)
1343
-
if err != nil {
1344
-
t.Fatalf("json.Marshal() error = %v", err)
1345
-
}
1346
-
1347
-
// Deserialize from JSON
1348
-
var decoded RepoPageRecord
1349
-
if err := json.Unmarshal(jsonData, &decoded); err != nil {
1350
-
t.Fatalf("json.Unmarshal() error = %v", err)
1351
-
}
1352
-
1353
-
// Verify fields
1354
-
if decoded.Type != record.Type {
1355
-
t.Errorf("Type = %v, want %v", decoded.Type, record.Type)
1356
-
}
1357
-
if decoded.Repository != record.Repository {
1358
-
t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository)
1359
-
}
1360
-
if decoded.Description != record.Description {
1361
-
t.Errorf("Description = %v, want %v", decoded.Description, record.Description)
1362
-
}
1363
-
if decoded.Avatar == nil {
1364
-
t.Fatal("Avatar should not be nil")
1365
-
}
1366
-
if decoded.Avatar.Ref.Link != record.Avatar.Ref.Link {
1367
-
t.Errorf("Avatar.Ref.Link = %v, want %v", decoded.Avatar.Ref.Link, record.Avatar.Ref.Link)
1368
-
}
1369
-
}
+103
pkg/atproto/manifest.go
+103
pkg/atproto/manifest.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
// Lexicon schema: io.atcr.manifest
4
+
5
+
package atproto
6
+
7
+
import (
8
+
lexutil "github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
// A container image manifest following OCI specification, stored in ATProto
12
+
type Manifest struct {
13
+
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.manifest"`
14
+
// annotations: Optional metadata annotations
15
+
Annotations *Manifest_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
16
+
// config: Reference to image configuration blob
17
+
Config *Manifest_BlobReference `json:"config,omitempty" cborgen:"config,omitempty"`
18
+
// createdAt: Record creation timestamp
19
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
20
+
// digest: Content digest (e.g., 'sha256:abc123...')
21
+
Digest string `json:"digest" cborgen:"digest"`
22
+
// holdDid: DID of the hold service where blobs are stored (e.g., 'did:web:hold01.atcr.io'). Primary reference for hold resolution.
23
+
HoldDid *string `json:"holdDid,omitempty" cborgen:"holdDid,omitempty"`
24
+
// holdEndpoint: Hold service endpoint URL where blobs are stored. DEPRECATED: Use holdDid instead. Kept for backward compatibility.
25
+
HoldEndpoint *string `json:"holdEndpoint,omitempty" cborgen:"holdEndpoint,omitempty"`
26
+
// layers: Filesystem layers (for image manifests)
27
+
Layers []Manifest_BlobReference `json:"layers,omitempty" cborgen:"layers,omitempty"`
28
+
// manifestBlob: The full OCI manifest stored as a blob in ATProto.
29
+
ManifestBlob *lexutil.LexBlob `json:"manifestBlob,omitempty" cborgen:"manifestBlob,omitempty"`
30
+
// manifests: Referenced manifests (for manifest lists/indexes)
31
+
Manifests []Manifest_ManifestReference `json:"manifests,omitempty" cborgen:"manifests,omitempty"`
32
+
// mediaType: OCI media type
33
+
MediaType string `json:"mediaType" cborgen:"mediaType"`
34
+
// repository: Repository name (e.g., 'myapp'). Scoped to user's DID.
35
+
Repository string `json:"repository" cborgen:"repository"`
36
+
// schemaVersion: OCI schema version (typically 2)
37
+
SchemaVersion int64 `json:"schemaVersion" cborgen:"schemaVersion"`
38
+
// subject: Optional reference to another manifest (for attestations, signatures)
39
+
Subject *Manifest_BlobReference `json:"subject,omitempty" cborgen:"subject,omitempty"`
40
+
}
41
+
42
+
// Optional metadata annotations
43
+
type Manifest_Annotations struct {
44
+
}
45
+
46
+
// Manifest_BlobReference is a "blobReference" in the io.atcr.manifest schema.
47
+
//
48
+
// Reference to a blob stored in S3 or external storage
49
+
type Manifest_BlobReference struct {
50
+
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#blobReference,omitempty"`
51
+
// annotations: Optional metadata
52
+
Annotations *Manifest_BlobReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
53
+
// digest: Content digest (e.g., 'sha256:...')
54
+
Digest string `json:"digest" cborgen:"digest"`
55
+
// mediaType: MIME type of the blob
56
+
MediaType string `json:"mediaType" cborgen:"mediaType"`
57
+
// size: Size in bytes
58
+
Size int64 `json:"size" cborgen:"size"`
59
+
// urls: Optional direct URLs to blob (for BYOS)
60
+
Urls []string `json:"urls,omitempty" cborgen:"urls,omitempty"`
61
+
}
62
+
63
+
// Optional metadata
64
+
type Manifest_BlobReference_Annotations struct {
65
+
}
66
+
67
+
// Manifest_ManifestReference is a "manifestReference" in the io.atcr.manifest schema.
68
+
//
69
+
// Reference to a manifest in a manifest list/index
70
+
type Manifest_ManifestReference struct {
71
+
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#manifestReference,omitempty"`
72
+
// annotations: Optional metadata
73
+
Annotations *Manifest_ManifestReference_Annotations `json:"annotations,omitempty" cborgen:"annotations,omitempty"`
74
+
// digest: Content digest (e.g., 'sha256:...')
75
+
Digest string `json:"digest" cborgen:"digest"`
76
+
// mediaType: Media type of the referenced manifest
77
+
MediaType string `json:"mediaType" cborgen:"mediaType"`
78
+
// platform: Platform information for this manifest
79
+
Platform *Manifest_Platform `json:"platform,omitempty" cborgen:"platform,omitempty"`
80
+
// size: Size in bytes
81
+
Size int64 `json:"size" cborgen:"size"`
82
+
}
83
+
84
+
// Optional metadata
85
+
type Manifest_ManifestReference_Annotations struct {
86
+
}
87
+
88
+
// Manifest_Platform is a "platform" in the io.atcr.manifest schema.
89
+
//
90
+
// Platform information describing OS and architecture
91
+
type Manifest_Platform struct {
92
+
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.manifest#platform,omitempty"`
93
+
// architecture: CPU architecture (e.g., 'amd64', 'arm64', 'arm')
94
+
Architecture string `json:"architecture" cborgen:"architecture"`
95
+
// os: Operating system (e.g., 'linux', 'windows', 'darwin')
96
+
Os string `json:"os" cborgen:"os"`
97
+
// osFeatures: Optional OS features
98
+
OsFeatures []string `json:"osFeatures,omitempty" cborgen:"osFeatures,omitempty"`
99
+
// osVersion: Optional OS version
100
+
OsVersion *string `json:"osVersion,omitempty" cborgen:"osVersion,omitempty"`
101
+
// variant: Optional CPU variant (e.g., 'v7' for ARM)
102
+
Variant *string `json:"variant,omitempty" cborgen:"variant,omitempty"`
103
+
}
+15
pkg/atproto/register.go
+15
pkg/atproto/register.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
package atproto
4
+
5
+
import lexutil "github.com/bluesky-social/indigo/lex/util"
6
+
7
+
func init() {
8
+
lexutil.RegisterType("io.atcr.hold.captain", &HoldCaptain{})
9
+
lexutil.RegisterType("io.atcr.hold.crew", &HoldCrew{})
10
+
lexutil.RegisterType("io.atcr.hold.layer", &HoldLayer{})
11
+
lexutil.RegisterType("io.atcr.manifest", &Manifest{})
12
+
lexutil.RegisterType("io.atcr.sailor.profile", &SailorProfile{})
13
+
lexutil.RegisterType("io.atcr.sailor.star", &SailorStar{})
14
+
lexutil.RegisterType("io.atcr.tag", &Tag{})
15
+
}
+16
pkg/atproto/sailorprofile.go
+16
pkg/atproto/sailorprofile.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
// Lexicon schema: io.atcr.sailor.profile
4
+
5
+
package atproto
6
+
7
+
// User profile for ATCR registry. Stores preferences like default hold for blob storage.
8
+
type SailorProfile struct {
9
+
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.profile"`
10
+
// createdAt: Profile creation timestamp
11
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
12
+
// defaultHold: Default hold endpoint for blob storage. If null, user has opted out of defaults.
13
+
DefaultHold *string `json:"defaultHold,omitempty" cborgen:"defaultHold,omitempty"`
14
+
// updatedAt: Profile last updated timestamp
15
+
UpdatedAt *string `json:"updatedAt,omitempty" cborgen:"updatedAt,omitempty"`
16
+
}
+25
pkg/atproto/sailorstar.go
+25
pkg/atproto/sailorstar.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
// Lexicon schema: io.atcr.sailor.star
4
+
5
+
package atproto
6
+
7
+
// A star (like) on a container image repository. Stored in the starrer's PDS, similar to Bluesky likes.
8
+
type SailorStar struct {
9
+
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.sailor.star"`
10
+
// createdAt: Star creation timestamp
11
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
12
+
// subject: The repository being starred
13
+
Subject SailorStar_Subject `json:"subject" cborgen:"subject"`
14
+
}
15
+
16
+
// SailorStar_Subject is a "subject" in the io.atcr.sailor.star schema.
17
+
//
18
+
// Reference to a repository owned by a user
19
+
type SailorStar_Subject struct {
20
+
LexiconTypeID string `json:"$type,omitempty" cborgen:"$type,const=io.atcr.sailor.star#subject,omitempty"`
21
+
// did: DID of the repository owner
22
+
Did string `json:"did" cborgen:"did"`
23
+
// repository: Repository name (e.g., 'myapp')
24
+
Repository string `json:"repository" cborgen:"repository"`
25
+
}
+20
pkg/atproto/tag.go
+20
pkg/atproto/tag.go
···
1
+
// Code generated by generate.go; DO NOT EDIT.
2
+
3
+
// Lexicon schema: io.atcr.tag
4
+
5
+
package atproto
6
+
7
+
// A named tag pointing to a specific manifest digest
8
+
type Tag struct {
9
+
LexiconTypeID string `json:"$type" cborgen:"$type,const=io.atcr.tag"`
10
+
// createdAt: Tag creation timestamp
11
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
12
+
// manifest: AT-URI of the manifest this tag points to (e.g., 'at://did:plc:xyz/io.atcr.manifest/abc123'). Preferred over manifestDigest for new records.
13
+
Manifest *string `json:"manifest,omitempty" cborgen:"manifest,omitempty"`
14
+
// manifestDigest: DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.
15
+
ManifestDigest *string `json:"manifestDigest,omitempty" cborgen:"manifestDigest,omitempty"`
16
+
// repository: Repository name (e.g., 'myapp'). Scoped to user's DID.
17
+
Repository string `json:"repository" cborgen:"repository"`
18
+
// tag: Tag name (e.g., 'latest', 'v1.0.0', '12-slim')
19
+
Tag string `json:"tag" cborgen:"tag"`
20
+
}
+2
-2
pkg/auth/hold_local.go
+2
-2
pkg/auth/hold_local.go
···
35
35
}
36
36
37
37
// GetCaptainRecord retrieves the captain record from the hold's PDS
38
-
func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
38
+
func (a *LocalHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
39
39
// Verify that the requested holdDID matches this hold
40
40
if holdDID != a.pds.DID() {
41
41
return nil, fmt.Errorf("holdDID mismatch: requested %s, this hold is %s", holdDID, a.pds.DID())
···
47
47
return nil, fmt.Errorf("failed to get captain record: %w", err)
48
48
}
49
49
50
-
// The PDS returns *atproto.CaptainRecord directly now (after we update pds to use atproto types)
50
+
// The PDS returns *atproto.HoldCaptain directly
51
51
return pdsCaptain, nil
52
52
}
53
53
+34
-20
pkg/auth/hold_remote.go
+34
-20
pkg/auth/hold_remote.go
···
101
101
// 1. Check database cache
102
102
// 2. If cache miss or expired, query hold's XRPC endpoint
103
103
// 3. Update cache
104
-
func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
104
+
func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
105
105
// Try cache first
106
106
if a.db != nil {
107
107
cached, err := a.getCachedCaptainRecord(holdDID)
108
108
if err == nil && cached != nil {
109
109
// Cache hit - check if still valid
110
110
if time.Since(cached.UpdatedAt) < a.cacheTTL {
111
-
return cached.CaptainRecord, nil
111
+
return cached.HoldCaptain, nil
112
112
}
113
113
// Cache expired - continue to fetch fresh data
114
114
}
···
133
133
134
134
// captainRecordWithMeta includes UpdatedAt for cache management
135
135
type captainRecordWithMeta struct {
136
-
*atproto.CaptainRecord
136
+
*atproto.HoldCaptain
137
137
UpdatedAt time.Time
138
138
}
139
139
···
145
145
WHERE hold_did = ?
146
146
`
147
147
148
-
var record atproto.CaptainRecord
148
+
var record atproto.HoldCaptain
149
149
var deployedAt, region, provider sql.NullString
150
150
var updatedAt time.Time
151
151
···
172
172
record.DeployedAt = deployedAt.String
173
173
}
174
174
if region.Valid {
175
-
record.Region = region.String
175
+
record.Region = ®ion.String
176
176
}
177
177
if provider.Valid {
178
-
record.Provider = provider.String
178
+
record.Provider = &provider.String
179
179
}
180
180
181
181
return &captainRecordWithMeta{
182
-
CaptainRecord: &record,
183
-
UpdatedAt: updatedAt,
182
+
HoldCaptain: &record,
183
+
UpdatedAt: updatedAt,
184
184
}, nil
185
185
}
186
186
187
187
// setCachedCaptainRecord stores a captain record in database cache
188
-
func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.CaptainRecord) error {
188
+
func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *atproto.HoldCaptain) error {
189
189
query := `
190
190
INSERT INTO hold_captain_records (
191
191
hold_did, owner_did, public, allow_all_crew,
···
207
207
record.Public,
208
208
record.AllowAllCrew,
209
209
nullString(record.DeployedAt),
210
-
nullString(record.Region),
211
-
nullString(record.Provider),
210
+
nullStringPtr(record.Region),
211
+
nullStringPtr(record.Provider),
212
212
time.Now(),
213
213
)
214
214
···
216
216
}
217
217
218
218
// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
219
-
func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
219
+
func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.HoldCaptain, error) {
220
220
// Resolve DID to URL
221
221
holdURL := atproto.ResolveHoldURL(holdDID)
222
222
···
261
261
}
262
262
263
263
// Convert to our type
264
-
record := &atproto.CaptainRecord{
265
-
Type: atproto.CaptainCollection,
266
-
Owner: xrpcResp.Value.Owner,
267
-
Public: xrpcResp.Value.Public,
268
-
AllowAllCrew: xrpcResp.Value.AllowAllCrew,
269
-
DeployedAt: xrpcResp.Value.DeployedAt,
270
-
Region: xrpcResp.Value.Region,
271
-
Provider: xrpcResp.Value.Provider,
264
+
record := &atproto.HoldCaptain{
265
+
LexiconTypeID: atproto.CaptainCollection,
266
+
Owner: xrpcResp.Value.Owner,
267
+
Public: xrpcResp.Value.Public,
268
+
AllowAllCrew: xrpcResp.Value.AllowAllCrew,
269
+
DeployedAt: xrpcResp.Value.DeployedAt,
270
+
}
271
+
272
+
// Handle optional pointer fields
273
+
if xrpcResp.Value.Region != "" {
274
+
record.Region = &xrpcResp.Value.Region
275
+
}
276
+
if xrpcResp.Value.Provider != "" {
277
+
record.Provider = &xrpcResp.Value.Provider
272
278
}
273
279
274
280
return record, nil
···
406
412
return sql.NullString{Valid: false}
407
413
}
408
414
return sql.NullString{String: s, Valid: true}
415
+
}
416
+
417
+
// nullStringPtr converts a *string to sql.NullString
418
+
func nullStringPtr(s *string) sql.NullString {
419
+
if s == nil || *s == "" {
420
+
return sql.NullString{Valid: false}
421
+
}
422
+
return sql.NullString{String: *s, Valid: true}
409
423
}
410
424
411
425
// getCachedApproval checks if user has a cached crew approval
+13
-8
pkg/auth/hold_remote_test.go
+13
-8
pkg/auth/hold_remote_test.go
···
14
14
"atcr.io/pkg/atproto"
15
15
)
16
16
17
+
// ptrString returns a pointer to the given string
18
+
func ptrString(s string) *string {
19
+
return &s
20
+
}
21
+
17
22
func TestNewRemoteHoldAuthorizer(t *testing.T) {
18
23
// Test with nil database (should still work)
19
24
authorizer := NewRemoteHoldAuthorizer(nil, false)
···
133
138
holdDID := "did:web:hold01.atcr.io"
134
139
135
140
// Pre-populate cache with a captain record
136
-
captainRecord := &atproto.CaptainRecord{
137
-
Type: atproto.CaptainCollection,
138
-
Owner: "did:plc:owner123",
139
-
Public: true,
140
-
AllowAllCrew: false,
141
-
DeployedAt: "2025-10-28T00:00:00Z",
142
-
Region: "us-east-1",
143
-
Provider: "fly.io",
141
+
captainRecord := &atproto.HoldCaptain{
142
+
LexiconTypeID: atproto.CaptainCollection,
143
+
Owner: "did:plc:owner123",
144
+
Public: true,
145
+
AllowAllCrew: false,
146
+
DeployedAt: "2025-10-28T00:00:00Z",
147
+
Region: ptrString("us-east-1"),
148
+
Provider: ptrString("fly.io"),
144
149
}
145
150
146
151
err := remote.setCachedCaptainRecord(holdDID, captainRecord)
+2
-5
pkg/auth/oauth/client.go
+2
-5
pkg/auth/oauth/client.go
···
77
77
func GetDefaultScopes(did string) []string {
78
78
scopes := []string{
79
79
"atproto",
80
-
// Used for service token validation on holds
81
-
"rpc:com.atproto.repo.getRecord?aud=*",
82
80
// Image manifest types (single-arch)
83
81
"blob:application/vnd.oci.image.manifest.v1+json",
84
82
"blob:application/vnd.docker.distribution.manifest.v2+json",
···
87
85
"blob:application/vnd.docker.distribution.manifest.list.v2+json",
88
86
// OCI artifact manifests (for cosign signatures, SBOMs, attestations)
89
87
"blob:application/vnd.cncf.oras.artifact.manifest.v1+json",
90
-
// image avatars
91
-
"blob:image/*",
88
+
// Used for service token validation on holds
89
+
"rpc:com.atproto.repo.getRecord?aud=*",
92
90
}
93
91
94
92
// Add repo scopes
···
97
95
fmt.Sprintf("repo:%s", atproto.TagCollection),
98
96
fmt.Sprintf("repo:%s", atproto.StarCollection),
99
97
fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
100
-
fmt.Sprintf("repo:%s", atproto.RepoPageCollection),
101
98
)
102
99
103
100
return scopes
+4
-4
pkg/hold/pds/captain.go
+4
-4
pkg/hold/pds/captain.go
···
18
18
// CreateCaptainRecord creates the captain record for the hold (first-time only).
19
19
// This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify.
20
20
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) {
21
-
captainRecord := &atproto.CaptainRecord{
22
-
Type: atproto.CaptainCollection,
21
+
captainRecord := &atproto.HoldCaptain{
22
+
LexiconTypeID: atproto.CaptainCollection,
23
23
Owner: ownerDID,
24
24
Public: public,
25
25
AllowAllCrew: allowAllCrew,
···
40
40
}
41
41
42
42
// GetCaptainRecord retrieves the captain record
43
-
func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.CaptainRecord, error) {
43
+
func (p *HoldPDS) GetCaptainRecord(ctx context.Context) (cid.Cid, *atproto.HoldCaptain, error) {
44
44
// Use repomgr.GetRecord - our types are registered in init()
45
45
// so it will automatically unmarshal to the concrete type
46
46
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CaptainCollection, CaptainRkey, cid.Undef)
···
49
49
}
50
50
51
51
// Type assert to our concrete type
52
-
captainRecord, ok := val.(*atproto.CaptainRecord)
52
+
captainRecord, ok := val.(*atproto.HoldCaptain)
53
53
if !ok {
54
54
return cid.Undef, nil, fmt.Errorf("unexpected type for captain record: %T", val)
55
55
}
+43
-32
pkg/hold/pds/captain_test.go
+43
-32
pkg/hold/pds/captain_test.go
···
12
12
"atcr.io/pkg/atproto"
13
13
)
14
14
15
+
// ptrString returns a pointer to the given string
16
+
func ptrString(s string) *string {
17
+
return &s
18
+
}
19
+
15
20
// setupTestPDS creates a test PDS instance in a temporary directory
16
21
// It initializes the repo but does NOT create captain/crew records
17
22
// Tests should call Bootstrap or create records as needed
···
146
151
if captain.EnableBlueskyPosts != tt.enableBlueskyPosts {
147
152
t.Errorf("Expected enableBlueskyPosts=%v, got %v", tt.enableBlueskyPosts, captain.EnableBlueskyPosts)
148
153
}
149
-
if captain.Type != atproto.CaptainCollection {
150
-
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type)
154
+
if captain.LexiconTypeID != atproto.CaptainCollection {
155
+
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
151
156
}
152
157
if captain.DeployedAt == "" {
153
158
t.Error("Expected deployedAt to be set")
···
322
327
func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
323
328
tests := []struct {
324
329
name string
325
-
record *atproto.CaptainRecord
330
+
record *atproto.HoldCaptain
326
331
}{
327
332
{
328
333
name: "Basic captain",
329
-
record: &atproto.CaptainRecord{
330
-
Type: atproto.CaptainCollection,
331
-
Owner: "did:plc:alice123",
332
-
Public: true,
333
-
AllowAllCrew: false,
334
-
DeployedAt: "2025-10-16T12:00:00Z",
334
+
record: &atproto.HoldCaptain{
335
+
LexiconTypeID: atproto.CaptainCollection,
336
+
Owner: "did:plc:alice123",
337
+
Public: true,
338
+
AllowAllCrew: false,
339
+
DeployedAt: "2025-10-16T12:00:00Z",
335
340
},
336
341
},
337
342
{
338
343
name: "Captain with optional fields",
339
-
record: &atproto.CaptainRecord{
340
-
Type: atproto.CaptainCollection,
341
-
Owner: "did:plc:bob456",
342
-
Public: false,
343
-
AllowAllCrew: true,
344
-
DeployedAt: "2025-10-16T12:00:00Z",
345
-
Region: "us-west-2",
346
-
Provider: "fly.io",
344
+
record: &atproto.HoldCaptain{
345
+
LexiconTypeID: atproto.CaptainCollection,
346
+
Owner: "did:plc:bob456",
347
+
Public: false,
348
+
AllowAllCrew: true,
349
+
DeployedAt: "2025-10-16T12:00:00Z",
350
+
Region: ptrString("us-west-2"),
351
+
Provider: ptrString("fly.io"),
347
352
},
348
353
},
349
354
{
350
355
name: "Captain with empty optional fields",
351
-
record: &atproto.CaptainRecord{
352
-
Type: atproto.CaptainCollection,
353
-
Owner: "did:plc:charlie789",
354
-
Public: true,
355
-
AllowAllCrew: true,
356
-
DeployedAt: "2025-10-16T12:00:00Z",
357
-
Region: "",
358
-
Provider: "",
356
+
record: &atproto.HoldCaptain{
357
+
LexiconTypeID: atproto.CaptainCollection,
358
+
Owner: "did:plc:charlie789",
359
+
Public: true,
360
+
AllowAllCrew: true,
361
+
DeployedAt: "2025-10-16T12:00:00Z",
362
+
Region: ptrString(""),
363
+
Provider: ptrString(""),
359
364
},
360
365
},
361
366
}
···
375
380
}
376
381
377
382
// Unmarshal from CBOR
378
-
var decoded atproto.CaptainRecord
383
+
var decoded atproto.HoldCaptain
379
384
err = decoded.UnmarshalCBOR(bytes.NewReader(cborBytes))
380
385
if err != nil {
381
386
t.Fatalf("UnmarshalCBOR failed: %v", err)
382
387
}
383
388
384
389
// Verify all fields match
385
-
if decoded.Type != tt.record.Type {
386
-
t.Errorf("Type mismatch: expected %s, got %s", tt.record.Type, decoded.Type)
390
+
if decoded.LexiconTypeID != tt.record.LexiconTypeID {
391
+
t.Errorf("LexiconTypeID mismatch: expected %s, got %s", tt.record.LexiconTypeID, decoded.LexiconTypeID)
387
392
}
388
393
if decoded.Owner != tt.record.Owner {
389
394
t.Errorf("Owner mismatch: expected %s, got %s", tt.record.Owner, decoded.Owner)
···
397
402
if decoded.DeployedAt != tt.record.DeployedAt {
398
403
t.Errorf("DeployedAt mismatch: expected %s, got %s", tt.record.DeployedAt, decoded.DeployedAt)
399
404
}
400
-
if decoded.Region != tt.record.Region {
401
-
t.Errorf("Region mismatch: expected %s, got %s", tt.record.Region, decoded.Region)
405
+
// Compare Region pointers (may be nil)
406
+
if (decoded.Region == nil) != (tt.record.Region == nil) {
407
+
t.Errorf("Region nil mismatch: expected %v, got %v", tt.record.Region, decoded.Region)
408
+
} else if decoded.Region != nil && *decoded.Region != *tt.record.Region {
409
+
t.Errorf("Region mismatch: expected %q, got %q", *tt.record.Region, *decoded.Region)
402
410
}
403
-
if decoded.Provider != tt.record.Provider {
404
-
t.Errorf("Provider mismatch: expected %s, got %s", tt.record.Provider, decoded.Provider)
411
+
// Compare Provider pointers (may be nil)
412
+
if (decoded.Provider == nil) != (tt.record.Provider == nil) {
413
+
t.Errorf("Provider nil mismatch: expected %v, got %v", tt.record.Provider, decoded.Provider)
414
+
} else if decoded.Provider != nil && *decoded.Provider != *tt.record.Provider {
415
+
t.Errorf("Provider mismatch: expected %q, got %q", *tt.record.Provider, *decoded.Provider)
405
416
}
406
417
})
407
418
}
+10
-10
pkg/hold/pds/crew.go
+10
-10
pkg/hold/pds/crew.go
···
15
15
16
16
// AddCrewMember adds a new crew member to the hold and commits to carstore
17
17
func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
18
-
crewRecord := &atproto.CrewRecord{
19
-
Type: atproto.CrewCollection,
20
-
Member: memberDID,
21
-
Role: role,
22
-
Permissions: permissions,
23
-
AddedAt: time.Now().Format(time.RFC3339),
18
+
crewRecord := &atproto.HoldCrew{
19
+
LexiconTypeID: atproto.CrewCollection,
20
+
Member: memberDID,
21
+
Role: role,
22
+
Permissions: permissions,
23
+
AddedAt: time.Now().Format(time.RFC3339),
24
24
}
25
25
26
26
// Use repomgr for crew operations - auto-generated rkey is fine
···
33
33
}
34
34
35
35
// GetCrewMember retrieves a crew member by their record key
36
-
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.CrewRecord, error) {
36
+
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.HoldCrew, error) {
37
37
// Use repomgr.GetRecord - our types are registered in init()
38
38
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CrewCollection, rkey, cid.Undef)
39
39
if err != nil {
···
41
41
}
42
42
43
43
// Type assert to our concrete type
44
-
crewRecord, ok := val.(*atproto.CrewRecord)
44
+
crewRecord, ok := val.(*atproto.HoldCrew)
45
45
if !ok {
46
46
return cid.Undef, nil, fmt.Errorf("unexpected type for crew record: %T", val)
47
47
}
···
53
53
type CrewMemberWithKey struct {
54
54
Rkey string
55
55
Cid cid.Cid
56
-
Record *atproto.CrewRecord
56
+
Record *atproto.HoldCrew
57
57
}
58
58
59
59
// ListCrewMembers returns all crew members with their rkeys
···
108
108
}
109
109
110
110
// Unmarshal the CBOR bytes into our concrete type
111
-
var crewRecord atproto.CrewRecord
111
+
var crewRecord atproto.HoldCrew
112
112
if err := crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
113
113
return fmt.Errorf("failed to decode crew record: %w", err)
114
114
}
+30
-30
pkg/hold/pds/crew_test.go
+30
-30
pkg/hold/pds/crew_test.go
···
53
53
t.Errorf("Expected permission[%d]=%s, got %s", i, perm, crew.Record.Permissions[i])
54
54
}
55
55
}
56
-
if crew.Record.Type != atproto.CrewCollection {
57
-
t.Errorf("Expected type %s, got %s", atproto.CrewCollection, crew.Record.Type)
56
+
if crew.Record.LexiconTypeID != atproto.CrewCollection {
57
+
t.Errorf("Expected type %s, got %s", atproto.CrewCollection, crew.Record.LexiconTypeID)
58
58
}
59
59
if crew.Record.AddedAt == "" {
60
60
t.Error("Expected addedAt to be set")
···
348
348
func TestCrewRecord_CBORRoundtrip(t *testing.T) {
349
349
tests := []struct {
350
350
name string
351
-
record *atproto.CrewRecord
351
+
record *atproto.HoldCrew
352
352
}{
353
353
{
354
354
name: "Basic crew member",
355
-
record: &atproto.CrewRecord{
356
-
Type: atproto.CrewCollection,
357
-
Member: "did:plc:alice123",
358
-
Role: "writer",
359
-
Permissions: []string{"blob:read", "blob:write"},
360
-
AddedAt: "2025-10-16T12:00:00Z",
355
+
record: &atproto.HoldCrew{
356
+
LexiconTypeID: atproto.CrewCollection,
357
+
Member: "did:plc:alice123",
358
+
Role: "writer",
359
+
Permissions: []string{"blob:read", "blob:write"},
360
+
AddedAt: "2025-10-16T12:00:00Z",
361
361
},
362
362
},
363
363
{
364
364
name: "Admin crew member",
365
-
record: &atproto.CrewRecord{
366
-
Type: atproto.CrewCollection,
367
-
Member: "did:plc:bob456",
368
-
Role: "admin",
369
-
Permissions: []string{"blob:read", "blob:write", "crew:admin"},
370
-
AddedAt: "2025-10-16T13:00:00Z",
365
+
record: &atproto.HoldCrew{
366
+
LexiconTypeID: atproto.CrewCollection,
367
+
Member: "did:plc:bob456",
368
+
Role: "admin",
369
+
Permissions: []string{"blob:read", "blob:write", "crew:admin"},
370
+
AddedAt: "2025-10-16T13:00:00Z",
371
371
},
372
372
},
373
373
{
374
374
name: "Reader crew member",
375
-
record: &atproto.CrewRecord{
376
-
Type: atproto.CrewCollection,
377
-
Member: "did:plc:charlie789",
378
-
Role: "reader",
379
-
Permissions: []string{"blob:read"},
380
-
AddedAt: "2025-10-16T14:00:00Z",
375
+
record: &atproto.HoldCrew{
376
+
LexiconTypeID: atproto.CrewCollection,
377
+
Member: "did:plc:charlie789",
378
+
Role: "reader",
379
+
Permissions: []string{"blob:read"},
380
+
AddedAt: "2025-10-16T14:00:00Z",
381
381
},
382
382
},
383
383
{
384
384
name: "Crew member with empty permissions",
385
-
record: &atproto.CrewRecord{
386
-
Type: atproto.CrewCollection,
387
-
Member: "did:plc:dave012",
388
-
Role: "none",
389
-
Permissions: []string{},
390
-
AddedAt: "2025-10-16T15:00:00Z",
385
+
record: &atproto.HoldCrew{
386
+
LexiconTypeID: atproto.CrewCollection,
387
+
Member: "did:plc:dave012",
388
+
Role: "none",
389
+
Permissions: []string{},
390
+
AddedAt: "2025-10-16T15:00:00Z",
391
391
},
392
392
},
393
393
}
···
407
407
}
408
408
409
409
// Unmarshal from CBOR
410
-
var decoded atproto.CrewRecord
410
+
var decoded atproto.HoldCrew
411
411
err = decoded.UnmarshalCBOR(bytes.NewReader(cborBytes))
412
412
if err != nil {
413
413
t.Fatalf("UnmarshalCBOR failed: %v", err)
414
414
}
415
415
416
416
// Verify all fields match
417
-
if decoded.Type != tt.record.Type {
418
-
t.Errorf("Type mismatch: expected %s, got %s", tt.record.Type, decoded.Type)
417
+
if decoded.LexiconTypeID != tt.record.LexiconTypeID {
418
+
t.Errorf("LexiconTypeID mismatch: expected %s, got %s", tt.record.LexiconTypeID, decoded.LexiconTypeID)
419
419
}
420
420
if decoded.Member != tt.record.Member {
421
421
t.Errorf("Member mismatch: expected %s, got %s", tt.record.Member, decoded.Member)
+5
-5
pkg/hold/pds/layer.go
+5
-5
pkg/hold/pds/layer.go
···
9
9
10
10
// CreateLayerRecord creates a new layer record in the hold's PDS
11
11
// Returns the rkey and CID of the created record
12
-
func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) {
12
+
func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.HoldLayer) (string, string, error) {
13
13
// Validate record
14
-
if record.Type != atproto.LayerCollection {
15
-
return "", "", fmt.Errorf("invalid record type: %s", record.Type)
14
+
if record.LexiconTypeID != atproto.LayerCollection {
15
+
return "", "", fmt.Errorf("invalid record type: %s", record.LexiconTypeID)
16
16
}
17
17
18
18
if record.Digest == "" {
···
40
40
41
41
// GetLayerRecord retrieves a specific layer record by rkey
42
42
// Note: This is a simplified implementation. For production, you may need to pass the CID
43
-
func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) {
43
+
func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.HoldLayer, error) {
44
44
// For now, we don't implement this as it's not needed for the manifest post feature
45
45
// Full implementation would require querying the carstore with a specific CID
46
46
return nil, fmt.Errorf("GetLayerRecord not yet implemented - use via XRPC listRecords instead")
···
50
50
// Returns records, next cursor (empty if no more), and error
51
51
// Note: This is a simplified implementation. For production, consider adding filters
52
52
// (by repository, user, digest, etc.) and proper pagination
53
-
func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) {
53
+
func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.HoldLayer, string, error) {
54
54
// For now, return empty list - full implementation would query the carstore
55
55
// This would require iterating over records in the collection and filtering
56
56
// In practice, layer records are mainly for analytics and Bluesky posts,
+19
-19
pkg/hold/pds/layer_test.go
+19
-19
pkg/hold/pds/layer_test.go
···
12
12
13
13
tests := []struct {
14
14
name string
15
-
record *atproto.LayerRecord
15
+
record *atproto.HoldLayer
16
16
wantErr bool
17
17
errSubstr string
18
18
}{
···
42
42
},
43
43
{
44
44
name: "invalid record type",
45
-
record: &atproto.LayerRecord{
46
-
Type: "wrong.type",
45
+
record: &atproto.HoldLayer{
46
+
LexiconTypeID: "wrong.type",
47
47
Digest: "sha256:abc123",
48
48
Size: 1024,
49
49
MediaType: "application/vnd.oci.image.layer.v1.tar",
50
50
Repository: "test",
51
-
UserDID: "did:plc:test",
51
+
UserDid: "did:plc:test",
52
52
UserHandle: "test.example.com",
53
53
},
54
54
wantErr: true,
···
56
56
},
57
57
{
58
58
name: "missing digest",
59
-
record: &atproto.LayerRecord{
60
-
Type: atproto.LayerCollection,
59
+
record: &atproto.HoldLayer{
60
+
LexiconTypeID: atproto.LayerCollection,
61
61
Digest: "",
62
62
Size: 1024,
63
63
MediaType: "application/vnd.oci.image.layer.v1.tar",
64
64
Repository: "test",
65
-
UserDID: "did:plc:test",
65
+
UserDid: "did:plc:test",
66
66
UserHandle: "test.example.com",
67
67
},
68
68
wantErr: true,
···
70
70
},
71
71
{
72
72
name: "zero size",
73
-
record: &atproto.LayerRecord{
74
-
Type: atproto.LayerCollection,
73
+
record: &atproto.HoldLayer{
74
+
LexiconTypeID: atproto.LayerCollection,
75
75
Digest: "sha256:abc123",
76
76
Size: 0,
77
77
MediaType: "application/vnd.oci.image.layer.v1.tar",
78
78
Repository: "test",
79
-
UserDID: "did:plc:test",
79
+
UserDid: "did:plc:test",
80
80
UserHandle: "test.example.com",
81
81
},
82
82
wantErr: true,
···
84
84
},
85
85
{
86
86
name: "negative size",
87
-
record: &atproto.LayerRecord{
88
-
Type: atproto.LayerCollection,
87
+
record: &atproto.HoldLayer{
88
+
LexiconTypeID: atproto.LayerCollection,
89
89
Digest: "sha256:abc123",
90
90
Size: -1,
91
91
MediaType: "application/vnd.oci.image.layer.v1.tar",
92
92
Repository: "test",
93
-
UserDID: "did:plc:test",
93
+
UserDid: "did:plc:test",
94
94
UserHandle: "test.example.com",
95
95
},
96
96
wantErr: true,
···
191
191
}
192
192
193
193
// Verify all fields are set correctly
194
-
if record.Type != atproto.LayerCollection {
195
-
t.Errorf("Type = %q, want %q", record.Type, atproto.LayerCollection)
194
+
if record.LexiconTypeID != atproto.LayerCollection {
195
+
t.Errorf("LexiconTypeID = %q, want %q", record.LexiconTypeID, atproto.LayerCollection)
196
196
}
197
197
198
198
if record.Digest != digest {
···
211
211
t.Errorf("Repository = %q, want %q", record.Repository, repository)
212
212
}
213
213
214
-
if record.UserDID != userDID {
215
-
t.Errorf("UserDID = %q, want %q", record.UserDID, userDID)
214
+
if record.UserDid != userDID {
215
+
t.Errorf("UserDid = %q, want %q", record.UserDid, userDID)
216
216
}
217
217
218
218
if record.UserHandle != userHandle {
···
282
282
}
283
283
284
284
// Verify the record can be created
285
-
if record.Type != atproto.LayerCollection {
286
-
t.Errorf("Type = %q, want %q", record.Type, atproto.LayerCollection)
285
+
if record.LexiconTypeID != atproto.LayerCollection {
286
+
t.Errorf("Type = %q, want %q", record.LexiconTypeID, atproto.LayerCollection)
287
287
}
288
288
289
289
if record.Digest != tt.digest {
+3
-7
pkg/hold/pds/server.go
+3
-7
pkg/hold/pds/server.go
···
19
19
"github.com/ipfs/go-cid"
20
20
)
21
21
22
-
// init registers our custom ATProto types with indigo's lexutil type registry
23
-
// This allows repomgr.GetRecord to automatically unmarshal our types
22
+
// init registers the TangledProfileRecord type with indigo's lexutil type registry.
23
+
// Note: HoldCaptain, HoldCrew, and HoldLayer are registered in pkg/atproto/register.go (generated).
24
+
// TangledProfileRecord is external (sh.tangled.actor.profile) so we register it here.
24
25
func init() {
25
-
// Register captain, crew, tangled profile, and layer record types
26
-
// These must match the $type field in the records
27
-
lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{})
28
-
lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{})
29
-
lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
30
26
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
31
27
}
32
28
+6
-6
pkg/hold/pds/server_test.go
+6
-6
pkg/hold/pds/server_test.go
···
150
150
if captain.AllowAllCrew != allowAllCrew {
151
151
t.Errorf("Expected allowAllCrew=%v, got %v", allowAllCrew, captain.AllowAllCrew)
152
152
}
153
-
if captain.Type != atproto.CaptainCollection {
154
-
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type)
153
+
if captain.LexiconTypeID != atproto.CaptainCollection {
154
+
t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
155
155
}
156
156
if captain.DeployedAt == "" {
157
157
t.Error("Expected deployedAt to be set")
···
317
317
if captain == nil {
318
318
t.Fatal("Expected non-nil captain record")
319
319
}
320
-
if captain.Type != atproto.CaptainCollection {
321
-
t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.Type)
320
+
if captain.LexiconTypeID != atproto.CaptainCollection {
321
+
t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.LexiconTypeID)
322
322
}
323
323
324
324
// Do the same for crew record
···
331
331
}
332
332
333
333
crew := crewMembers[0].Record
334
-
if crew.Type != atproto.CrewCollection {
335
-
t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.Type)
334
+
if crew.LexiconTypeID != atproto.CrewCollection {
335
+
t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.LexiconTypeID)
336
336
}
337
337
}
338
338