Files
at-container-registry/pkg/appview/jetstream/processor.go
T
Evan JarrettandClaude Opus 5 454a6bad3d db: key manifests by manifest_key and drop the rowid
Completes the swap 0033 set up. layers and manifest_references move onto
manifest_key and manifests.id is gone, which removes the last node-allocated
identifier in the AppView schema.

Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE
performs an implicit DELETE FROM, so dropping manifests while layers still holds
an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did
exactly that; it went unnoticed because the Jetstream backfill rebuilds layers
from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help:
it is a no-op inside a transaction and migrations run in one. So the new
children are built pointing at manifests_new, the old children are dropped
first, and only then is the old manifests table dropped, by which point nothing
references it. Verified both behaviors before relying on them.

manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a
PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That
constraint immediately caught four test helpers inserting manifests without one.

Five queries used MAX(id) as "the newest manifest in this repo", which I had
previously reported as absent after grepping only for ORDER BY. A derived key
has no ordering, so recency now comes from created_at with manifest_key as a
deterministic tiebreak. This is a real behavior change, and a fix: the two
disagree whenever a manifest is indexed out of order, which the backfill does
routinely, and created_at is the push time these queries always wanted. Both
directions are tested, including that ties resolve the same way every run.

InsertManifest and BatchInsertManifests no longer read anything back. The key is
derived from (did, repository, digest), so the writer knows it before the
statement runs: the select-back, its per-DID IN list, and the "manifest missing
id after batch insert" branch all go away, along with the UNIQUE-conflict
fallback that existed only to recover a rowid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:44:41 -05:00

1079 lines
36 KiB
Go

