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

Configure Feed

Select the types of activity you want to include in your feed.

general appview bugfixes

+150 -63
+1 -1
config-appview.example.yaml
··· 74 74 relay_endpoints: 75 75 - https://relay1.us-east.bsky.network 76 76 - https://relay1.us-west.bsky.network 77 - - https://zlay.waow.tech 77 + - https://relay.waow.tech 78 78 # JWT authentication settings. 79 79 auth: 80 80 # RSA private key for signing registry JWTs issued to Docker clients.
+3 -2
pkg/appview/db/hold_store.go
··· 333 333 c.permissions 334 334 FROM hold_captain_records h 335 335 LEFT JOIN hold_crew_members c ON h.hold_did = c.hold_did AND c.member_did = ?1 336 - WHERE h.allow_all_crew = 1 336 + WHERE (h.successor IS NULL OR h.successor = '') 337 + AND (h.allow_all_crew = 1 337 338 OR h.owner_did = ?1 338 - OR c.member_did IS NOT NULL 339 + OR c.member_did IS NOT NULL) 339 340 ORDER BY 340 341 CASE 341 342 WHEN h.owner_did = ?1 THEN 0
+26
pkg/appview/db/queries.go
··· 2022 2022 return UpdateManifestHoldDID(h.db, did, oldHoldDID, newHoldDID) 2023 2023 } 2024 2024 2025 + // GetDistinctManifestHoldDIDs returns all distinct hold DIDs referenced by a user's manifests. 2026 + func GetDistinctManifestHoldDIDs(db DBTX, did string) ([]string, error) { 2027 + rows, err := db.Query(` 2028 + SELECT DISTINCT hold_endpoint FROM manifests 2029 + WHERE did = ? AND hold_endpoint != '' 2030 + `, did) 2031 + if err != nil { 2032 + return nil, err 2033 + } 2034 + defer rows.Close() 2035 + var holds []string 2036 + for rows.Next() { 2037 + var h string 2038 + if err := rows.Scan(&h); err != nil { 2039 + return nil, err 2040 + } 2041 + holds = append(holds, h) 2042 + } 2043 + return holds, rows.Err() 2044 + } 2045 + 2046 + // GetDistinctManifestHoldDIDs wraps the package-level function. 2047 + func (h *HoldDIDDB) GetDistinctManifestHoldDIDs(did string) ([]string, error) { 2048 + return GetDistinctManifestHoldDIDs(h.db, did) 2049 + } 2050 + 2025 2051 // IsManifestReferenced checks if a digest is a child of any manifest list for the user. 2026 2052 // Implements storage.ManifestReferenceChecker. 2027 2053 func (h *HoldDIDDB) IsManifestReferenced(did, digest string) (bool, error) {
+28 -20
pkg/appview/handlers/repository.go
··· 594 594 ociClient = user.OciClient 595 595 } 596 596 597 + // Resolve viewer's default hold for per-entry badges 598 + var viewerDefaultHold string 599 + if viewerDID != "" { 600 + viewerDefaultHold = db.GetUserHoldDID(h.ReadOnlyDB, viewerDID) 601 + } 602 + 597 603 data := struct { 598 - Owner *db.User 599 - Repository *db.Repository 600 - Entries []db.ManifestEntry 601 - IsOwner bool 602 - ScanBatchParams []template.HTML 603 - RegistryURL string 604 - OciClient string 605 - HasMore bool 606 - NextOffset int 607 - IsFirstPage bool 604 + Owner *db.User 605 + Repository *db.Repository 606 + Entries []db.ManifestEntry 607 + IsOwner bool 608 + ScanBatchParams []template.HTML 609 + RegistryURL string 610 + OciClient string 611 + HasMore bool 612 + NextOffset int 613 + IsFirstPage bool 614 + ViewerDefaultHold string 608 615 }{ 609 - Owner: owner, 610 - Repository: &db.Repository{Name: repository}, 611 - Entries: entries, 612 - IsOwner: isOwner, 613 - ScanBatchParams: scanBatchParams, 614 - RegistryURL: h.RegistryURL, 615 - OciClient: ociClient, 616 - HasMore: hasMore, 617 - NextOffset: offset + pageSize, 618 - IsFirstPage: isFirstPage, 616 + Owner: owner, 617 + Repository: &db.Repository{Name: repository}, 618 + Entries: entries, 619 + IsOwner: isOwner, 620 + ScanBatchParams: scanBatchParams, 621 + RegistryURL: h.RegistryURL, 622 + OciClient: ociClient, 623 + HasMore: hasMore, 624 + NextOffset: offset + pageSize, 625 + IsFirstPage: isFirstPage, 626 + ViewerDefaultHold: viewerDefaultHold, 619 627 } 620 628 621 629 w.Header().Set("Content-Type", "text/html; charset=utf-8")
+4 -9
pkg/appview/jetstream/processor.go
··· 66 66 // EnsureUser resolves and upserts a user by DID 67 67 // Uses cache if enabled (Worker), queries DB if cache disabled (Backfill) 68 68 func (p *Processor) EnsureUser(ctx context.Context, did string) error { 69 - // Check cache first (if enabled) 69 + // Check cache first (if enabled) — within a single backfill run, 70 + // a user's identity won't change, so the cache hit is safe. 70 71 if p.useCache && p.userCache != nil { 71 72 if _, ok := p.userCache.cache[did]; ok { 72 - // User in cache - just update last seen timestamp 73 - return db.UpdateUserLastSeen(p.db, did) 74 - } 75 - } else if !p.useCache { 76 - // No cache - check if user already exists in DB 77 - existingUser, err := db.GetUserByDID(p.db, did) 78 - if err == nil && existingUser != nil { 79 - // User exists - just update last seen timestamp 80 73 return db.UpdateUserLastSeen(p.db, did) 81 74 } 82 75 } 76 + // No cache early-return: always re-resolve identity so stale handles 77 + // (e.g., user changed handle between backfill runs) get corrected. 83 78 84 79 // Resolve DID to get handle and PDS endpoint 85 80 resolvedDID, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
+1
pkg/appview/storage/context.go
··· 40 40 type HoldDIDLookup interface { 41 41 GetLatestHoldDIDForRepo(did, repository string) (string, error) 42 42 UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error) 43 + GetDistinctManifestHoldDIDs(did string) ([]string, error) 43 44 } 44 45 45 46 // RegistryContext bundles all the context needed for registry operations
+7
pkg/appview/storage/context_test.go
··· 21 21 return 0, nil 22 22 } 23 23 24 + func (m *mockHoldDIDLookup) GetDistinctManifestHoldDIDs(did string) ([]string, error) { 25 + if m.holdDID != "" { 26 + return []string{m.holdDID}, nil 27 + } 28 + return nil, nil 29 + } 30 + 24 31 func TestRegistryContext_Fields(t *testing.T) { 25 32 // Create a sample RegistryContext 26 33 ctx := &RegistryContext{
+56 -27
pkg/appview/storage/drain.go
··· 17 17 var drainLocks sync.Map 18 18 19 19 // MigrateManifestsForSuccessor rewrites manifest records and profile 20 - // when a user's defaultHold has a successor. Best-effort, runs in background. 20 + // when any of the user's holds have a successor. Best-effort, runs in background. 21 21 // 22 22 // Steps: 23 - // 1. Get user's sailor profile — check if defaultHold has a successor 24 - // 2. Update profile.DefaultHold from oldHold → newHold 25 - // 3. Walk all io.atcr.manifest records, rewrite holdDid from oldHold → newHold 26 - // 4. Update appview's local manifests table to match 23 + // 1. Get user's sailor profile 24 + // 2. Collect candidate holds: profile.DefaultHold + distinct holds from manifests DB 25 + // 3. For each hold with a successor: rewrite PDS manifest records and local DB 26 + // 4. If profile.DefaultHold itself had a successor, update the profile too 27 27 func MigrateManifestsForSuccessor( 28 28 ctx context.Context, 29 29 client *atproto.Client, ··· 43 43 slog.Debug("Drain: failed to get profile", "component", "storage/drain", "did", did, "error", err) 44 44 return 45 45 } 46 - if profile == nil || profile.DefaultHold == "" { 47 - return 46 + 47 + // 2. Collect candidate holds to check for successors. 48 + // Start with profile.DefaultHold, then add any distinct holds from the DB. 49 + candidates := make(map[string]bool) 50 + if profile != nil && profile.DefaultHold != "" { 51 + candidates[profile.DefaultHold] = true 52 + } 53 + if db != nil { 54 + manifestHolds, err := db.GetDistinctManifestHoldDIDs(did) 55 + if err != nil { 56 + slog.Warn("Drain: failed to get distinct manifest holds", "component", "storage/drain", "did", did, "error", err) 57 + } 58 + for _, h := range manifestHolds { 59 + candidates[h] = true 60 + } 48 61 } 49 62 50 - // 2. Check if their defaultHold has a successor 51 - oldHold := profile.DefaultHold 52 - captain, err := authorizer.GetCaptainRecord(ctx, oldHold) 53 - if err != nil { 54 - slog.Debug("Drain: failed to get captain record", "component", "storage/drain", "did", did, "hold", oldHold, "error", err) 63 + if len(candidates) == 0 { 55 64 return 56 65 } 57 - if captain == nil || captain.Successor == "" { 58 - return // No successor — nothing to drain 59 - } 60 - newHold := captain.Successor 66 + 67 + // 3. Check each candidate for a successor and drain if found 68 + for oldHold := range candidates { 69 + captain, err := authorizer.GetCaptainRecord(ctx, oldHold) 70 + if err != nil { 71 + slog.Debug("Drain: failed to get captain record", "component", "storage/drain", "did", did, "hold", oldHold, "error", err) 72 + continue 73 + } 74 + if captain == nil || captain.Successor == "" { 75 + continue 76 + } 77 + newHold := captain.Successor 61 78 62 - slog.Info("Starting hold drain", "component", "storage/drain", "did", did, "from", oldHold, "to", newHold) 79 + slog.Info("Starting hold drain", "component", "storage/drain", "did", did, "from", oldHold, "to", newHold) 63 80 64 - // 3. Update profile.DefaultHold 65 - profile.DefaultHold = newHold 66 - profile.UpdatedAt = time.Now() 67 - if err := UpdateProfile(ctx, client, profile); err != nil { 68 - slog.Warn("Drain: failed to update profile", "component", "storage/drain", "did", did, "error", err) 69 - // Continue — manifest rewrite is still valuable even if profile update fails 70 - } else { 71 - slog.Info("Drain: updated profile defaultHold", "component", "storage/drain", "did", did, "newHold", newHold) 81 + drainHold(ctx, client, db, did, oldHold, newHold) 82 + 83 + // 4. If profile.DefaultHold pointed to this old hold, update it 84 + if profile != nil && profile.DefaultHold == oldHold { 85 + profile.DefaultHold = newHold 86 + profile.UpdatedAt = time.Now() 87 + if err := UpdateProfile(ctx, client, profile); err != nil { 88 + slog.Warn("Drain: failed to update profile", "component", "storage/drain", "did", did, "error", err) 89 + } else { 90 + slog.Info("Drain: updated profile defaultHold", "component", "storage/drain", "did", did, "newHold", newHold) 91 + } 92 + } 72 93 } 94 + } 73 95 74 - // 4. Walk manifest records, rewrite holdDid 96 + // drainHold rewrites all PDS manifest records and local DB rows from oldHold to newHold. 97 + func drainHold( 98 + ctx context.Context, 99 + client *atproto.Client, 100 + db HoldDIDLookup, 101 + did, oldHold, newHold string, 102 + ) { 103 + // Walk PDS manifest records, rewrite holdDid 75 104 cursor := "" 76 105 rewritten := 0 77 106 for { ··· 126 155 cursor = nextCursor 127 156 } 128 157 129 - // 5. Update appview's local manifests table 158 + // Update appview's local manifests table 130 159 if db != nil { 131 160 dbUpdated, err := db.UpdateManifestHoldDID(did, oldHold, newHold) 132 161 if err != nil {
+7
pkg/appview/storage/routing_repository_test.go
··· 37 37 return 0, nil 38 38 } 39 39 40 + func (m *mockDatabase) GetDistinctManifestHoldDIDs(did string) ([]string, error) { 41 + if m.holdDID != "" { 42 + return []string{m.holdDID}, nil 43 + } 44 + return nil, nil 45 + } 46 + 40 47 func TestNewRoutingRepository(t *testing.T) { 41 48 ctx := &RegistryContext{ 42 49 DID: "did:plc:test123",
+5 -2
pkg/appview/templates/partials/repo-tags.html
··· 18 18 {{ icon "shield-check" "size-3" }} Attested 19 19 </button> 20 20 {{ end }} 21 + {{ if and .ViewerDefaultHold .Entry.HoldEndpoint (ne .Entry.HoldEndpoint .ViewerDefaultHold) }} 22 + <span class="badge badge-xs badge-soft badge-warning" title="{{ .Entry.HoldEndpoint }}">{{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }}</span> 23 + {{ end }} 21 24 </div> 22 25 <div class="flex items-center gap-2 shrink-0"> 23 26 <span class="text-base-content text-sm flex items-center gap-1" title="{{ .Entry.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Entry.CreatedAt }}</span> ··· 160 163 <div class="card bg-base-100 shadow-sm border border-base-300"> 161 164 <div class="divide-y divide-base-200" id="tags-list"> 162 165 {{ range .Entries }} 163 - {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner) }} 166 + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner "ViewerDefaultHold" $.ViewerDefaultHold) }} 164 167 {{ end }} 165 168 </div> 166 169 {{ template "load-more-button" . }} ··· 175 178 176 179 {{ define "repo-tags-page" }} 177 180 {{ range .Entries }} 178 - {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner) }} 181 + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner "ViewerDefaultHold" $.ViewerDefaultHold) }} 179 182 {{ end }} 180 183 {{ template "load-more-button" . }} 181 184 {{ template "scan-batch-triggers" . }}
-1
pkg/atproto/relays.go
··· 34 34 {Name: "Xero", URL: "https://relay.xero.systems"}, 35 35 {Name: "Feeds Blue", URL: "https://relay.feeds.blue"}, 36 36 {Name: "Waow", URL: "https://relay.waow.tech"}, 37 - {Name: "Zlay", URL: "https://zlay.waow.tech"}, 38 37 {Name: "Bassh", URL: "https://relay.bas.sh"}, 39 38 } 40 39
+12 -1
pkg/auth/hold_remote.go
··· 103 103 // 3. Update cache 104 104 func (a *RemoteHoldAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) { 105 105 // Try cache first 106 + var staleCached *captainRecordWithMeta 106 107 if a.db != nil { 107 108 cached, err := a.getCachedCaptainRecord(holdDID) 108 109 if err == nil && cached != nil { ··· 110 111 if time.Since(cached.UpdatedAt) < a.cacheTTL { 111 112 return cached.CaptainRecord, nil 112 113 } 113 - // Cache expired - continue to fetch fresh data 114 + // Cache expired - keep as fallback in case XRPC fetch fails 115 + staleCached = cached 114 116 } 115 117 } 116 118 117 119 // Cache miss or expired - query XRPC endpoint 118 120 record, err := a.fetchCaptainRecordFromXRPC(ctx, holdDID) 119 121 if err != nil { 122 + // If the hold is unreachable but we have stale cache, use it. 123 + // Successor fields don't change once set, so stale data is safe. 124 + if staleCached != nil { 125 + slog.Warn("Captain record fetch failed, using stale cache", 126 + "holdDID", holdDID, 127 + "cache_age", time.Since(staleCached.UpdatedAt), 128 + "error", err) 129 + return staleCached.CaptainRecord, nil 130 + } 120 131 slog.Error("Captain record fetch failed", 121 132 "holdDID", holdDID, 122 133 "denial_reason", "captain_record_fetch_failed",