diff --git a/app/components/Header/AccountMenu.client.vue b/app/components/Header/AccountMenu.client.vue index 10828b993..5a913d762 100644 --- a/app/components/Header/AccountMenu.client.vue +++ b/app/components/Header/AccountMenu.client.vue @@ -87,8 +87,17 @@ function openAuthModal() { + @@ -181,7 +190,18 @@ function openAuthModal() { class="w-full px-3 py-2.5 flex items-center gap-3 hover:bg-bg-subtle transition-colors text-start" @click="openAuthModal" > - + +
diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts index e19fe837a..39721941d 100644 --- a/server/api/auth/atproto.get.ts +++ b/server/api/auth/atproto.get.ts @@ -7,6 +7,15 @@ import { SLINGSHOT_HOST } from '#shared/utils/constants' import { useServerSession } from '#server/utils/server-session' import type { PublicUserSession } from '#shared/schemas/publicUserSession' +interface ProfileRecord { + avatar?: { + $type: 'blob' + ref: { $link: string } + mimeType: string + size: number + } +} + export default defineEventHandler(async event => { const config = useRuntimeConfig(event) if (!config.sessionPassword) { @@ -58,8 +67,36 @@ export default defineEventHandler(async event => { ) if (response.ok) { const miniDoc: PublicUserSession = await response.json() + + // Fetch the user's profile record to get their avatar blob reference + let avatar: string | undefined + const did = agent.did + try { + const pdsUrl = new URL(miniDoc.pds) + // Only fetch from HTTPS PDS endpoints to prevent SSRF + if (did && pdsUrl.protocol === 'https:') { + const profileResponse = await fetch( + `${pdsUrl.origin}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, + { headers: { 'User-Agent': 'npmx' } }, + ) + if (profileResponse.ok) { + const record = (await profileResponse.json()) as { value: ProfileRecord } + const avatarBlob = record.value.avatar + if (avatarBlob?.ref?.$link) { + // Use Bluesky CDN for faster image loading + avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${avatarBlob.ref.$link}@jpeg` + } + } + } + } catch { + // Avatar fetch failed, continue without it + } + await session.update({ - public: miniDoc, + public: { + ...miniDoc, + avatar, + }, }) } diff --git a/shared/schemas/publicUserSession.ts b/shared/schemas/publicUserSession.ts index 2ead3080f..1c9e15a92 100644 --- a/shared/schemas/publicUserSession.ts +++ b/shared/schemas/publicUserSession.ts @@ -1,4 +1,4 @@ -import { object, string, pipe, url } from 'valibot' +import { object, string, pipe, url, optional } from 'valibot' import type { InferOutput } from 'valibot' export const PublicUserSessionSchema = object({ @@ -6,6 +6,7 @@ export const PublicUserSessionSchema = object({ did: string(), handle: string(), pds: pipe(string(), url()), + avatar: optional(pipe(string(), url())), }) export type PublicUserSession = InferOutput diff --git a/shared/types/userSession.ts b/shared/types/userSession.ts index 081c1b296..dea7decd3 100644 --- a/shared/types/userSession.ts +++ b/shared/types/userSession.ts @@ -5,6 +5,7 @@ export interface UserServerSession { did: string handle: string pds: string + avatar?: string } // Only to be used in the atproto session and state stores // Will need to change to Record and add a current logged in user if we ever want to support