package jetstream
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
atpdata "github.com/bluesky-social/indigo/atproto/atdata"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/lexicon"
)
// Processor handles shared database operations for both Worker (live) and Backfill (sync)
// This eliminates code duplication between the two data ingestion paths
type Processor struct {
db db.DBTX
userCache *UserCache // Optional - enabled for Worker, disabled for Backfill
statsCache *StatsCache // In-memory cache for per-hold stats aggregation
useCache bool
catalog *lexicon.ResolvingCatalog // For debug logging of validation failures
webhookDispatcher WebhookDispatcher // Optional - only for live Worker (nil for Backfill)
}
// WebhookDispatcher is an interface for dispatching webhooks on scan completion.
// Only the live Worker sets this; backfill does NOT (avoids spamming old scan results).
type WebhookDispatcher interface {
DispatchForScan(ctx context.Context, scan, previousScan *db.Scan, userHandle, tag, holdEndpoint string)
}
// SetWebhookDispatcher sets the webhook dispatcher for scan processing.
// Only the live Worker should set this — backfill skips webhook dispatch.
func (p *Processor) SetWebhookDispatcher(d WebhookDispatcher) {
p.webhookDispatcher = d
}
// NewProcessor creates a new shared processor
// useCache: true for Worker (live streaming), false for Backfill (batch processing)
// statsCache: shared stats cache for aggregating across holds (nil to skip stats processing)
func NewProcessor(database db.DBTX, useCache bool, statsCache *StatsCache) *Processor {
// Create lexicon catalog for debug validation logging
dir := identity.DefaultDirectory()
catalog := lexicon.NewResolvingCatalog()
catalog.Directory = dir
p := &Processor{
db: database,
useCache: useCache,
statsCache: statsCache,
catalog: catalog,
}
if useCache {
p.userCache = &UserCache{
cache: make(map[string]*db.User),
}
}
return p
}
// 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) — 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 {
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)
if err != nil {
return err
}
// Fetch user's Bluesky profile record from their PDS (including avatar)
avatarURL := ""
client := atproto.NewClient(pdsEndpoint, "", "")
profileRecord, err := client.GetProfileRecord(ctx, resolvedDID)
if err != nil {
slog.Warn("Failed to fetch profile record", "component", "processor", "did", resolvedDID, "error", err)
// Continue without avatar
} else if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
avatarURL = atproto.BlobCDNURL(resolvedDID, profileRecord.Avatar.Ref.Link)
}
// Create user record
user := &db.User{
DID: resolvedDID,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: avatarURL,
LastSeen: time.Now(),
}
// Cache if enabled
if p.useCache {
p.userCache.cache[did] = user
}
// Upsert to database
// Use UpsertUser if we successfully fetched an avatar (to update existing users)
// Use UpsertUserIgnoreAvatar if fetch failed (to preserve existing avatars)
if avatarURL != "" {
return db.UpsertUser(p.db, user)
}
return db.UpsertUserIgnoreAvatar(p.db, user)
}
// EnsureUserExists ensures a user row exists in the database without updating it.
// Used by non-profile collections to avoid unnecessary writes during backfill.
// If the user doesn't exist, resolves identity and inserts with ON CONFLICT DO NOTHING.
func (p *Processor) EnsureUserExists(ctx context.Context, did string) error {
// Check cache first (if enabled)
if p.useCache && p.userCache != nil {
if _, ok := p.userCache.cache[did]; ok {
return nil // User in cache, nothing to do
}
} 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 {
return nil // User exists, nothing to do
}
}
// User doesn't exist yet — resolve and insert
resolvedDID, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
if err != nil {
return err
}
avatarURL := ""
client := atproto.NewClient(pdsEndpoint, "", "")
profileRecord, err := client.GetProfileRecord(ctx, resolvedDID)
if err != nil {
slog.Warn("Failed to fetch profile record", "component", "processor", "did", resolvedDID, "error", err)
} else if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
avatarURL = atproto.BlobCDNURL(resolvedDID, profileRecord.Avatar.Ref.Link)
}
user := &db.User{
DID: resolvedDID,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: avatarURL,
LastSeen: time.Now(),
}
// Cache if enabled
if p.useCache {
p.userCache.cache[did] = user
}
return db.InsertUserIfNotExists(p.db, user)
}
// ValidateRecord performs validation on records.
// - Full lexicon validation is logged for debugging but does NOT block ingestion
// - Targeted validation (captain/crew DID checks) DOES block bogus records
func (p *Processor) ValidateRecord(ctx context.Context, collection string, data []byte) error {
recordData, err := atpdata.UnmarshalJSON(data)
if err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
// Debug: Full lexicon validation (log only, don't block)
if p.catalog != nil {
if err := lexicon.ValidateRecord(p.catalog, recordData, collection, 0); err != nil {
slog.Debug("Record failed full lexicon validation (ingesting anyway)",
"component", "processor",
"collection", collection,
"error", err)
}
}
// Targeted validation for collections that had bogus data issues
// These DO block ingestion
switch collection {
case atproto.CaptainCollection:
// Captain must have non-empty owner DID
owner, _ := recordData["owner"].(string)
if owner == "" || !strings.HasPrefix(owner, "did:") {
return fmt.Errorf("captain record missing or invalid owner DID")
}
case atproto.CrewCollection:
// Crew must have non-empty member DID
member, _ := recordData["member"].(string)
if member == "" || !strings.HasPrefix(member, "did:") {
return fmt.Errorf("crew record missing or invalid member DID")
}
}
return nil
}
// ProcessRecord is the unified entry point for processing any ATCR record.
// It handles:
// 1. Schema validation against published lexicons
// 2. User creation for user-activity collections
// 3. Dispatch to the appropriate Process* method
//
// queryCaptainFn is optional - used by backfill for sailor profile processing
func (p *Processor) ProcessRecord(ctx context.Context, did, collection, rkey string, data []byte, isDelete bool, queryCaptainFn func(context.Context, string) error) error {
// Skip validation for deletes (no record data)
if !isDelete && data != nil {
if err := p.ValidateRecord(ctx, collection, data); err != nil {
slog.Warn("Record failed schema validation, skipping",
"component", "processor",
"collection", collection,
"did", did,
"error", err)
return nil // Skip invalid records silently
}
}
// Skip ingestion for taken-down content
if !isDelete && data != nil {
if repo := extractRepoFromRecord(collection, data); repo != "" {
if taken, _ := db.IsTakenDown(p.db, did, repo); taken {
slog.Debug("Skipping taken-down content",
"component", "processor",
"did", did,
"collection", collection,
"repository", repo)
return nil
}
}
}
// User-activity collections create/update user entries
// Skip for deletes - user should already exist, and we don't need to resolve identity
if !isDelete {
switch collection {
case atproto.SailorProfileCollection:
// Sailor profile is the authoritative source for user data — full upsert
if err := p.EnsureUser(ctx, did); err != nil {
return fmt.Errorf("failed to ensure user: %w", err)
}
case atproto.ManifestCollection,
atproto.TagCollection,
atproto.StarCollection,
atproto.RepoPageCollection:
// Other user collections just need the row to exist — no update if unchanged
if err := p.EnsureUserExists(ctx, did); err != nil {
return fmt.Errorf("failed to ensure user exists: %w", err)
}
// Hold collections (captain, crew, stats) - don't create user entries
// These are records FROM holds, not user activity
}
}
// Dispatch to specific handler
switch collection {
case atproto.ManifestCollection:
if isDelete {
// Manifest rkey is the digest with "sha256:" stripped; the digest
// column stores the full form, so reconstruct it for the WHERE clause.
return db.DeleteManifest(p.db, did, "", "sha256:"+rkey)
}
_, err := p.ProcessManifest(ctx, did, data)
return err
case atproto.TagCollection:
if isDelete {
repo, tag := atproto.RKeyToRepositoryTag(rkey)
return db.DeleteTag(p.db, did, repo, tag)
}
return p.ProcessTag(ctx, did, data)
case atproto.StarCollection:
if isDelete {
ownerDID, repository, err := atproto.ParseStarRecordKey(rkey)
if err != nil {
return err
}
return db.DeleteStar(p.db, did, ownerDID, repository)
}
return p.ProcessStar(ctx, did, data)
case atproto.RepoPageCollection:
return p.ProcessRepoPage(ctx, did, rkey, data, isDelete)
case atproto.SailorProfileCollection:
return p.ProcessSailorProfile(ctx, did, data, queryCaptainFn)
case atproto.ScanCollection:
return p.ProcessScan(ctx, did, data, isDelete)
case atproto.StatsCollection:
return p.ProcessStats(ctx, did, data, isDelete)
case atproto.DailyStatsCollection:
return p.ProcessDailyStats(ctx, did, data, isDelete)
case atproto.CaptainCollection:
if isDelete {
return db.DeleteCaptainRecord(p.db, did)
}
return p.ProcessCaptain(ctx, did, data)
case atproto.CrewCollection:
if isDelete {
return db.DeleteCrewMemberByRkey(p.db, did, rkey)
}
return p.ProcessCrew(ctx, did, rkey, data)
default:
return nil // Unknown collection, ignore
}
}
// ProcessManifest processes a manifest record and stores it in the database
// Returns the manifest ID for further processing (layers/references)
func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData []byte) (string, error) {
// Unmarshal manifest record
var manifestRecord atproto.ManifestRecord
if err := json.Unmarshal(recordData, &manifestRecord); err != nil {
return "", fmt.Errorf("failed to unmarshal manifest: %w", err)
}
// Detect manifest type
isManifestList := len(manifestRecord.Manifests) > 0
// Extract hold DID from manifest (with fallback for legacy manifests)
// New manifests use holdDid field (DID format)
// Old manifests use holdEndpoint field (URL format) - convert to DID
holdDID := manifestRecord.HoldDID
if holdDID == "" && manifestRecord.HoldEndpoint != "" {
// Legacy manifest - resolve URL to DID via /.well-known/atproto-did
if resolved, err := atproto.ResolveHoldDID(ctx, manifestRecord.HoldEndpoint); err != nil {
slog.Warn("Failed to resolve hold DID from legacy manifest endpoint", "holdEndpoint", manifestRecord.HoldEndpoint, "error", err)
} else {
holdDID = resolved
}
}
// Detect artifact type from config media type
artifactType := "container-image"
if !isManifestList && manifestRecord.Config != nil {
artifactType = db.GetArtifactType(manifestRecord.Config.MediaType)
}
// Prepare manifest for insertion (WITHOUT annotation fields)
manifest := &db.Manifest{
DID: did,
Repository: manifestRecord.Repository,
Digest: manifestRecord.Digest,
MediaType: manifestRecord.MediaType,
SchemaVersion: manifestRecord.SchemaVersion,
HoldEndpoint: holdDID,
ArtifactType: artifactType,
CreatedAt: manifestRecord.CreatedAt,
// Annotations removed - stored separately in repository_annotations table
}
// Set config fields only for image manifests (not manifest lists)
if !isManifestList && manifestRecord.Config != nil {
manifest.ConfigDigest = manifestRecord.Config.Digest
manifest.ConfigSize = manifestRecord.Config.Size
}
// Track subject digest for attestation/referrer manifests
if manifestRecord.Subject != nil {
manifest.SubjectDigest = manifestRecord.Subject.Digest
}
// Insert manifest. The key is derived from (did, repository, digest), so it
// is known whether the row was inserted, updated or already present; there is
// no need to read an identifier back on a constraint conflict the way there
// was with a rowid.
manifestKey, err := db.InsertManifest(p.db, manifest)
if err != nil {
return "", fmt.Errorf("failed to insert manifest: %w", err)
}
// Update repository annotations ONLY if manifest has at least one non-empty annotation
if manifestRecord.Annotations != nil {
hasData := false
for _, value := range manifestRecord.Annotations {
if value != "" {
hasData = true
break
}
}
if hasData {
// Replace all annotations for this repository
err = db.UpsertRepositoryAnnotations(p.db, did, manifestRecord.Repository, manifestRecord.Annotations)
if err != nil {
return "", fmt.Errorf("failed to upsert annotations: %w", err)
}
}
}
// Insert manifest references or layers
if isManifestList {
// Insert manifest references (for manifest lists/indexes)
for i, ref := range manifestRecord.Manifests {
platformArch := ""
platformOS := ""
platformVariant := ""
platformOSVersion := ""
if ref.Platform != nil {
platformArch = ref.Platform.Architecture
platformOS = ref.Platform.OS
platformVariant = ref.Platform.Variant
platformOSVersion = ref.Platform.OSVersion
}
// Detect attestation manifests from annotations
isAttestation := false
if ref.Annotations != nil {
if refType, ok := ref.Annotations["vnd.docker.reference.type"]; ok {
isAttestation = refType == "attestation-manifest"
}
}
if err := db.InsertManifestReference(p.db, &db.ManifestReference{
ManifestKey: manifestKey,
Digest: ref.Digest,
MediaType: ref.MediaType,
Size: ref.Size,
PlatformArchitecture: platformArch,
PlatformOS: platformOS,
PlatformVariant: platformVariant,
PlatformOSVersion: platformOSVersion,
IsAttestation: isAttestation,
ReferenceIndex: i,
}); err != nil {
// Continue on error - reference might already exist
continue
}
}
} else {
// Insert layers (for image manifests)
for i, layer := range manifestRecord.Layers {
if err := db.InsertLayer(p.db, &db.Layer{
ManifestKey: manifestKey,
Digest: layer.Digest,
MediaType: layer.MediaType,
Size: layer.Size,
LayerIndex: i,
Annotations: layer.Annotations,
}); err != nil {
// Continue on error - layer might already exist
continue
}
}
}
return manifestKey, nil
}
// ProcessTag processes a tag record and stores it in the database
func (p *Processor) ProcessTag(ctx context.Context, did string, recordData []byte) error {
// Unmarshal tag record
var tagRecord atproto.TagRecord
if err := json.Unmarshal(recordData, &tagRecord); err != nil {
return fmt.Errorf("failed to unmarshal tag: %w", err)
}
// Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
manifestDigest, err := tagRecord.GetManifestDigest()
if err != nil {
return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
}
// Insert or update tag
return db.UpsertTag(p.db, &db.Tag{
DID: did,
Repository: tagRecord.Repository,
Tag: tagRecord.Tag,
Digest: manifestDigest,
CreatedAt: tagRecord.UpdatedAt,
})
}
// ProcessStar processes a star record and stores it in the database.
// Records that don't match the current schema (bad JSON or unparseable
// subject AT URI) are skipped with a warning rather than failing the event,
// matching the backfill behavior.
func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []byte) error {
var starRecord atproto.StarRecord
if err := json.Unmarshal(recordData, &starRecord); err != nil {
slog.Warn("Skipping invalid star record", "component", "processor", "did", did, "error", err)
return nil
}
ownerDID, repository, err := starRecord.GetSubjectDIDAndRepository()
if err != nil {
slog.Warn("Skipping star with bad subject", "component", "processor", "did", did, "error", err)
return nil
}
// Ensure the starred repository's owner exists in the users table
// (the starrer is already ensured by ProcessRecord, but the owner
// may not have been processed yet during backfill or live events)
if err := p.EnsureUserExists(ctx, ownerDID); err != nil {
return fmt.Errorf("failed to ensure star subject user: %w", err)
}
// Upsert the star record (idempotent - won't duplicate)
// The DID here is the starrer (user who starred)
// Star count will be calculated on demand from the stars table
return db.UpsertStar(p.db, did, ownerDID, repository, starRecord.CreatedAt)
}
// ProcessSailorProfile processes a sailor profile record
// This is primarily used by backfill to cache captain records for holds
func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, recordData []byte, queryCaptainFn func(context.Context, string) error) error {
// Unmarshal sailor profile record
var profileRecord atproto.SailorProfileRecord
if err := json.Unmarshal(recordData, &profileRecord); err != nil {
return fmt.Errorf("failed to unmarshal sailor profile: %w", err)
}
// Cache OCI client preference (always, even if no default hold)
if profileRecord.OciClient != "" {
if err := db.UpdateUserOciClient(p.db, did, profileRecord.OciClient); err != nil {
slog.Warn("Failed to cache OCI client preference", "component", "processor", "did", did, "ociClient", profileRecord.OciClient, "error", err)
}
}
// Cache preferred registry domain (write unconditionally so clearing it propagates)
if err := db.UpdateUserRegistryDomain(p.db, did, profileRecord.RegistryDomain); err != nil {
slog.Warn("Failed to cache registry domain preference", "component", "processor", "did", did, "registryDomain", profileRecord.RegistryDomain, "error", err)
}
// Skip hold processing if no default hold set
if profileRecord.DefaultHold == "" {
return nil
}
// Convert hold URL/DID to canonical DID. On failure, cache the raw reference
// rather than leaving default_hold_did empty: "" means "operator default"
// (managed) to the billing gate, so a self-hosted user whose hold is briefly
// unresolvable must not fall through to managed. A managed hold's DID never
// fails resolution (DIDs return as-is), so only unreachable URL-form refs land
// here, and a raw non-DID value correctly reads as non-managed (fail closed).
holdDID, err := atproto.ResolveHoldDID(ctx, profileRecord.DefaultHold)
if err != nil {
slog.Warn("Invalid hold reference in profile; caching raw value (fails closed for billing)",
"component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err)
holdDID = profileRecord.DefaultHold
}
// Cache default hold DID on the user record
if err := db.UpdateUserDefaultHold(p.db, did, holdDID); err != nil {
slog.Warn("Failed to cache default hold DID", "component", "processor", "did", did, "holdDid", holdDID, "error", err)
}
// Query and cache the captain record using provided function
// This allows backfill-specific logic (retries, test mode handling) without duplicating it here
if queryCaptainFn != nil {
return queryCaptainFn(ctx, holdDID)
}
return nil
}
// ProcessRepoPage processes a repository page record
// This is called when Jetstream receives a repo page create/update event
func (p *Processor) ProcessRepoPage(ctx context.Context, did string, rkey string, recordData []byte, isDelete bool) error {
if isDelete {
// Delete the repo page from our cache
return db.DeleteRepoPage(p.db, did, rkey)
}
// Unmarshal repo page record
var pageRecord atproto.RepoPageRecord
if err := json.Unmarshal(recordData, &pageRecord); err != nil {
return fmt.Errorf("failed to unmarshal repo page: %w", err)
}
// Extract avatar CID if present
avatarCID := ""
if pageRecord.Avatar != nil && pageRecord.Avatar.Ref.Link != "" {
avatarCID = pageRecord.Avatar.Ref.Link
}
// Upsert to database
return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.UserEdited, pageRecord.CreatedAt, pageRecord.UpdatedAt)
}
// ProcessIdentity handles identity change events (handle updates)
// This is called when Jetstream receives an identity event indicating a handle change.
// The identity cache is invalidated to ensure the next lookup uses the new handle,
// and the database is updated to reflect the change in the UI.
//
// Only processes events for users who already exist in our database (have ATCR activity).
func (p *Processor) ProcessIdentity(ctx context.Context, did string, newHandle string) error {
// Check if user exists in our database - only update if they're an ATCR user
user, err := db.GetUserByDID(p.db, did)
if err != nil {
return fmt.Errorf("failed to check user existence: %w", err)
}
// Skip if user doesn't exist - they don't have any ATCR activity (manifests, profiles, etc.)
if user == nil {
return nil
}
// Update handle in database. An empty handle means the identity event carried
// an invalidated/unresolvable handle (expired, DNS/_atproto TXT removed, PDS down
// during resolution). Don't overwrite the cached handle in that case, and don't
// blank it to "" (which trips UNIQUE(handle) the moment a second user does the same).
// Fall through to invalidate the cache so the next lookup re-resolves.
if newHandle != "" {
if err := db.UpdateUserHandle(p.db, did, newHandle); err != nil {
slog.Warn("Failed to update user handle in database",
"component", "processor",
"did", did,
"handle", newHandle,
"error", err)
// Continue to invalidate cache even if DB update fails
}
}
// Invalidate cached identity data to force re-resolution on next lookup
if err := atproto.InvalidateIdentity(ctx, did); err != nil {
slog.Warn("Failed to invalidate identity cache",
"component", "processor",
"did", did,
"error", err)
return err
}
slog.Info("Processed identity change event",
"component", "processor",
"did", did,
"old_handle", user.Handle,
"new_handle", newHandle)
return nil
}
// ProcessProfileUpdate handles app.bsky.actor.profile updates for known ATCR users
// This refreshes the cached avatar URL when a user changes their Bluesky profile picture
func (p *Processor) ProcessProfileUpdate(ctx context.Context, did string, recordData []byte) error {
// Check if user exists in our database - only update if they're an ATCR user
user, err := db.GetUserByDID(p.db, did)
if err != nil {
return fmt.Errorf("failed to check user existence: %w", err)
}
// Skip if user doesn't exist - they don't have any ATCR activity
if user == nil {
return nil
}
// Parse the profile record to extract avatar
var profile struct {
Avatar *atproto.ATProtoBlobRef `json:"avatar"`
}
if err := json.Unmarshal(recordData, &profile); err != nil {
return fmt.Errorf("failed to unmarshal profile: %w", err)
}
// Build new avatar URL
avatarURL := ""
if profile.Avatar != nil && profile.Avatar.Ref.Link != "" {
avatarURL = atproto.BlobCDNURL(did, profile.Avatar.Ref.Link)
}
// Update if changed
if avatarURL != user.Avatar {
slog.Info("Updating avatar from profile change",
"component", "processor",
"did", did,
"old_avatar", user.Avatar,
"new_avatar", avatarURL)
return db.UpdateUserAvatar(p.db, did, avatarURL)
}
return nil
}
// RefreshUserAvatar fetches the user's current Bluesky profile and updates their cached avatar
// This is called during backfill to ensure avatars stay fresh for existing users
func (p *Processor) RefreshUserAvatar(ctx context.Context, did, pdsEndpoint string) error {
// Get user from database to compare avatar
user, err := db.GetUserByDID(p.db, did)
if err != nil || user == nil {
return nil // User doesn't exist, skip
}
// Fetch profile from PDS
client := atproto.NewClient(pdsEndpoint, "", "")
profile, err := client.GetProfileRecord(ctx, did)
if err != nil {
return fmt.Errorf("failed to fetch profile: %w", err)
}
// Build avatar URL
avatarURL := ""
if profile.Avatar != nil && profile.Avatar.Ref.Link != "" {
avatarURL = atproto.BlobCDNURL(did, profile.Avatar.Ref.Link)
}
// Update if changed
if avatarURL != user.Avatar {
slog.Info("Backfill refreshing avatar",
"component", "processor",
"did", did,
"old_avatar", user.Avatar,
"new_avatar", avatarURL)
return db.UpdateUserAvatar(p.db, did, avatarURL)
}
return nil
}
// ProcessScan handles scan record events from hold PDSes.
// Caches scan results in the appview DB and dispatches webhooks (if dispatcher is set).
func (p *Processor) ProcessScan(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error {
if isDelete {
return nil // Scan deletes are not processed (scans are immutable)
}
// Unmarshal scan record
var scanRecord atproto.ScanRecord
if err := json.Unmarshal(recordData, &scanRecord); err != nil {
return fmt.Errorf("failed to unmarshal scan record: %w", err)
}
// Extract manifest digest from the scan record's manifest AT-URI
manifestDigest := ""
if parts := strings.Split(scanRecord.Manifest, "/"); len(parts) > 0 {
manifestDigest = "sha256:" + parts[len(parts)-1]
}
// Parse scanned_at timestamp
scannedAt := time.Now()
if t, err := time.Parse(time.RFC3339, scanRecord.ScannedAt); err == nil {
scannedAt = t
}
scan := &db.Scan{
HoldDID: holdDID,
ManifestDigest: manifestDigest,
UserDID: scanRecord.UserDID,
Repository: scanRecord.Repository,
Critical: int(scanRecord.Critical),
High: int(scanRecord.High),
Medium: int(scanRecord.Medium),
Low: int(scanRecord.Low),
Total: int(scanRecord.Total),
ScannerVersion: scanRecord.ScannerVersion,
ScannedAt: scannedAt,
}
// Upsert scan to DB (returns previous scan for change detection)
previousScan, err := db.UpsertScan(p.db, scan)
if err != nil {
return fmt.Errorf("failed to upsert scan: %w", err)
}
// Dispatch webhooks if dispatcher is set (live Worker only, not backfill)
if p.webhookDispatcher != nil {
// Resolve user handle from cache or DB
userHandle := ""
user, userErr := db.GetUserByDID(p.db, scanRecord.UserDID)
if userErr == nil && user != nil {
userHandle = user.Handle
}
// Resolve tag for the manifest digest
tag := ""
if tagVal, tagErr := db.GetTagByDigest(p.db, scanRecord.UserDID, scanRecord.Repository, manifestDigest); tagErr == nil {
tag = tagVal
}
// Resolve hold endpoint URL
holdEndpoint := ""
if holdURL, holdErr := atproto.ResolveHoldURL(ctx, holdDID); holdErr == nil {
holdEndpoint = holdURL
}
p.webhookDispatcher.DispatchForScan(ctx, scan, previousScan, userHandle, tag, holdEndpoint)
}
return nil
}
// ProcessStats handles stats record events from hold PDSes
// This is called when Jetstream receives a stats create/update/delete event from a hold
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownerDID + repository
func (p *Processor) ProcessStats(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error {
// Skip if no stats cache configured
if p.statsCache == nil {
return nil
}
// Unmarshal stats record
var statsRecord atproto.StatsRecord
if err := json.Unmarshal(recordData, &statsRecord); err != nil {
return fmt.Errorf("failed to unmarshal stats record: %w", err)
}
// Ensure the owner user exists in the users table (repository_stats has FK to users.did)
if !isDelete && statsRecord.OwnerDID != "" {
if err := p.EnsureUserExists(ctx, statsRecord.OwnerDID); err != nil {
return fmt.Errorf("failed to ensure stats owner user exists: %w", err)
}
}
if isDelete {
// Delete from in-memory cache
p.statsCache.Delete(holdDID, statsRecord.OwnerDID, statsRecord.Repository)
} else {
// Parse timestamps
var lastPull, lastPush *time.Time
if statsRecord.LastPull != "" {
t, err := time.Parse(time.RFC3339, statsRecord.LastPull)
if err == nil {
lastPull = &t
}
}
if statsRecord.LastPush != "" {
t, err := time.Parse(time.RFC3339, statsRecord.LastPush)
if err == nil {
lastPush = &t
}
}
// Update in-memory cache
p.statsCache.Update(holdDID, statsRecord.OwnerDID, statsRecord.Repository,
statsRecord.PullCount, statsRecord.PushCount, lastPull, lastPush)
}
// Get aggregated stats across all holds
totalPull, totalPush, latestPull, latestPush := p.statsCache.GetAggregated(
statsRecord.OwnerDID, statsRecord.Repository)
// Upsert aggregated stats to repository_stats
return db.UpsertRepositoryStats(p.db, &db.RepositoryStats{
DID: statsRecord.OwnerDID,
Repository: statsRecord.Repository,
PullCount: int(totalPull),
PushCount: int(totalPush),
LastPull: latestPull,
LastPush: latestPush,
})
}
// ProcessDailyStats handles daily stats record events from hold PDSes
// This is called when Jetstream receives a daily stats create/update/delete event from a hold
func (p *Processor) ProcessDailyStats(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error {
var record atproto.DailyStatsRecord
if err := json.Unmarshal(recordData, &record); err != nil {
return fmt.Errorf("failed to unmarshal daily stats record: %w", err)
}
if isDelete {
// Daily stats deletions are rare — just log and skip
slog.Debug("Daily stats record deleted",
"holdDID", holdDID,
"ownerDID", record.OwnerDID,
"repository", record.Repository,
"date", record.Date)
return nil
}
// Ensure the owner user exists (FK constraint)
if record.OwnerDID != "" {
if err := p.EnsureUserExists(ctx, record.OwnerDID); err != nil {
return fmt.Errorf("failed to ensure daily stats owner user exists: %w", err)
}
}
return db.UpsertDailyStats(p.db, &db.DailyStats{
DID: record.OwnerDID,
Repository: record.Repository,
Date: record.Date,
PullCount: int(record.PullCount),
PushCount: int(record.PushCount),
})
}
// ProcessCaptain handles captain record events from hold PDSes
// This is called when Jetstream receives a captain create/update/delete event from a hold
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownership info
func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordData []byte) error {
// Unmarshal captain record
var captainRecord atproto.CaptainRecord
if err := json.Unmarshal(recordData, &captainRecord); err != nil {
return fmt.Errorf("failed to unmarshal captain record: %w", err)
}
// Verify the publishing DID actually runs a hold before caching: any
// account can publish a captain record, but only real holds advertise the
// atcr_hold service in their DID document. Unverified records would
// otherwise surface in every user's hold picker (allowAllCrew=true) or,
// via forged crew records, in targeted users' member-hold lists.
// Skip rather than fail — unresolvable DIDs are retried by periodic backfill.
isHold, err := atproto.HasHoldService(ctx, holdDID)
if err != nil {
slog.Warn("Skipping captain record, hold DID unresolvable",
"component", "processor", "hold_did", holdDID, "error", err)
return nil
}
if !isHold {
slog.Info("Skipping captain record from non-hold DID",
"component", "processor", "hold_did", holdDID)
return nil
}
// Convert to db struct and upsert
record := &db.HoldCaptainRecord{
HoldDID: holdDID,
OwnerDID: captainRecord.Owner,
Public: captainRecord.Public,
AllowAllCrew: captainRecord.AllowAllCrew,
DeployedAt: captainRecord.DeployedAt,
Region: captainRecord.Region,
Successor: captainRecord.Successor,
UpdatedAt: time.Now(),
}
if err := db.UpsertCaptainRecord(p.db, record); err != nil {
return fmt.Errorf("failed to upsert captain record: %w", err)
}
slog.Info("Processed captain record",
"component", "processor",
"hold_did", holdDID,
"owner_did", captainRecord.Owner,
"public", captainRecord.Public,
"allow_all_crew", captainRecord.AllowAllCrew)
return nil
}
// ProcessCrew handles crew record events from hold PDSes
// This is called when Jetstream receives a crew create/update/delete event from a hold
// The holdDID is the DID of the hold PDS (event.DID), and the record contains member info
func (p *Processor) ProcessCrew(ctx context.Context, holdDID string, rkey string, recordData []byte) error {
// Unmarshal crew record
var crewRecord atproto.CrewRecord
if err := json.Unmarshal(recordData, &crewRecord); err != nil {
return fmt.Errorf("failed to unmarshal crew record: %w", err)
}
// Marshal permissions to JSON string
permissionsJSON := ""
if len(crewRecord.Permissions) > 0 {
if jsonBytes, err := json.Marshal(crewRecord.Permissions); err == nil {
permissionsJSON = string(jsonBytes)
}
}
// Convert to db struct and upsert
member := &db.CrewMember{
HoldDID: holdDID,
MemberDID: crewRecord.Member,
Rkey: rkey,
Role: crewRecord.Role,
Permissions: permissionsJSON,
Tier: crewRecord.Tier,
AddedAt: crewRecord.AddedAt,
}
if err := db.UpsertCrewMember(p.db, member); err != nil {
return fmt.Errorf("failed to upsert crew member: %w", err)
}
return nil
}
// ProcessAccount handles account status events (deactivation/deletion/etc)
// This is called when Jetstream receives an account event indicating status changes.
//
// Status handling:
// - "deleted": Account permanently deleted - remove all cached data
// - "deactivated": Could be PDS migration or permanent - invalidate cache only
// - "takendown": Moderation action - invalidate cache only
// - Other: Ignore
//
// For "deactivated", we don't delete data because it's ambiguous:
// - Could be permanent deactivation (user deleted account)
// - Could be PDS migration (account moves to new PDS)
// Cache invalidation forces re-resolution on next lookup.
//
// Only processes events for users who already exist in our database (have ATCR activity).
func (p *Processor) ProcessAccount(ctx context.Context, did string, active bool, status string) error {
// Skip active accounts or unknown statuses
if active {
return nil
}
// Check if user exists in our database - only process if they're an ATCR user
user, err := db.GetUserByDID(p.db, did)
if err != nil {
return fmt.Errorf("failed to check user existence: %w", err)
}
// Skip if user doesn't exist - they don't have any ATCR activity
if user == nil {
return nil
}
switch status {
case "deleted":
// Account permanently deleted - remove all cached data
if _, err := db.DeleteUserData(p.db, did); err != nil {
slog.Error("Failed to delete user data for deleted account",
"component", "processor",
"did", did,
"handle", user.Handle,
"error", err)
return err
}
// Also invalidate identity cache
_ = atproto.InvalidateIdentity(ctx, did)
slog.Info("Deleted user data for deleted account",
"component", "processor",
"did", did,
"handle", user.Handle)
case "deactivated", "takendown":
// Ambiguous status - invalidate cache but keep data
// For deactivated: could be PDS migration, will resolve on next lookup
// For takendown: moderation action, keep data in case of appeal
if err := atproto.InvalidateIdentity(ctx, did); err != nil {
slog.Warn("Failed to invalidate identity cache",
"component", "processor",
"did", did,
"status", status,
"error", err)
return err
}
slog.Info("Processed account status event - cache invalidated",
"component", "processor",
"did", did,
"handle", user.Handle,
"status", status)
default:
// Unknown status - ignore
slog.Debug("Ignoring unknown account status",
"component", "processor",
"did", did,
"status", status)
}
return nil
}
// extractRepoFromRecord extracts the repository field from a record's JSON data
// for user-owned collections subject to labeler takedowns. Hold-owned collections
// (io.atcr.hold.*) are intentionally excluded — the labeler doesn't moderate holds.
func extractRepoFromRecord(collection string, data []byte) string {
switch collection {
case atproto.ManifestCollection,
atproto.TagCollection,
atproto.RepoPageCollection:
var rec struct {
Repository string `json:"repository"`
}
if err := json.Unmarshal(data, &rec); err == nil {
return rec.Repository
}
}
return ""
}