mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
* feat: avatar upload UI, self-service profiles, server-side avatar proxy * refactor(ui): make resolveAvatar synchronous so avatar updates render immediately resolveAvatar no longer does async work (gravatar moved server-side), so the until()-deferred render left the identity chip img stale after updateSelfUser. Rendering synchronously reuses the img element and reflects avatarUrl changes on the next render. * fix(gateway): bound avatar Gravatar lookup to one timeout budget Linked-email Gravatar lookups ran sequentially, each with its own 5s timeout, so an upstream outage stalled the held connection for 5s x linked-email-count. Resolve them concurrently and pick the first hit in email order, keeping primary-email precedence while capping the request to a single timeout budget. * fix(gateway): cap per-profile Gravatar fan-out to bound sockets and memory A profile with many linked emails would fan out one concurrent fetch per email with no bound, each able to buffer up to MAX_GRAVATAR_BYTES before the cache budget applies. Cap the lookups to the first 8 primary-ordered emails so a single avatar request holds a bounded number of sockets and transient bytes. * test: reconcile avatar tests with server-side gravatar model after merge main landed a client-side Gravatar path (browser computes the gravatar.com URL); this branch resolves avatars through the same-origin gateway route, which is the only approach that works under the Control UI CSP (img-src 'self'). Update the identity-section, app-sidebar footer chip, and profile-page e2e expectations to assert the canonical /api/users/<id>/avatar route (gateway serves the Gravatar fallback behind it) instead of a CSP-blocked direct gravatar.com image. * test(gateway): fix fetchImpl mock param type for check-test-types The Gravatar mock typed its param as (url: string), narrower than the fetchImpl signature (URL | RequestInfo); function-param contravariance made it unassignable under tsgo:test. Widen to the fetch input type and extract the URL via a small helper that avoids no-base-to-string on a Request. * fix(gateway): short-circuit Gravatar lookups and version presence avatar URL Two autoreview findings on the avatar path: - Privacy: the concurrent Promise.all fan-out queried every linked email's hash against Gravatar even when the primary already had one, exposing secondary work/personal addresses. Resolve sequentially with short-circuit so a later hash is disclosed only after the earlier email is a definite miss, under one shared request deadline that still bounds total latency. - Staleness: presence published an unversioned /api/users/<id>/avatar, so a reconnecting viewer's <img> kept the stale cached image after an upload. Carry the profile revision as ?v=<updatedAt> so a changed avatar refetches.
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
import { formatSenderLabel, type SenderIdentity } from "./chat/sender-label.ts";
|
|
|
|
// NOTE: this is sender-controlled metadata. It must never carry the trusted
|
|
// gateway origin — that comes only from the app connection via
|
|
// setAvatarGatewayOrigin().
|
|
export type IdentityAvatarInput = SenderIdentity & {
|
|
profileAvatarUrl?: string;
|
|
};
|
|
|
|
const ORIGIN_PROBE = "https://origin-probe.invalid";
|
|
|
|
let appGatewayOrigin: string | null = null;
|
|
|
|
function toHttpOrigin(url: string | null | undefined): string | null {
|
|
if (!url) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = new URL(url);
|
|
const scheme =
|
|
parsed.protocol === "wss:" ? "https:" : parsed.protocol === "ws:" ? "http:" : parsed.protocol;
|
|
return `${scheme}//${parsed.host}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Records the connected gateway URL so avatar routes resolve to its origin. */
|
|
export function setAvatarGatewayOrigin(gatewayUrl: string | null | undefined): void {
|
|
appGatewayOrigin = toHttpOrigin(gatewayUrl);
|
|
}
|
|
|
|
// Mirrors the server's user-profiles-http-path matcher. Sender metadata may
|
|
// point only at this image route, never another gateway endpoint.
|
|
const USER_AVATAR_PATHNAME = /^\/api\/users\/[^/]+\/avatar$/u;
|
|
|
|
/**
|
|
* Returns a browser-safe avatar URL, or null. Only the canonical
|
|
* /api/users/<id>/avatar route is trusted (pathname pinned, fragment dropped).
|
|
* The query is preserved: the gateway stamps a ?v=<updatedAt> revision there so
|
|
* the browser cache-busts a replaced avatar. Since avatars now render as plain
|
|
* <img> with no attached credentials, a varied query cannot amplify any
|
|
* client cache — the browser bounds it. Relative paths resolve against the
|
|
* trusted gateway origin; absolute URLs must match that origin.
|
|
*/
|
|
function toTrustedAvatarUrl(value: string, gatewayOrigin: string | null): string | null {
|
|
try {
|
|
const parsed = new URL(value, ORIGIN_PROBE);
|
|
if (!USER_AVATAR_PATHNAME.test(parsed.pathname)) {
|
|
return null;
|
|
}
|
|
const suffix = parsed.pathname + parsed.search;
|
|
if (parsed.origin === ORIGIN_PROBE) {
|
|
return gatewayOrigin ? new URL(suffix, gatewayOrigin).toString() : suffix;
|
|
}
|
|
return gatewayOrigin && parsed.origin === gatewayOrigin ? gatewayOrigin + suffix : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type ResolvedIdentityAvatar =
|
|
| { kind: "profile"; url: string }
|
|
| { kind: "gravatar"; url: string }
|
|
| { kind: "initials"; initials: string; colorSeed: number };
|
|
|
|
function initialsFromLabel(label: string): string {
|
|
const words = label.trim().split(/\s+/u).filter(Boolean).slice(0, 2);
|
|
const initials = words.map((word) => Array.from(word)[0] ?? "").join("");
|
|
return initials.toUpperCase() || "?";
|
|
}
|
|
|
|
function stableColorSeed(value: string): number {
|
|
let hash = 0x811c9dc5;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash ^= value.charCodeAt(index);
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
return hash >>> 0;
|
|
}
|
|
|
|
export function resolveAvatarInitials(
|
|
input: IdentityAvatarInput,
|
|
): Extract<ResolvedIdentityAvatar, { kind: "initials" }> {
|
|
const id = input.id?.trim();
|
|
const label = formatSenderLabel(input) ?? "?";
|
|
return {
|
|
kind: "initials",
|
|
initials: initialsFromLabel(label),
|
|
colorSeed: stableColorSeed(id || label),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolves a trusted gateway avatar route, else deterministic initials.
|
|
* Gravatar is served by the gateway inside the profile avatar route itself, so
|
|
* the client never constructs a Gravatar URL — it only ever renders the
|
|
* canonical /api/users/<id>/avatar endpoint or falls back to initials.
|
|
*/
|
|
export function resolveAvatar(input: IdentityAvatarInput): ResolvedIdentityAvatar {
|
|
// Trusted origin comes only from the app connection, never from `input`.
|
|
const gatewayOrigin = appGatewayOrigin;
|
|
|
|
const profileAvatarUrl = input.profileAvatarUrl?.trim();
|
|
if (profileAvatarUrl) {
|
|
const trusted = toTrustedAvatarUrl(profileAvatarUrl, gatewayOrigin);
|
|
if (trusted) {
|
|
return { kind: "profile", url: trusted };
|
|
}
|
|
}
|
|
|
|
return resolveAvatarInitials(input);
|
|
}
|