A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(api): resolve profiles via bsky AppView with PDS fallback

Self-hosted PDSes (e.g. caramelo.social.br on Locaweb BR) silently drop
traffic from our Contabo VPS IP, so com.atproto.repo.getRecord against
the origin PDS always timed out in prod — corrupting avatar/displayName
for those users.

Add lib/bskyProfile.fetchBskyProfile: read avatar + displayName from the
Bluesky public AppView (public.api.bsky.app, globally reachable, returns
a ready-made avatar CDN URL) and only fall back to a direct PDS read when
the AppView lookup fails. Wire it into getProfile, GET /profile and
GET /users/:did, and stop hand-building CID-less avatar URLs.

+115 -84
+18 -19
apps/api/src/bsky/app.ts
··· 1 1 import { AtpAgent } from "@atproto/api"; 2 - import { consola } from "consola"; 3 - import type { BlobRef } from "@atproto/lexicon"; 4 2 import { isValidHandle } from "@atproto/syntax"; 3 + import { SCOPES } from "auth/client"; 4 + import { consola } from "consola"; 5 5 import { ctx } from "context"; 6 6 import { and, desc, eq } from "drizzle-orm"; 7 7 import { Hono } from "hono"; 8 8 import jwt from "jsonwebtoken"; 9 - import * as Profile from "lexicon/types/app/bsky/actor/profile"; 10 9 import { deepSnakeCaseKeys } from "lib"; 11 10 import { createAgent } from "lib/agent"; 11 + import { fetchBskyProfile } from "lib/bskyProfile"; 12 12 import { env } from "lib/env"; 13 13 import extractPdsFromDid from "lib/extractPdsFromDid"; 14 14 import { verifyToken } from "lib/verifyToken"; ··· 18 18 import spotifyAccounts from "schema/spotify-accounts"; 19 19 import spotifyTokens from "schema/spotify-tokens"; 20 20 import users from "schema/users"; 21 - import { SCOPES } from "auth/client"; 22 21 23 22 const app = new Hono(); 24 23 ··· 187 186 return c.text("Unauthorized"); 188 187 } 189 188 190 - const { data: profileRecord } = await agent.com.atproto.repo.getRecord({ 191 - repo: agent.assertDid, 192 - collection: "app.bsky.actor.profile", 193 - rkey: "self", 194 - }); 195 189 const handle = await ctx.resolver.resolveDidToHandle(did); 196 - const profile: { handle?: string; displayName?: string; avatar?: BlobRef } = 197 - Profile.isRecord(profileRecord.value) 198 - ? { ...profileRecord.value, handle } 199 - : {}; 190 + const resolved = await fetchBskyProfile(did, agent); 200 191 201 - if (profile.handle) { 192 + if (handle) { 202 193 try { 203 194 await ctx.db 204 195 .insert(users) 205 196 .values({ 206 197 did, 207 198 handle, 208 - displayName: profile.displayName, 209 - avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`, 199 + displayName: resolved.displayName ?? null, 200 + avatar: resolved.avatar ?? "", 210 201 }) 211 202 .execute(); 212 203 } catch (e) { ··· 217 208 .update(users) 218 209 .set({ 219 210 handle, 220 - displayName: profile.displayName, 221 - avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`, 211 + // Only overwrite avatar/displayName when the lookup actually 212 + // returned them, so a failed/partial fetch never clobbers good data. 213 + ...(resolved.displayName !== undefined 214 + ? { displayName: resolved.displayName } 215 + : {}), 216 + ...(resolved.avatar !== undefined 217 + ? { avatar: resolved.avatar } 218 + : {}), 222 219 }) 223 220 .where(eq(users.did, did)) 224 221 .execute(); ··· 286 283 ]).then(([s, t, g, d]) => deepSnakeCaseKeys([s[0], t[0], g[0], d[0]])); 287 284 288 285 return c.json({ 289 - ...profile, 286 + handle, 287 + displayName: resolved.displayName, 288 + avatar: resolved.avatar, 290 289 spotifyUser, 291 290 spotifyConnected: !!spotifyToken, 292 291 googledrive,
+78
apps/api/src/lib/bskyProfile.ts
··· 1 + import { type Agent, AtpAgent } from "@atproto/api"; 2 + import { consola } from "consola"; 3 + import _ from "lodash"; 4 + 5 + // Resolved public profile fields. A field is `undefined` when neither the 6 + // AppView nor the PDS provided it, so callers can preserve existing good data 7 + // instead of overwriting it with an empty value. 8 + export type ResolvedBskyProfile = { 9 + avatar?: string; 10 + displayName?: string; 11 + }; 12 + 13 + // Shared, unauthenticated client for the Bluesky public AppView. The AppView is 14 + // globally reachable and purpose-built for public reads, so it never geo/IP 15 + // blocks our egress the way a self-hosted PDS can (see caramelo.social.br 16 + // dropping traffic from our Contabo VPS). 17 + const appViewAgent = new AtpAgent({ service: "https://public.api.bsky.app" }); 18 + 19 + /** 20 + * Resolve a user's avatar + displayName, preferring the Bluesky public AppView 21 + * (returns a ready-made avatar CDN URL and never blocks our egress). Falls back 22 + * to reading the profile record straight from the user's PDS via `agent` only 23 + * when the AppView lookup fails entirely. 24 + */ 25 + export async function fetchBskyProfile( 26 + did: string | undefined, 27 + agent?: Agent | AtpAgent, 28 + ): Promise<ResolvedBskyProfile> { 29 + if (!did) { 30 + return {}; 31 + } 32 + 33 + // Primary: public AppView. 34 + try { 35 + const { data } = await appViewAgent.app.bsky.actor.getProfile({ 36 + actor: did, 37 + }); 38 + return { 39 + avatar: data.avatar || undefined, 40 + displayName: data.displayName || undefined, 41 + }; 42 + } catch (error) { 43 + consola.warn( 44 + `AppView getProfile failed for ${did}; falling back to PDS:`, 45 + error, 46 + ); 47 + } 48 + 49 + // Fallback: read the profile record directly from the user's PDS. This only 50 + // succeeds when our egress can actually reach the PDS. 51 + try { 52 + if (agent) { 53 + const { data } = await agent.com.atproto.repo.getRecord({ 54 + repo: did, 55 + collection: "app.bsky.actor.profile", 56 + rkey: "self", 57 + }); 58 + const ref = _.get(data, "value.avatar.ref") as 59 + | { toString(): string } 60 + | undefined; 61 + const cid = ref ? ref.toString() : ""; 62 + const ext = 63 + (_.get(data, "value.avatar.mimeType", "") as string).split("/")[1] || 64 + "jpeg"; 65 + return { 66 + avatar: cid 67 + ? `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}` 68 + : undefined, 69 + displayName: 70 + (_.get(data, "value.displayName") as string | undefined) || undefined, 71 + }; 72 + } 73 + } catch (error) { 74 + consola.error(`Failed to read profile record from PDS for ${did}:`, error); 75 + } 76 + 77 + return {}; 78 + }
+11 -19
apps/api/src/users/app.ts
··· 1 - import type { BlobRef } from "@atproto/lexicon"; 2 1 import { ctx } from "context"; 3 2 import { 4 3 aliasedTable, ··· 12 11 sql, 13 12 } from "drizzle-orm"; 14 13 import { Hono } from "hono"; 15 - import * as Profile from "lexicon/types/app/bsky/actor/profile"; 16 14 import { createAgent } from "lib/agent"; 15 + import { fetchBskyProfile } from "lib/bskyProfile"; 17 16 import { verifyToken } from "lib/verifyToken"; 18 17 import { likeTrack, unLikeTrack } from "lovedtracks/lovedtracks.service"; 19 18 import { requestCounter } from "metrics"; ··· 551 550 const agent = await createAgent(ctx.oauthClient, claims.did); 552 551 553 552 if (agent) { 554 - const { data: profileRecord } = await agent.com.atproto.repo.getRecord({ 555 - repo: did, 556 - collection: "app.bsky.actor.profile", 557 - rkey: "self", 558 - }); 559 553 const handle = await ctx.resolver.resolveDidToHandle(did); 560 - const profile: { 561 - handle?: string; 562 - displayName?: string; 563 - avatar?: BlobRef; 564 - } = 565 - Profile.isRecord(profileRecord.value) && 566 - Profile.validateRecord(profileRecord.value).success 567 - ? { ...profileRecord.value, handle } 568 - : {}; 554 + const resolved = await fetchBskyProfile(did, agent); 569 555 570 - if (profile.handle) { 556 + if (handle) { 571 557 await ctx.db 572 558 .update(tables.users) 573 559 .set({ 574 560 handle, 575 - displayName: profile.displayName, 576 - avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`, 561 + // Only overwrite avatar/displayName when the lookup actually 562 + // returned them, so a failed/partial fetch never clobbers good data. 563 + ...(resolved.displayName !== undefined 564 + ? { displayName: resolved.displayName } 565 + : {}), 566 + ...(resolved.avatar !== undefined 567 + ? { avatar: resolved.avatar } 568 + : {}), 577 569 }) 578 570 .where(eq(tables.users.did, did)) 579 571 .execute();
+8 -46
apps/api/src/xrpc/app/rocksky/actor/getProfile.ts
··· 1 1 import { type Agent, AtpAgent } from "@atproto/api"; 2 + import type { HandlerAuth } from "@atproto/xrpc-server"; 2 3 import { consola } from "consola"; 3 - import type { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/getRecord"; 4 - import type { HandlerAuth } from "@atproto/xrpc-server"; 5 4 import type { Context } from "context"; 6 5 import { eq } from "drizzle-orm"; 7 6 import { Effect, pipe } from "effect"; ··· 9 8 import type { ProfileViewDetailed } from "lexicon/types/app/rocksky/actor/defs"; 10 9 import type { QueryParams } from "lexicon/types/app/rocksky/actor/getProfile"; 11 10 import { createAgent } from "lib/agent"; 11 + import { fetchBskyProfile, type ResolvedBskyProfile } from "lib/bskyProfile"; 12 12 import _ from "lodash"; 13 13 import * as R from "ramda"; 14 14 import tables from "schema"; ··· 194 194 user, 195 195 }: WithUser): Effect.Effect< 196 196 [ 197 - Profile | {}, 197 + Profile, 198 198 string, 199 199 SelectSpotifyAccount, 200 200 SelectSpotifyToken, ··· 205 205 > => { 206 206 return Effect.tryPromise({ 207 207 try: async () => { 208 - let record = {}; 209 - try { 210 - if (agent) { 211 - const { data } = await agent.com.atproto.repo.getRecord({ 212 - repo: did, 213 - collection: "app.bsky.actor.profile", 214 - rkey: "self", 215 - }); 216 - record = data; 217 - } 218 - } catch (error) { 219 - consola.error("Failed to retrieve profile record:", error); 220 - } 208 + const resolved = await fetchBskyProfile(did, agent); 221 209 return Promise.all([ 222 210 Promise.resolve({ 223 - profileRecord: record, 211 + resolved, 224 212 ctx, 225 213 did, 226 214 user, ··· 272 260 }); 273 261 }; 274 262 275 - // Derive avatar URL + displayName from the fetched bsky profile record. 276 - // Returns `undefined` for a field when the record didn't provide it (e.g. the 277 - // profile fetch in `retrieveProfile` failed and left `profileRecord` empty), so 278 - // callers can preserve existing good data instead of clobbering it. 279 - const resolveProfileFields = ( 280 - profile: Profile, 281 - ): { avatar?: string; displayName?: string } => { 282 - const displayName = _.get(profile, "profileRecord.value.displayName") as 283 - | string 284 - | undefined; 285 - const ref = _.get(profile, "profileRecord.value.avatar.ref") as 286 - | { toString(): string } 287 - | undefined; 288 - const cid = ref ? ref.toString() : ""; 289 - const ext = 290 - _.get(profile, "profileRecord.value.avatar.mimeType", "").split("/")[1] || 291 - "jpeg"; 292 - return { 293 - avatar: cid 294 - ? `https://cdn.bsky.app/img/avatar/plain/${profile.did}/${cid}@${ext}` 295 - : undefined, 296 - displayName: displayName || undefined, 297 - }; 298 - }; 299 - 300 263 const refreshProfile = ([ 301 264 profile, 302 265 handle, ··· 314 277 ]) => { 315 278 return Effect.tryPromise({ 316 279 try: async () => { 317 - const fetched = resolveProfileFields(profile); 280 + const fetched = profile.resolved; 318 281 319 282 if (!profile.user) { 320 283 // Brand new user: nothing to preserve. `avatar` is NOT NULL, so fall ··· 355 318 // actually provided them. A failed or partial profile fetch must never 356 319 // overwrite good data with an empty name or a CID-less avatar URL. 357 320 const nextAvatar = fetched.avatar ?? profile.user.avatar; 358 - const nextDisplayName = 359 - fetched.displayName ?? profile.user.displayName; 321 + const nextDisplayName = fetched.displayName ?? profile.user.displayName; 360 322 361 323 if ( 362 324 profile.user.handle !== handle || ··· 458 420 }; 459 421 460 422 type Profile = { 461 - profileRecord: OutputSchema; 423 + resolved: ResolvedBskyProfile; 462 424 ctx: Context; 463 425 did: string; 464 426 user?: SelectUser;