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.

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

+52 -57
+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