Skip to content

Commit dccd279

Browse files
committed
feat: enhance avatar retrieval with base64 format support and improved error handling
1 parent 83ea54b commit dccd279

File tree

2 files changed

+59
-24
lines changed

2 files changed

+59
-24
lines changed

packages/node/src/api/routes/profile.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -133,21 +133,26 @@ export function profileRoutes(ctx: RuntimeContext): Router {
133133
});
134134

135135
// ── GET /avatar (public — no auth) ──────────────────────────────────────
136-
router.get('/avatar', async ({ res, url }) => {
136+
// Returns raw binary by default. With ?format=base64, returns JSON { data, mimeType }
137+
// so the API proxy relay (text-only) can transport avatar data safely.
138+
router.get('/avatar', async ({ res, query, url }) => {
137139
try {
138140
const avatar = await ctx.selfProfileStore.loadAvatar();
139141
if (!avatar) {
140142
throw new TelagentError(ErrorCodes.NOT_FOUND, 'No avatar uploaded');
141143
}
144+
if (query.get('format') === 'base64') {
145+
ok(res, { data: avatar.data.toString('base64'), mimeType: avatar.mimeType }, { self: '/api/v1/profile/avatar' });
146+
return;
147+
}
142148
sendBinary(res, 200, avatar.data, avatar.mimeType);
143149
} catch (error) {
144150
handleError(res, error, url.pathname);
145151
}
146152
});
147153

148154
// ── GET /:did/avatar (public — proxies peer avatar via local node) ────────
149-
// Serves the cached avatar first (embedded in profile card via P2P).
150-
// Falls back to fetching from the peer's HTTP endpoint if no local cache.
155+
// 1. Local cache → 2. Direct HTTP → 3. P2P relay → 404
151156
router.get('/:did/avatar', async ({ res, params, url }) => {
152157
try {
153158
const { did } = params;
@@ -159,34 +164,54 @@ export function profileRoutes(ctx: RuntimeContext): Router {
159164
throw new TelagentError(ErrorCodes.NOT_FOUND, `No avatar for did: ${did}`);
160165
}
161166

162-
// 1. Try local cache (avatar embedded in the P2P profile card).
167+
// 1. Try local cache.
163168
const cached = ctx.peerProfileRepository.loadAvatar(did);
164169
if (cached) {
165170
sendBinary(res, 200, cached.data, cached.mimeType);
166171
return;
167172
}
168173

169-
// 2. Fallback: fetch from the peer's HTTP endpoint.
174+
// 2. Try direct HTTP to the peer's node.
170175
const remoteUrl = resolvePeerAvatarUrl(profile.avatarUrl, profile.nodeUrl);
171-
if (!remoteUrl || remoteUrl.startsWith('/')) {
172-
throw new TelagentError(ErrorCodes.NOT_FOUND, `Cannot resolve avatar URL for did: ${did}`);
173-
}
174-
let response: Response;
175-
try {
176-
response = await fetch(remoteUrl, { signal: AbortSignal.timeout(8000) });
177-
} catch {
178-
throw new TelagentError(ErrorCodes.NOT_FOUND, `Remote node unreachable for avatar: ${did}`);
179-
}
180-
if (!response.ok) {
181-
throw new TelagentError(ErrorCodes.NOT_FOUND, `Remote avatar fetch failed: ${response.status}`);
176+
if (remoteUrl && !remoteUrl.startsWith('/')) {
177+
try {
178+
const response = await fetch(remoteUrl, { signal: AbortSignal.timeout(5000) });
179+
if (response.ok) {
180+
const contentType = response.headers.get('content-type') ?? 'image/jpeg';
181+
const data = Buffer.from(await response.arrayBuffer());
182+
try { ctx.peerProfileRepository.saveAvatar(did, data, contentType); } catch { /* best-effort */ }
183+
sendBinary(res, 200, data, contentType);
184+
return;
185+
}
186+
} catch {
187+
// Direct HTTP failed — fall through to P2P relay.
188+
}
182189
}
183-
const contentType = response.headers.get('content-type') ?? 'image/jpeg';
184-
const data = Buffer.from(await response.arrayBuffer());
185190

186-
// Cache for future requests.
187-
try { ctx.peerProfileRepository.saveAvatar(did, data, contentType); } catch { /* best-effort */ }
191+
// 3. P2P relay: request avatar as base64 JSON through ClawNet.
192+
if (ctx.apiProxyService) {
193+
try {
194+
const proxyRes = await ctx.apiProxyService.proxyRequest(
195+
did, 'GET', '/api/v1/profile/avatar?format=base64',
196+
{ accept: 'application/json' },
197+
);
198+
if (proxyRes.status === 200 && proxyRes.body) {
199+
const json = JSON.parse(proxyRes.body) as { data?: { data?: string; mimeType?: string } };
200+
const b64 = json.data?.data;
201+
const mimeType = json.data?.mimeType ?? 'image/jpeg';
202+
if (b64) {
203+
const data = Buffer.from(b64, 'base64');
204+
try { ctx.peerProfileRepository.saveAvatar(did, data, mimeType); } catch { /* best-effort */ }
205+
sendBinary(res, 200, data, mimeType);
206+
return;
207+
}
208+
}
209+
} catch {
210+
// P2P relay also failed — fall through to 404.
211+
}
212+
}
188213

189-
sendBinary(res, 200, data, contentType);
214+
throw new TelagentError(ErrorCodes.NOT_FOUND, `Avatar unavailable for did: ${did}`);
190215
} catch (error) {
191216
handleError(res, error, url.pathname);
192217
}

packages/webapp/src/hooks/use-event-source.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,22 @@ export function useEventSource(
2828

2929
// Build the SSE URL
3030
const buildSseUrl = useCallback((): string | null => {
31-
if (!nodeUrl || status !== "connected") return null
31+
if (!nodeUrl || status !== "connected") {
32+
console.debug("[sse] buildSseUrl → null (nodeUrl=%s, status=%s)", nodeUrl, status)
33+
return null
34+
}
3235

3336
if (connectionMode === "relay" && targetDid && gatewayUrl) {
3437
// DID relay mode: SSE through gateway
35-
return `${gatewayUrl.replace(/\/$/, "")}/relay/${encodeURIComponent(targetDid)}/api/v1/events`
38+
const url = `${gatewayUrl.replace(/\/$/, "")}/relay/${encodeURIComponent(targetDid)}/api/v1/events`
39+
console.debug("[sse] buildSseUrl → %s (relay mode)", url)
40+
return url
3641
}
3742

3843
// Local mode: direct SSE to node
39-
return `${nodeUrl.replace(/\/$/, "")}/api/v1/events`
44+
const url = `${nodeUrl.replace(/\/$/, "")}/api/v1/events`
45+
console.debug("[sse] buildSseUrl → %s (direct mode)", url)
46+
return url
4047
}, [nodeUrl, status, connectionMode, targetDid, gatewayUrl])
4148

4249
useEffect(() => {
@@ -53,9 +60,11 @@ export function useEventSource(
5360
let reconnectTimer: number | undefined
5461

5562
const connect = () => {
63+
console.info("[sse] Connecting to %s", url)
5664
es = new EventSource(url)
5765

5866
es.onopen = () => {
67+
console.info("[sse] Connected ✓")
5968
isConnectedRef.current = true
6069
}
6170

@@ -80,6 +89,7 @@ export function useEventSource(
8089
}
8190

8291
es.onerror = () => {
92+
console.warn("[sse] Connection error, reconnecting in 5s…")
8393
isConnectedRef.current = false
8494
es?.close()
8595
es = null

0 commit comments

Comments
 (0)