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

fix up ser creation logic when user doesn't have a bluesky profile record

evan.jarrett.net 6bc929f2 60249535

verified
Changed files
+52 -57
cmd
appview
pkg
appview
db
jetstream
+8 -15
cmd/appview/serve.go
··· 253 253 profileRecord, err := client.GetProfileRecord(ctx, did) 254 254 if err != nil { 255 255 slog.Warn("Failed to fetch profile record", "component", "appview/callback", "did", did, "error", err) 256 - // Still update user without avatar 257 - _ = db.UpsertUser(uiDatabase, &db.User{ 258 - DID: did, 259 - Handle: handle, 260 - PDSEndpoint: pdsEndpoint, 261 - Avatar: "", 262 - LastSeen: time.Now(), 263 - }) 264 - return nil // Non-fatal 256 + // Continue without avatar - set profileRecord to nil to skip avatar extraction 257 + profileRecord = nil 265 258 } 266 259 267 - // Construct avatar URL from blob CID using imgs.blue CDN 268 - var avatarURL string 269 - if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 260 + // Construct avatar URL from blob CID using imgs.blue CDN (if profile record was fetched successfully) 261 + avatarURL := "" 262 + if profileRecord != nil && profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 270 263 avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link) 271 264 slog.Debug("Constructed avatar URL", "component", "appview/callback", "avatar_url", avatarURL) 272 265 } 273 266 274 - // Store user with avatar in database 275 - err = db.UpsertUser(uiDatabase, &db.User{ 267 + // Store user in database (with or without avatar) 268 + err = db.UpsertUserIgnoreAvatar(uiDatabase, &db.User{ 276 269 DID: did, 277 270 Handle: handle, 278 271 PDSEndpoint: pdsEndpoint, ··· 284 277 return nil // Non-fatal 285 278 } 286 279 287 - slog.Debug("Stored user with avatar", "component", "appview/callback", "did", did) 280 + slog.Debug("Stored user", "component", "appview/callback", "did", did, "has_avatar", avatarURL != "") 288 281 289 282 // Migrate profile URL→DID if needed 290 283 profile, err := storage.GetProfile(ctx, client)
+23
pkg/appview/db/queries.go
··· 351 351 return err 352 352 } 353 353 354 + // UpsertUserIgnoreAvatar inserts or updates a user record, but preserves existing avatar on update 355 + // This is useful when avatar fetch fails, and we don't want to overwrite an existing avatar with empty string 356 + func UpsertUserIgnoreAvatar(db *sql.DB, user *User) error { 357 + _, err := db.Exec(` 358 + INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) 359 + VALUES (?, ?, ?, ?, ?) 360 + ON CONFLICT(did) DO UPDATE SET 361 + handle = excluded.handle, 362 + pds_endpoint = excluded.pds_endpoint, 363 + last_seen = excluded.last_seen 364 + `, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen) 365 + return err 366 + } 367 + 368 + // UpdateUserLastSeen updates only the last_seen timestamp for a user 369 + // This is more efficient than UpsertUser when only updating activity timestamp 370 + func UpdateUserLastSeen(db *sql.DB, did string) error { 371 + _, err := db.Exec(` 372 + UPDATE users SET last_seen = ? WHERE did = ? 373 + `, time.Now(), did) 374 + return err 375 + } 376 + 354 377 // GetManifestDigestsForDID returns all manifest digests for a DID 355 378 func GetManifestDigestsForDID(db *sql.DB, did string) ([]string, error) { 356 379 rows, err := db.Query(`
+21 -42
pkg/appview/jetstream/processor.go
··· 48 48 func (p *Processor) EnsureUser(ctx context.Context, did string) error { 49 49 // Check cache first (if enabled) 50 50 if p.useCache && p.userCache != nil { 51 - if user, ok := p.userCache.cache[did]; ok { 52 - // Update last seen 53 - user.LastSeen = time.Now() 54 - return db.UpsertUser(p.db, user) 51 + if _, ok := p.userCache.cache[did]; ok { 52 + // User in cache - just update last seen timestamp 53 + return db.UpdateUserLastSeen(p.db, did) 55 54 } 56 55 } else if !p.useCache { 57 56 // No cache - check if user already exists in DB 58 57 existingUser, err := db.GetUserByDID(p.db, did) 59 58 if err == nil && existingUser != nil { 60 - // Update last seen 61 - existingUser.LastSeen = time.Now() 62 - return db.UpsertUser(p.db, existingUser) 59 + // User exists - just update last seen timestamp 60 + return db.UpdateUserLastSeen(p.db, did) 63 61 } 64 62 } 65 63 66 64 // Resolve DID to get handle and PDS endpoint 67 65 didParsed, err := syntax.ParseDID(did) 68 66 if err != nil { 69 - // Fallback: use DID as handle 70 - user := &db.User{ 71 - DID: did, 72 - Handle: did, 73 - PDSEndpoint: "https://bsky.social", 74 - LastSeen: time.Now(), 75 - } 76 - if p.useCache { 77 - p.userCache.cache[did] = user 78 - } 79 - return db.UpsertUser(p.db, user) 67 + return fmt.Errorf("failed to parse DID: %w", err) 80 68 } 81 69 82 70 ident, err := p.directory.LookupDID(ctx, didParsed) 83 71 if err != nil { 84 - // Fallback: use DID as handle 85 - user := &db.User{ 86 - DID: did, 87 - Handle: did, 88 - PDSEndpoint: "https://bsky.social", 89 - LastSeen: time.Now(), 90 - } 91 - if p.useCache { 92 - p.userCache.cache[did] = user 93 - } 94 - return db.UpsertUser(p.db, user) 72 + return fmt.Errorf("failed to lookup DID: %w", err) 95 73 } 96 74 97 75 resolvedDID := ident.DID.String() 98 76 handle := ident.Handle.String() 99 77 pdsEndpoint := ident.PDSEndpoint() 100 78 101 - // If handle is invalid or PDS is missing, use defaults 79 + // If handle is invalid, use DID as display name 102 80 if handle == "handle.invalid" || handle == "" { 103 81 handle = resolvedDID 104 82 } 83 + 84 + // PDS endpoint is required - we can't make XRPC calls without it 105 85 if pdsEndpoint == "" { 106 - pdsEndpoint = "https://bsky.social" 86 + return fmt.Errorf("no PDS endpoint found for DID: %s", resolvedDID) 107 87 } 108 88 109 - // Fetch user's Bluesky profile (including avatar) 110 - // Use public Bluesky AppView API (doesn't require auth for public profiles) 111 - avatar := "" 112 - publicClient := atproto.NewClient("https://public.api.bsky.app", "", "") 113 - profile, err := publicClient.GetActorProfile(ctx, resolvedDID) 89 + // Fetch user's Bluesky profile record from their PDS (including avatar) 90 + avatarURL := "" 91 + client := atproto.NewClient(pdsEndpoint, "", "") 92 + profileRecord, err := client.GetProfileRecord(ctx, resolvedDID) 114 93 if err != nil { 115 - slog.Warn("Failed to fetch profile", "component", "processor", "did", resolvedDID, "error", err) 94 + slog.Warn("Failed to fetch profile record", "component", "processor", "did", resolvedDID, "error", err) 116 95 // Continue without avatar 117 - } else { 118 - avatar = profile.Avatar 96 + } else if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 97 + avatarURL = atproto.BlobCDNURL(resolvedDID, profileRecord.Avatar.Ref.Link) 119 98 } 120 99 121 100 // Create user record ··· 123 102 DID: resolvedDID, 124 103 Handle: handle, 125 104 PDSEndpoint: pdsEndpoint, 126 - Avatar: avatar, 105 + Avatar: avatarURL, 127 106 LastSeen: time.Now(), 128 107 } 129 108 ··· 132 111 p.userCache.cache[did] = user 133 112 } 134 113 135 - // Upsert to database 136 - return db.UpsertUser(p.db, user) 114 + // Upsert to database - preserve existing avatar if fetch failed 115 + return db.UpsertUserIgnoreAvatar(p.db, user) 137 116 } 138 117 139 118 // ProcessManifest processes a manifest record and stores it in the database