diff --git a/config-appview.example.yaml b/config-appview.example.yaml index c06f919..6391938 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -74,7 +74,7 @@ jetstream: relay_endpoints: - https://relay1.us-east.bsky.network - https://relay1.us-west.bsky.network - - https://zlay.waow.tech + - https://relay.waow.tech # JWT authentication settings. auth: # RSA private key for signing registry JWTs issued to Docker clients. diff --git a/pkg/appview/db/hold_store.go b/pkg/appview/db/hold_store.go index 7b6ce94..47f9cb8 100644 --- a/pkg/appview/db/hold_store.go +++ b/pkg/appview/db/hold_store.go @@ -333,9 +333,10 @@ func GetAvailableHolds(db DBTX, userDID string) ([]AvailableHold, error) { c.permissions FROM hold_captain_records h LEFT JOIN hold_crew_members c ON h.hold_did = c.hold_did AND c.member_did = ?1 - WHERE h.allow_all_crew = 1 + WHERE (h.successor IS NULL OR h.successor = '') + AND (h.allow_all_crew = 1 OR h.owner_did = ?1 - OR c.member_did IS NOT NULL + OR c.member_did IS NOT NULL) ORDER BY CASE WHEN h.owner_did = ?1 THEN 0 diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index be69189..010a04d 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -2022,6 +2022,32 @@ func (h *HoldDIDDB) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (i return UpdateManifestHoldDID(h.db, did, oldHoldDID, newHoldDID) } +// GetDistinctManifestHoldDIDs returns all distinct hold DIDs referenced by a user's manifests. +func GetDistinctManifestHoldDIDs(db DBTX, did string) ([]string, error) { + rows, err := db.Query(` + SELECT DISTINCT hold_endpoint FROM manifests + WHERE did = ? AND hold_endpoint != '' + `, did) + if err != nil { + return nil, err + } + defer rows.Close() + var holds []string + for rows.Next() { + var h string + if err := rows.Scan(&h); err != nil { + return nil, err + } + holds = append(holds, h) + } + return holds, rows.Err() +} + +// GetDistinctManifestHoldDIDs wraps the package-level function. +func (h *HoldDIDDB) GetDistinctManifestHoldDIDs(did string) ([]string, error) { + return GetDistinctManifestHoldDIDs(h.db, did) +} + // IsManifestReferenced checks if a digest is a child of any manifest list for the user. // Implements storage.ManifestReferenceChecker. func (h *HoldDIDDB) IsManifestReferenced(did, digest string) (bool, error) { diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 309f6f4..84c6094 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -594,28 +594,36 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request ociClient = user.OciClient } + // Resolve viewer's default hold for per-entry badges + var viewerDefaultHold string + if viewerDID != "" { + viewerDefaultHold = db.GetUserHoldDID(h.ReadOnlyDB, viewerDID) + } + data := struct { - Owner *db.User - Repository *db.Repository - Entries []db.ManifestEntry - IsOwner bool - ScanBatchParams []template.HTML - RegistryURL string - OciClient string - HasMore bool - NextOffset int - IsFirstPage bool + Owner *db.User + Repository *db.Repository + Entries []db.ManifestEntry + IsOwner bool + ScanBatchParams []template.HTML + RegistryURL string + OciClient string + HasMore bool + NextOffset int + IsFirstPage bool + ViewerDefaultHold string }{ - Owner: owner, - Repository: &db.Repository{Name: repository}, - Entries: entries, - IsOwner: isOwner, - ScanBatchParams: scanBatchParams, - RegistryURL: h.RegistryURL, - OciClient: ociClient, - HasMore: hasMore, - NextOffset: offset + pageSize, - IsFirstPage: isFirstPage, + Owner: owner, + Repository: &db.Repository{Name: repository}, + Entries: entries, + IsOwner: isOwner, + ScanBatchParams: scanBatchParams, + RegistryURL: h.RegistryURL, + OciClient: ociClient, + HasMore: hasMore, + NextOffset: offset + pageSize, + IsFirstPage: isFirstPage, + ViewerDefaultHold: viewerDefaultHold, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index 88873da..7728546 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -66,20 +66,15 @@ func NewProcessor(database db.DBTX, useCache bool, statsCache *StatsCache) *Proc // EnsureUser resolves and upserts a user by DID // Uses cache if enabled (Worker), queries DB if cache disabled (Backfill) func (p *Processor) EnsureUser(ctx context.Context, did string) error { - // Check cache first (if enabled) + // Check cache first (if enabled) — within a single backfill run, + // a user's identity won't change, so the cache hit is safe. if p.useCache && p.userCache != nil { if _, ok := p.userCache.cache[did]; ok { - // User in cache - just update last seen timestamp - return db.UpdateUserLastSeen(p.db, did) - } - } else if !p.useCache { - // No cache - check if user already exists in DB - existingUser, err := db.GetUserByDID(p.db, did) - if err == nil && existingUser != nil { - // User exists - just update last seen timestamp return db.UpdateUserLastSeen(p.db, did) } } + // No cache early-return: always re-resolve identity so stale handles + // (e.g., user changed handle between backfill runs) get corrected. // Resolve DID to get handle and PDS endpoint resolvedDID, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did) diff --git a/pkg/appview/storage/context.go b/pkg/appview/storage/context.go index 5a3dd80..ecd82c8 100644 --- a/pkg/appview/storage/context.go +++ b/pkg/appview/storage/context.go @@ -40,6 +40,7 @@ type PushWebhookEvent struct { type HoldDIDLookup interface { GetLatestHoldDIDForRepo(did, repository string) (string, error) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error) + GetDistinctManifestHoldDIDs(did string) ([]string, error) } // RegistryContext bundles all the context needed for registry operations diff --git a/pkg/appview/storage/context_test.go b/pkg/appview/storage/context_test.go index 6c576f0..68fe573 100644 --- a/pkg/appview/storage/context_test.go +++ b/pkg/appview/storage/context_test.go @@ -21,6 +21,13 @@ func (m *mockHoldDIDLookup) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID st return 0, nil } +func (m *mockHoldDIDLookup) GetDistinctManifestHoldDIDs(did string) ([]string, error) { + if m.holdDID != "" { + return []string{m.holdDID}, nil + } + return nil, nil +} + func TestRegistryContext_Fields(t *testing.T) { // Create a sample RegistryContext ctx := &RegistryContext{ diff --git a/pkg/appview/storage/drain.go b/pkg/appview/storage/drain.go index e46e2f6..e9bf713 100644 --- a/pkg/appview/storage/drain.go +++ b/pkg/appview/storage/drain.go @@ -17,13 +17,13 @@ import ( var drainLocks sync.Map // MigrateManifestsForSuccessor rewrites manifest records and profile -// when a user's defaultHold has a successor. Best-effort, runs in background. +// when any of the user's holds have a successor. Best-effort, runs in background. // // Steps: -// 1. Get user's sailor profile — check if defaultHold has a successor -// 2. Update profile.DefaultHold from oldHold → newHold -// 3. Walk all io.atcr.manifest records, rewrite holdDid from oldHold → newHold -// 4. Update appview's local manifests table to match +// 1. Get user's sailor profile +// 2. Collect candidate holds: profile.DefaultHold + distinct holds from manifests DB +// 3. For each hold with a successor: rewrite PDS manifest records and local DB +// 4. If profile.DefaultHold itself had a successor, update the profile too func MigrateManifestsForSuccessor( ctx context.Context, client *atproto.Client, @@ -43,35 +43,64 @@ func MigrateManifestsForSuccessor( slog.Debug("Drain: failed to get profile", "component", "storage/drain", "did", did, "error", err) return } - if profile == nil || profile.DefaultHold == "" { + + // 2. Collect candidate holds to check for successors. + // Start with profile.DefaultHold, then add any distinct holds from the DB. + candidates := make(map[string]bool) + if profile != nil && profile.DefaultHold != "" { + candidates[profile.DefaultHold] = true + } + if db != nil { + manifestHolds, err := db.GetDistinctManifestHoldDIDs(did) + if err != nil { + slog.Warn("Drain: failed to get distinct manifest holds", "component", "storage/drain", "did", did, "error", err) + } + for _, h := range manifestHolds { + candidates[h] = true + } + } + + if len(candidates) == 0 { return } - // 2. Check if their defaultHold has a successor - oldHold := profile.DefaultHold - captain, err := authorizer.GetCaptainRecord(ctx, oldHold) - if err != nil { - slog.Debug("Drain: failed to get captain record", "component", "storage/drain", "did", did, "hold", oldHold, "error", err) - return - } - if captain == nil || captain.Successor == "" { - return // No successor — nothing to drain - } - newHold := captain.Successor + // 3. Check each candidate for a successor and drain if found + for oldHold := range candidates { + captain, err := authorizer.GetCaptainRecord(ctx, oldHold) + if err != nil { + slog.Debug("Drain: failed to get captain record", "component", "storage/drain", "did", did, "hold", oldHold, "error", err) + continue + } + if captain == nil || captain.Successor == "" { + continue + } + newHold := captain.Successor - slog.Info("Starting hold drain", "component", "storage/drain", "did", did, "from", oldHold, "to", newHold) + slog.Info("Starting hold drain", "component", "storage/drain", "did", did, "from", oldHold, "to", newHold) - // 3. Update profile.DefaultHold - profile.DefaultHold = newHold - profile.UpdatedAt = time.Now() - if err := UpdateProfile(ctx, client, profile); err != nil { - slog.Warn("Drain: failed to update profile", "component", "storage/drain", "did", did, "error", err) - // Continue — manifest rewrite is still valuable even if profile update fails - } else { - slog.Info("Drain: updated profile defaultHold", "component", "storage/drain", "did", did, "newHold", newHold) + drainHold(ctx, client, db, did, oldHold, newHold) + + // 4. If profile.DefaultHold pointed to this old hold, update it + if profile != nil && profile.DefaultHold == oldHold { + profile.DefaultHold = newHold + profile.UpdatedAt = time.Now() + if err := UpdateProfile(ctx, client, profile); err != nil { + slog.Warn("Drain: failed to update profile", "component", "storage/drain", "did", did, "error", err) + } else { + slog.Info("Drain: updated profile defaultHold", "component", "storage/drain", "did", did, "newHold", newHold) + } + } } +} - // 4. Walk manifest records, rewrite holdDid +// drainHold rewrites all PDS manifest records and local DB rows from oldHold to newHold. +func drainHold( + ctx context.Context, + client *atproto.Client, + db HoldDIDLookup, + did, oldHold, newHold string, +) { + // Walk PDS manifest records, rewrite holdDid cursor := "" rewritten := 0 for { @@ -126,7 +155,7 @@ func MigrateManifestsForSuccessor( cursor = nextCursor } - // 5. Update appview's local manifests table + // Update appview's local manifests table if db != nil { dbUpdated, err := db.UpdateManifestHoldDID(did, oldHold, newHold) if err != nil { diff --git a/pkg/appview/storage/routing_repository_test.go b/pkg/appview/storage/routing_repository_test.go index 64bb8bb..dbbb06c 100644 --- a/pkg/appview/storage/routing_repository_test.go +++ b/pkg/appview/storage/routing_repository_test.go @@ -37,6 +37,13 @@ func (m *mockDatabase) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) return 0, nil } +func (m *mockDatabase) GetDistinctManifestHoldDIDs(did string) ([]string, error) { + if m.holdDID != "" { + return []string{m.holdDID}, nil + } + return nil, nil +} + func TestNewRoutingRepository(t *testing.T) { ctx := &RegistryContext{ DID: "did:plc:test123", diff --git a/pkg/appview/templates/partials/repo-tags.html b/pkg/appview/templates/partials/repo-tags.html index 63ec876..a54e998 100644 --- a/pkg/appview/templates/partials/repo-tags.html +++ b/pkg/appview/templates/partials/repo-tags.html @@ -18,6 +18,9 @@ {{ icon "shield-check" "size-3" }} Attested {{ end }} + {{ if and .ViewerDefaultHold .Entry.HoldEndpoint (ne .Entry.HoldEndpoint .ViewerDefaultHold) }} + {{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }} + {{ end }}