+8
-15
cmd/appview/serve.go
+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
+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
+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