mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat: avatar upload UI, self-service profiles, server-side avatar route (#111421)
* 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.
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
- **Discord and Slack native login:** register `/login` in native command menus while keeping pairing-code issuance limited to private chats and the Web UI.
|
- **Discord and Slack native login:** register `/login` in native command menus while keeping pairing-code issuance limited to private chats and the Web UI.
|
||||||
|
- **Control UI user profiles:** let trusted-proxy users manage their own display name and avatar, resolve attributed chat and presence identities through uploaded avatars or a private cached Gravatar proxy, and keep other users' profiles admin-only.
|
||||||
- **Trusted-proxy browser pairing:** optionally auto-approve new Control UI and WebChat devices from allowlisted proxy identities with non-admin scope caps, while keeping existing-device upgrades manual.
|
- **Trusted-proxy browser pairing:** optionally auto-approve new Control UI and WebChat devices from allowlisted proxy identities with non-admin scope caps, while keeping existing-device upgrades manual.
|
||||||
- **Channel plugin ingress monitors:** add a shared plugin SDK monitor for durable admission, polling, pruning, claim identity validation, adoption handoff, and shutdown, and migrate IRC, Synology Chat, and Google Chat to the shared lifecycle.
|
- **Channel plugin ingress monitors:** add a shared plugin SDK monitor for durable admission, polling, pruning, claim identity validation, adoption handoff, and shutdown, and migrate IRC, Synology Chat, and Google Chat to the shared lifecycle.
|
||||||
- **External gateway supervision:** add `OPENCLAW_SUPERVISOR_MODE=external` for lifecycle owners such as OCM, preserving verified restart and deferral behavior without exposing native service authority, blocking native service mutation and self-update, and providing a versioned atomic restart-handoff consume contract. Thanks @shakkernerd.
|
- **External gateway supervision:** add `OPENCLAW_SUPERVISOR_MODE=external` for lifecycle owners such as OCM, preserving verified restart and deferral behavior without exposing native service authority, blocking native service mutation and self-update, and providing a versioned atomic restart-handoff consume contract. Thanks @shakkernerd.
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export type GatewayClient = {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
hasAvatar: boolean;
|
hasAvatar: boolean;
|
||||||
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
pluginSurfaceUrls?: Record<string, string>;
|
pluginSurfaceUrls?: Record<string, string>;
|
||||||
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
|
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
|
||||||
|
|||||||
@@ -215,6 +215,27 @@ describe("users gateway methods", () => {
|
|||||||
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@example.com");
|
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("denies an identified write caller changing another profile's avatar", async () => {
|
||||||
|
ensureProfileForEmail.mockReturnValue(profile);
|
||||||
|
resolveUserProfileId.mockReturnValue("profile-2");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await runUsersHandler(
|
||||||
|
"users.setAvatar",
|
||||||
|
{ profileId: "profile-2", mime: "image/png", avatarBase64: "AQ==" },
|
||||||
|
selfClient,
|
||||||
|
),
|
||||||
|
).toHaveBeenCalledWith(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "profile edits require the owning user or operator.admin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(setAvatar).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("allows an owner to edit through a tombstoned durable profile id", async () => {
|
it("allows an owner to edit through a tombstoned durable profile id", async () => {
|
||||||
ensureProfileForEmail.mockReturnValue(profile);
|
ensureProfileForEmail.mockReturnValue(profile);
|
||||||
resolveUserProfileId.mockReturnValue(profile.id);
|
resolveUserProfileId.mockReturnValue(profile.id);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
type PluginNodeCapabilitySurface,
|
type PluginNodeCapabilitySurface,
|
||||||
} from "../../plugin-node-capability.js";
|
} from "../../plugin-node-capability.js";
|
||||||
import { MAX_PAYLOAD_BYTES } from "../../server-constants.js";
|
import { MAX_PAYLOAD_BYTES } from "../../server-constants.js";
|
||||||
|
import { formatUserProfileAvatarPath } from "../../user-profiles-http-path.js";
|
||||||
import { formatForLog, logWs } from "../../ws-log.js";
|
import { formatForLog, logWs } from "../../ws-log.js";
|
||||||
import { truncateCloseReason } from "../close-reason.js";
|
import { truncateCloseReason } from "../close-reason.js";
|
||||||
import { incrementPresenceVersion } from "../health-state.js";
|
import { incrementPresenceVersion } from "../health-state.js";
|
||||||
@@ -149,6 +150,7 @@ export async function attachAuthenticatedGatewayConnect(
|
|||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
displayName: profile.displayName,
|
displayName: profile.displayName,
|
||||||
hasAvatar: profile.avatarMime !== null,
|
hasAvatar: profile.avatarMime !== null,
|
||||||
|
updatedAt: profile.updatedAt,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Profile storage must not block login; retain the legacy email-only identity on failure.
|
// Profile storage must not block login; retain the legacy email-only identity on failure.
|
||||||
@@ -359,11 +361,12 @@ export async function attachAuthenticatedGatewayConnect(
|
|||||||
...(authenticatedUserProfile.displayName
|
...(authenticatedUserProfile.displayName
|
||||||
? { name: authenticatedUserProfile.displayName }
|
? { name: authenticatedUserProfile.displayName }
|
||||||
: {}),
|
: {}),
|
||||||
...(authenticatedUserProfile.hasAvatar
|
// This authenticated route resolves the uploaded avatar first, then the
|
||||||
? {
|
// gateway-side Gravatar proxy, so clients never need an email-hash URL.
|
||||||
avatarUrl: `/api/users/${authenticatedUserProfile.profileId}/avatar`,
|
// The ?v=<updatedAt> revision changes when the profile (avatar) is
|
||||||
}
|
// updated, so a reconnecting viewer's <img> refetches instead of reusing
|
||||||
: {}),
|
// a stale cached image for the unchanged route.
|
||||||
|
avatarUrl: `${formatUserProfileAvatarPath(authenticatedUserProfile.profileId)}?v=${authenticatedUserProfile.updatedAt}`,
|
||||||
}
|
}
|
||||||
: { id: authenticatedUserId, email: authenticatedUserId },
|
: { id: authenticatedUserId, email: authenticatedUserId },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,6 +617,12 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
|||||||
id: profileId,
|
id: profileId,
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
name: "alice",
|
name: "alice",
|
||||||
|
// Published route carries the profile revision (?v=<updatedAt>) so a
|
||||||
|
// reconnecting viewer's <img> refetches after an avatar upload instead
|
||||||
|
// of reusing the stale cached image for an unchanged URL.
|
||||||
|
avatarUrl: expect.stringMatching(
|
||||||
|
new RegExp(`^/api/users/${profileId}/avatar\\?v=\\d+$`, "u"),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
expect(first.harness.client).toMatchObject({
|
expect(first.harness.client).toMatchObject({
|
||||||
authenticatedUserId: "alice@example.com",
|
authenticatedUserId: "alice@example.com",
|
||||||
@@ -633,7 +639,9 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
|||||||
id: profileId,
|
id: profileId,
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
name: "alice",
|
name: "alice",
|
||||||
avatarUrl: `/api/users/${profileId}/avatar`,
|
avatarUrl: expect.stringMatching(
|
||||||
|
new RegExp(`^/api/users/${profileId}/avatar\\?v=\\d+$`, "u"),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
expect(second.harness.client).toMatchObject({
|
expect(second.harness.client).toMatchObject({
|
||||||
authenticatedUserProfile: { profileId, hasAvatar: true },
|
authenticatedUserProfile: { profileId, hasAvatar: true },
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
hasAvatar: boolean;
|
hasAvatar: boolean;
|
||||||
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
clientIp?: string;
|
clientIp?: string;
|
||||||
internal?: {
|
internal?: {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
const USER_PROFILE_AVATAR_PATH = /^\/api\/users\/([^/]+)\/avatar$/u;
|
const USER_PROFILE_AVATAR_PATH = /^\/api\/users\/([^/]+)\/avatar$/u;
|
||||||
|
|
||||||
|
export function formatUserProfileAvatarPath(profileId: string): string {
|
||||||
|
return `/api/users/${encodeURIComponent(profileId)}/avatar`;
|
||||||
|
}
|
||||||
|
|
||||||
export function matchUserProfileAvatarPath(pathname: string): string | undefined {
|
export function matchUserProfileAvatarPath(pathname: string): string | undefined {
|
||||||
const profileId = USER_PROFILE_AVATAR_PATH.exec(pathname)?.[1];
|
const profileId = USER_PROFILE_AVATAR_PATH.exec(pathname)?.[1];
|
||||||
if (!profileId) {
|
if (!profileId) {
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { handleUserProfileAvatarHttpRequest } from "./user-profiles-http.js";
|
import { handleUserProfileAvatarHttpRequest } from "./user-profiles-http.js";
|
||||||
|
|
||||||
const authorizeScopedGatewayHttpRequestOrReply = vi.hoisted(() => vi.fn());
|
const authorizeScopedGatewayHttpRequestOrReply = vi.hoisted(() => vi.fn());
|
||||||
|
const getRuntimeConfig = vi.hoisted(() => vi.fn());
|
||||||
const getProfileAvatar = vi.hoisted(() => vi.fn());
|
const getProfileAvatar = vi.hoisted(() => vi.fn());
|
||||||
|
const getUserProfileListItem = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("./http-utils.js", async (importOriginal) => ({
|
vi.mock("./http-utils.js", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("./http-utils.js")>()),
|
...(await importOriginal<typeof import("./http-utils.js")>()),
|
||||||
authorizeScopedGatewayHttpRequestOrReply,
|
authorizeScopedGatewayHttpRequestOrReply,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../config/io.js", () => ({ getRuntimeConfig }));
|
||||||
vi.mock("../state/user-profiles.js", () => ({
|
vi.mock("../state/user-profiles.js", () => ({
|
||||||
formatUserProfileAvatarEtag: (sha256: string, mime: string) =>
|
formatUserProfileAvatarEtag: (sha256: string, mime: string) =>
|
||||||
`"${sha256}-${mime.slice("image/".length)}"`,
|
`"${sha256}-${mime.slice("image/".length)}"`,
|
||||||
getProfileAvatar,
|
getProfileAvatar,
|
||||||
|
getUserProfileListItem,
|
||||||
|
UserProfileNotFoundError: class UserProfileNotFoundError extends Error {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function emailHash(email: string): string {
|
||||||
|
return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchUrl(input: URL | RequestInfo): string {
|
||||||
|
return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
}
|
||||||
|
|
||||||
function response() {
|
function response() {
|
||||||
const end = vi.fn();
|
const end = vi.fn();
|
||||||
const setHeader = vi.fn();
|
const setHeader = vi.fn();
|
||||||
@@ -22,6 +36,7 @@ function response() {
|
|||||||
return {
|
return {
|
||||||
end,
|
end,
|
||||||
response: { end, setHeader, writeHead } as unknown as ServerResponse,
|
response: { end, setHeader, writeHead } as unknown as ServerResponse,
|
||||||
|
setHeader,
|
||||||
writeHead,
|
writeHead,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -34,7 +49,34 @@ describe("profile avatar HTTP endpoint", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authorizeScopedGatewayHttpRequestOrReply.mockReset();
|
authorizeScopedGatewayHttpRequestOrReply.mockReset();
|
||||||
getProfileAvatar.mockReset();
|
getProfileAvatar.mockReset();
|
||||||
|
getUserProfileListItem.mockReset();
|
||||||
|
getRuntimeConfig.mockReset();
|
||||||
authorizeScopedGatewayHttpRequestOrReply.mockResolvedValue({});
|
authorizeScopedGatewayHttpRequestOrReply.mockResolvedValue({});
|
||||||
|
getRuntimeConfig.mockReturnValue({
|
||||||
|
gateway: { controlUi: { allowedOrigins: ["https://control.example"] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers allowed credentialed cross-origin preflights without avatar auth", async () => {
|
||||||
|
const res = response();
|
||||||
|
const req = {
|
||||||
|
method: "OPTIONS",
|
||||||
|
url: "/ignored-by-handler",
|
||||||
|
headers: { origin: "https://control.example" },
|
||||||
|
} as unknown as IncomingMessage;
|
||||||
|
|
||||||
|
await handleUserProfileAvatarHttpRequest(req, res.response, "/api/users/profile-1/avatar", {
|
||||||
|
auth: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authorizeScopedGatewayHttpRequestOrReply).not.toHaveBeenCalled();
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
|
"https://control.example",
|
||||||
|
);
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Access-Control-Allow-Credentials", "true");
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Access-Control-Allow-Headers", "Authorization");
|
||||||
|
expect(res.writeHead).toHaveBeenCalledWith(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves avatars with their stored MIME type and representation ETag", async () => {
|
it("serves avatars with their stored MIME type and representation ETag", async () => {
|
||||||
@@ -79,7 +121,10 @@ describe("profile avatar HTTP endpoint", () => {
|
|||||||
{ auth: {} as never },
|
{ auth: {} as never },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.writeHead).toHaveBeenCalledWith(304, { ETag: '"current-hash-png"' });
|
expect(res.writeHead).toHaveBeenCalledWith(304, {
|
||||||
|
ETag: '"current-hash-png"',
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
});
|
||||||
expect(res.end).toHaveBeenCalledWith();
|
expect(res.end).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +187,300 @@ describe("profile avatar HTTP endpoint", () => {
|
|||||||
{ auth: {} as never },
|
{ auth: {} as never },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.writeHead).toHaveBeenCalledWith(304, { ETag: '"current-hash-png"' });
|
expect(res.writeHead).toHaveBeenCalledWith(304, {
|
||||||
|
ETag: '"current-hash-png"',
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("proxies and caches Gravatar by a profile's normalized email", async () => {
|
||||||
|
const profileId = "profile-gravatar-cache";
|
||||||
|
const hash = emailHash(" Ada@Example.com ");
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
getUserProfileListItem.mockReturnValue({
|
||||||
|
id: profileId,
|
||||||
|
emails: [" Ada@Example.com "],
|
||||||
|
hasAvatar: false,
|
||||||
|
});
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(
|
||||||
|
new Response(new Uint8Array([4, 5, 6]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = response();
|
||||||
|
const second = response();
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
first.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
second.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
`https://www.gravatar.com/avatar/${hash}?s=256&d=404`,
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Accept: "image/webp,image/png,image/jpeg,image/gif" },
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(first.writeHead).toHaveBeenCalledWith(
|
||||||
|
200,
|
||||||
|
expect.objectContaining({
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(first.end).toHaveBeenCalledWith(new Uint8Array([4, 5, 6]));
|
||||||
|
expect(second.end).toHaveBeenCalledWith(new Uint8Array([4, 5, 6]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative-caches a Gravatar 404 so the UI can fall back to initials", async () => {
|
||||||
|
const profileId = "profile-gravatar-miss";
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
getUserProfileListItem.mockReturnValue({
|
||||||
|
id: profileId,
|
||||||
|
emails: ["missing-avatar@example.com"],
|
||||||
|
hasAvatar: false,
|
||||||
|
});
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 404 }));
|
||||||
|
|
||||||
|
const first = response();
|
||||||
|
const second = response();
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
first.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
second.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
expect(first.response.statusCode).toBe(404);
|
||||||
|
expect(first.setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8");
|
||||||
|
// A cached 404 would hide a later uploaded avatar behind the stable route.
|
||||||
|
expect(first.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store");
|
||||||
|
expect(second.response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the primary email's Gravatar when several linked emails resolve", async () => {
|
||||||
|
const profileId = "profile-multi-email-primary";
|
||||||
|
const primaryHash = emailHash("primary@example.com");
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
getUserProfileListItem.mockReturnValue({
|
||||||
|
id: profileId,
|
||||||
|
emails: ["primary@example.com", "secondary@example.com"],
|
||||||
|
hasAvatar: false,
|
||||||
|
});
|
||||||
|
const secondaryHash = emailHash("secondary@example.com");
|
||||||
|
// The primary email has a Gravatar, so its lookup short-circuits — the
|
||||||
|
// secondary email's hash must never be disclosed to Gravatar.
|
||||||
|
const fetchImpl = vi.fn(async (input: URL | RequestInfo) =>
|
||||||
|
fetchUrl(input).includes(primaryHash)
|
||||||
|
? new Response(new Uint8Array([1, 1, 1]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
})
|
||||||
|
: new Response(new Uint8Array([2, 2, 2]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
res.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.end).toHaveBeenCalledWith(new Uint8Array([1, 1, 1]));
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(secondaryHash),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to a later linked email when the primary has no Gravatar", async () => {
|
||||||
|
const profileId = "profile-multi-email-fallthrough";
|
||||||
|
const primaryHash = emailHash("primary-miss@example.com");
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
getUserProfileListItem.mockReturnValue({
|
||||||
|
id: profileId,
|
||||||
|
emails: ["primary-miss@example.com", "secondary-hit@example.com"],
|
||||||
|
hasAvatar: false,
|
||||||
|
});
|
||||||
|
const fetchImpl = vi.fn(async (input: URL | RequestInfo) =>
|
||||||
|
fetchUrl(input).includes(primaryHash)
|
||||||
|
? new Response(null, { status: 404 })
|
||||||
|
: new Response(new Uint8Array([2, 2, 2]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
res.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
// A definite miss on the primary lets the request fall through to the
|
||||||
|
// secondary email under the shared deadline; the secondary hit is served.
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||||
|
expect(res.end).toHaveBeenCalledWith(new Uint8Array([2, 2, 2]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the Gravatar fan-out so a profile with many linked emails is bounded", async () => {
|
||||||
|
const profileId = "profile-many-emails";
|
||||||
|
const emails = Array.from({ length: 12 }, (_, index) => `many-${index}@example.com`);
|
||||||
|
// Only the last email — beyond the fan-out cap — has a Gravatar.
|
||||||
|
const reachableHash = emailHash(emails[emails.length - 1] ?? "");
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
getUserProfileListItem.mockReturnValue({ id: profileId, emails, hasAvatar: false });
|
||||||
|
const fetchImpl = vi.fn(async (input: URL | RequestInfo) =>
|
||||||
|
fetchUrl(input).includes(reachableHash)
|
||||||
|
? new Response(new Uint8Array([9, 9, 9]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
})
|
||||||
|
: new Response(null, { status: 404 }),
|
||||||
|
);
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
res.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only the first 8 emails are looked up, so the request never fans out to
|
||||||
|
// all 12 and the beyond-cap avatar stays unreachable (404 fallback).
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(8);
|
||||||
|
expect(res.response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a chunked Gravatar response as soon as it exceeds the byte cap", async () => {
|
||||||
|
const profileId = "profile-gravatar-oversized";
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
getUserProfileListItem.mockReturnValue({
|
||||||
|
id: profileId,
|
||||||
|
emails: ["oversized-avatar@example.com"],
|
||||||
|
hasAvatar: false,
|
||||||
|
});
|
||||||
|
const cancel = vi.fn();
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(600_000));
|
||||||
|
controller.enqueue(new Uint8Array(600_000));
|
||||||
|
},
|
||||||
|
cancel,
|
||||||
|
});
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(
|
||||||
|
new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
res.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.response.statusCode).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a Gravatar response rejected by its declared byte size", async () => {
|
||||||
|
const profileId = "profile-gravatar-declared-oversized";
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
getUserProfileListItem.mockReturnValue({
|
||||||
|
id: profileId,
|
||||||
|
emails: ["declared-oversized-avatar@example.com"],
|
||||||
|
hasAvatar: false,
|
||||||
|
});
|
||||||
|
const cancel = vi.fn();
|
||||||
|
const body = new ReadableStream<Uint8Array>({ cancel });
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(
|
||||||
|
new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-length": "1000001",
|
||||||
|
"content-type": "image/png",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
res.response,
|
||||||
|
`/api/users/${profileId}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.response.statusCode).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evicts older Gravatar images when the cache reaches its byte budget", async () => {
|
||||||
|
getProfileAvatar.mockReturnValue(undefined);
|
||||||
|
const imageBytes = new Uint8Array(1_000_000);
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(imageBytes.slice(), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const emails = Array.from({ length: 17 }, (_, index) => `cache-${index}@example.com`);
|
||||||
|
const profiles = emails.map((email, index) => ({
|
||||||
|
id: `profile-cache-${index}`,
|
||||||
|
emails: [email],
|
||||||
|
hasAvatar: false,
|
||||||
|
}));
|
||||||
|
getUserProfileListItem.mockImplementation((profileId: string) =>
|
||||||
|
profiles.find((profile) => profile.id === profileId),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
response().response,
|
||||||
|
`/api/users/${profile.id}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await handleUserProfileAvatarHttpRequest(
|
||||||
|
request("/ignored-by-handler"),
|
||||||
|
response().response,
|
||||||
|
`/api/users/${profiles[0]?.id}/avatar`,
|
||||||
|
{ auth: {} as never, fetchImpl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(18);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
// Authenticated HTTP avatar serving for durable user profiles.
|
// Authenticated HTTP avatar serving and Gravatar proxying for durable user profiles.
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { formatUserProfileAvatarEtag, getProfileAvatar } from "../state/user-profiles.js";
|
import { getRuntimeConfig } from "../config/io.js";
|
||||||
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
|
import {
|
||||||
|
formatUserProfileAvatarEtag,
|
||||||
|
getProfileAvatar,
|
||||||
|
getUserProfileListItem,
|
||||||
|
UserProfileNotFoundError,
|
||||||
|
} from "../state/user-profiles.js";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import { sendJson, sendMethodNotAllowed } from "./http-common.js";
|
import { sendJson, sendMethodNotAllowed } from "./http-common.js";
|
||||||
@@ -10,6 +18,271 @@ import {
|
|||||||
} from "./http-utils.js";
|
} from "./http-utils.js";
|
||||||
import { matchUserProfileAvatarPath } from "./user-profiles-http-path.js";
|
import { matchUserProfileAvatarPath } from "./user-profiles-http-path.js";
|
||||||
|
|
||||||
|
const GRAVATAR_BASE_URL = "https://www.gravatar.com/avatar";
|
||||||
|
const GRAVATAR_FETCH_TIMEOUT_MS = 5_000;
|
||||||
|
// Whole-request budget shared across a profile's linked emails. Lookups run
|
||||||
|
// sequentially (see the resolution loop) so a secondary email's hash is only
|
||||||
|
// disclosed to Gravatar after the earlier one is a definite miss; this deadline
|
||||||
|
// bounds the total wait so an unreachable Gravatar cannot stall the held
|
||||||
|
// connection by GRAVATAR_FETCH_TIMEOUT_MS × linked-email-count.
|
||||||
|
const GRAVATAR_TOTAL_TIMEOUT_MS = 6_000;
|
||||||
|
const GRAVATAR_CACHE_MAX_ENTRIES = 256;
|
||||||
|
const GRAVATAR_CACHE_MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
const GRAVATAR_HIT_TTL_MS = 24 * 60 * 60_000;
|
||||||
|
const GRAVATAR_MISS_TTL_MS = 15 * 60_000;
|
||||||
|
const MAX_GRAVATAR_BYTES = 1_000_000;
|
||||||
|
// Bound the Gravatar fan-out per avatar request. Linked emails are primary-first
|
||||||
|
// and resolved sequentially with short-circuit, so the cap only matters when
|
||||||
|
// every earlier email misses; it stops a profile with many linked addresses from
|
||||||
|
// probing an unbounded number of them against Gravatar.
|
||||||
|
const MAX_GRAVATAR_EMAIL_LOOKUPS = 8;
|
||||||
|
const GRAVATAR_MIME_TYPES = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]);
|
||||||
|
|
||||||
|
function resolveAvatarCorsOrigin(req: IncomingMessage, cfg: OpenClawConfig): string | undefined {
|
||||||
|
const rawOrigin = typeof req.headers.origin === "string" ? req.headers.origin.trim() : "";
|
||||||
|
if (!rawOrigin) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let origin: string;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawOrigin);
|
||||||
|
if (parsed.origin !== rawOrigin || parsed.username || parsed.password) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
origin = parsed.origin;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const allowed = cfg.gateway?.controlUi?.allowedOrigins ?? [];
|
||||||
|
return allowed.some((candidate) => candidate.trim() === "*" || candidate.trim() === origin)
|
||||||
|
? origin
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAvatarCorsHeaders(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
): boolean {
|
||||||
|
if (!req.headers.origin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const origin = resolveAvatarCorsOrigin(req, cfg);
|
||||||
|
if (!origin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||||
|
res.setHeader("Vary", "Origin");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GravatarHit = {
|
||||||
|
kind: "hit";
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mime: string;
|
||||||
|
etag: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GravatarResult = GravatarHit | { kind: "miss" } | { kind: "error" };
|
||||||
|
type CachedGravatarResult = Exclude<GravatarResult, { kind: "error" }> & { expiresAtMs: number };
|
||||||
|
|
||||||
|
const gravatarCache = new Map<string, CachedGravatarResult>();
|
||||||
|
const gravatarRequests = new Map<string, Promise<GravatarResult>>();
|
||||||
|
let gravatarCacheBytes = 0;
|
||||||
|
|
||||||
|
function deleteCachedGravatar(hash: string): void {
|
||||||
|
const cached = gravatarCache.get(hash);
|
||||||
|
if (cached?.kind === "hit") {
|
||||||
|
gravatarCacheBytes -= cached.bytes.byteLength;
|
||||||
|
}
|
||||||
|
gravatarCache.delete(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashEmail(email: string): string {
|
||||||
|
return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedGravatar(hash: string, nowMs: number): GravatarResult | undefined {
|
||||||
|
const cached = gravatarCache.get(hash);
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (cached.expiresAtMs <= nowMs) {
|
||||||
|
deleteCachedGravatar(hash);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Map insertion order is the LRU order. Promote on every hit.
|
||||||
|
deleteCachedGravatar(hash);
|
||||||
|
gravatarCache.set(hash, cached);
|
||||||
|
if (cached.kind === "hit") {
|
||||||
|
gravatarCacheBytes += cached.bytes.byteLength;
|
||||||
|
}
|
||||||
|
return cached.kind === "hit"
|
||||||
|
? { kind: "hit", bytes: cached.bytes, mime: cached.mime, etag: cached.etag }
|
||||||
|
: { kind: "miss" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheGravatar(
|
||||||
|
hash: string,
|
||||||
|
result: Exclude<GravatarResult, { kind: "error" }>,
|
||||||
|
nowMs: number,
|
||||||
|
) {
|
||||||
|
const ttlMs = result.kind === "hit" ? GRAVATAR_HIT_TTL_MS : GRAVATAR_MISS_TTL_MS;
|
||||||
|
deleteCachedGravatar(hash);
|
||||||
|
const cached = { ...result, expiresAtMs: nowMs + ttlMs } satisfies CachedGravatarResult;
|
||||||
|
gravatarCache.set(hash, cached);
|
||||||
|
if (cached.kind === "hit") {
|
||||||
|
gravatarCacheBytes += cached.bytes.byteLength;
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
gravatarCache.size > GRAVATAR_CACHE_MAX_ENTRIES ||
|
||||||
|
gravatarCacheBytes > GRAVATAR_CACHE_MAX_BYTES
|
||||||
|
) {
|
||||||
|
const oldest = gravatarCache.keys().next().value;
|
||||||
|
if (oldest === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
deleteCachedGravatar(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContentType(value: string | null): string {
|
||||||
|
return value?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBoundedGravatarBody(
|
||||||
|
body: ReadableStream<Uint8Array> | null,
|
||||||
|
): Promise<Uint8Array | undefined> {
|
||||||
|
if (!body) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
totalBytes += next.value.byteLength;
|
||||||
|
if (totalBytes > MAX_GRAVATAR_BYTES) {
|
||||||
|
await reader.cancel();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
chunks.push(next.value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
if (totalBytes === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(totalBytes);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelGravatarBody(body: ReadableStream<Uint8Array> | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await body?.cancel();
|
||||||
|
} catch {
|
||||||
|
// The response is already unusable; cancellation is only best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGravatar(
|
||||||
|
hash: string,
|
||||||
|
fetchImpl: typeof globalThis.fetch,
|
||||||
|
deadline?: AbortSignal,
|
||||||
|
): Promise<GravatarResult> {
|
||||||
|
try {
|
||||||
|
const perCall = AbortSignal.timeout(GRAVATAR_FETCH_TIMEOUT_MS);
|
||||||
|
const response = await fetchImpl(`${GRAVATAR_BASE_URL}/${hash}?s=256&d=404`, {
|
||||||
|
headers: { Accept: "image/webp,image/png,image/jpeg,image/gif" },
|
||||||
|
signal: deadline ? AbortSignal.any([deadline, perCall]) : perCall,
|
||||||
|
});
|
||||||
|
if (response.status === 404) {
|
||||||
|
await cancelGravatarBody(response.body);
|
||||||
|
return { kind: "miss" };
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
await cancelGravatarBody(response.body);
|
||||||
|
return { kind: "error" };
|
||||||
|
}
|
||||||
|
const mime = normalizeContentType(response.headers.get("content-type"));
|
||||||
|
const declaredLength = Number(response.headers.get("content-length"));
|
||||||
|
if (
|
||||||
|
!GRAVATAR_MIME_TYPES.has(mime) ||
|
||||||
|
(Number.isFinite(declaredLength) && declaredLength > MAX_GRAVATAR_BYTES)
|
||||||
|
) {
|
||||||
|
await cancelGravatarBody(response.body);
|
||||||
|
return { kind: "error" };
|
||||||
|
}
|
||||||
|
const bytes = await readBoundedGravatarBody(response.body);
|
||||||
|
if (!bytes) {
|
||||||
|
return { kind: "error" };
|
||||||
|
}
|
||||||
|
const etag = `"gravatar-${createHash("sha256").update(bytes).digest("hex")}"`;
|
||||||
|
return { kind: "hit", bytes, mime, etag };
|
||||||
|
} catch {
|
||||||
|
return { kind: "error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGravatar(
|
||||||
|
hash: string,
|
||||||
|
options: { fetchImpl: typeof globalThis.fetch; nowMs: () => number; deadline?: AbortSignal },
|
||||||
|
): Promise<GravatarResult> {
|
||||||
|
const cached = getCachedGravatar(hash, options.nowMs());
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const inFlight = gravatarRequests.get(hash);
|
||||||
|
if (inFlight) {
|
||||||
|
return await inFlight;
|
||||||
|
}
|
||||||
|
const request = fetchGravatar(hash, options.fetchImpl, options.deadline).then((result) => {
|
||||||
|
if (result.kind !== "error") {
|
||||||
|
cacheGravatar(hash, result, options.nowMs());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
gravatarRequests.set(hash, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
gravatarRequests.delete(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAvatar(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
avatar: { bytes: Uint8Array; mime: string; etag: string },
|
||||||
|
cacheControl: string,
|
||||||
|
): void {
|
||||||
|
if (ifNoneMatchMatches(req.headers["if-none-match"], avatar.etag)) {
|
||||||
|
// Carry the success cache policy so a 304 does not inherit the miss-path
|
||||||
|
// no-store and force the client to re-download an unchanged avatar.
|
||||||
|
res.writeHead(304, { ETag: avatar.etag, "Cache-Control": cacheControl });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": avatar.mime,
|
||||||
|
"Content-Length": avatar.bytes.byteLength,
|
||||||
|
"Cache-Control": cacheControl,
|
||||||
|
ETag: avatar.etag,
|
||||||
|
});
|
||||||
|
res.end(req.method === "HEAD" ? undefined : avatar.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/** Serves a profile avatar with the same HTTP operator auth as sibling gateway endpoints. */
|
/** Serves a profile avatar with the same HTTP operator auth as sibling gateway endpoints. */
|
||||||
export async function handleUserProfileAvatarHttpRequest(
|
export async function handleUserProfileAvatarHttpRequest(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
@@ -20,6 +293,8 @@ export async function handleUserProfileAvatarHttpRequest(
|
|||||||
trustedProxies?: string[];
|
trustedProxies?: string[];
|
||||||
allowRealIpFallback?: boolean;
|
allowRealIpFallback?: boolean;
|
||||||
rateLimiter?: AuthRateLimiter;
|
rateLimiter?: AuthRateLimiter;
|
||||||
|
fetchImpl?: typeof globalThis.fetch;
|
||||||
|
nowMs?: () => number;
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const profileId = matchUserProfileAvatarPath(pathname);
|
const profileId = matchUserProfileAvatarPath(pathname);
|
||||||
@@ -27,6 +302,20 @@ export async function handleUserProfileAvatarHttpRequest(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const method = req.method;
|
const method = req.method;
|
||||||
|
const cfg = getRuntimeConfig();
|
||||||
|
const corsAllowed = setAvatarCorsHeaders(req, res, cfg);
|
||||||
|
if (method === "OPTIONS") {
|
||||||
|
if (!corsAllowed) {
|
||||||
|
sendJson(res, 403, { ok: false, error: { type: "origin_not_allowed" } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Authorization");
|
||||||
|
res.setHeader("Access-Control-Max-Age", "600");
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (method !== "GET" && method !== "HEAD") {
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
sendMethodNotAllowed(res, "GET, HEAD");
|
sendMethodNotAllowed(res, "GET, HEAD");
|
||||||
return true;
|
return true;
|
||||||
@@ -44,24 +333,75 @@ export async function handleUserProfileAvatarHttpRequest(
|
|||||||
if (!authResult) {
|
if (!authResult) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const avatar = getProfileAvatar(profileId);
|
// Avatars render as plain <img> against a stable, unversioned route, so a
|
||||||
if (!avatar) {
|
// heuristically-cached 404 miss would otherwise hide a later uploaded image.
|
||||||
sendJson(res, 404, { ok: false, error: { type: "not_found" } });
|
// Misses must never be cached; the 200 path overrides this with must-revalidate.
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
let uploadedAvatar: ReturnType<typeof getProfileAvatar>;
|
||||||
|
try {
|
||||||
|
uploadedAvatar = getProfileAvatar(profileId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserProfileNotFoundError) {
|
||||||
|
sendJson(res, 404, { ok: false, error: { type: "not_found" } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendJson(res, 500, { ok: false, error: { type: "profile_lookup_failed" } });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const etag = formatUserProfileAvatarEtag(avatar.sha256, avatar.mime);
|
if (uploadedAvatar) {
|
||||||
if (ifNoneMatchMatches(req.headers["if-none-match"], etag)) {
|
sendAvatar(
|
||||||
res.writeHead(304, { ETag: etag });
|
req,
|
||||||
res.end();
|
res,
|
||||||
|
{
|
||||||
|
bytes: uploadedAvatar.bytes,
|
||||||
|
mime: uploadedAvatar.mime,
|
||||||
|
etag: formatUserProfileAvatarEtag(uploadedAvatar.sha256, uploadedAvatar.mime),
|
||||||
|
},
|
||||||
|
"private, max-age=0, must-revalidate",
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
res.writeHead(200, {
|
|
||||||
"Content-Type": avatar.mime,
|
let hashes: string[];
|
||||||
"Content-Length": avatar.bytes.byteLength,
|
try {
|
||||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
hashes = getUserProfileListItem(profileId)
|
||||||
ETag: etag,
|
.emails.slice(0, MAX_GRAVATAR_EMAIL_LOOKUPS)
|
||||||
|
.map(hashEmail);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserProfileNotFoundError) {
|
||||||
|
sendJson(res, 404, { ok: false, error: { type: "not_found" } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendJson(res, 500, { ok: false, error: { type: "profile_lookup_failed" } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve linked emails sequentially and stop at the first hit: the primary
|
||||||
|
// email keeps precedence, and a secondary email's hash is disclosed to
|
||||||
|
// Gravatar only once the earlier one is a definite miss. A single shared
|
||||||
|
// deadline bounds the total wait, so an unreachable Gravatar cannot stall the
|
||||||
|
// held connection by one timeout per linked email.
|
||||||
|
const deadline = AbortSignal.timeout(GRAVATAR_TOTAL_TIMEOUT_MS);
|
||||||
|
let transientFailure = false;
|
||||||
|
for (const hash of hashes) {
|
||||||
|
const result = await resolveGravatar(hash, {
|
||||||
|
fetchImpl: opts.fetchImpl ?? globalThis.fetch,
|
||||||
|
nowMs: opts.nowMs ?? Date.now,
|
||||||
|
deadline,
|
||||||
|
});
|
||||||
|
if (result.kind === "hit") {
|
||||||
|
sendAvatar(req, res, result, "private, max-age=0, must-revalidate");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
transientFailure ||= result.kind === "error";
|
||||||
|
if (deadline.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendJson(res, transientFailure ? 502 : 404, {
|
||||||
|
ok: false,
|
||||||
|
error: { type: transientFailure ? "avatar_upstream_unavailable" : "not_found" },
|
||||||
});
|
});
|
||||||
res.end(method === "HEAD" ? undefined : avatar.bytes);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type GatewayEventListener,
|
type GatewayEventListener,
|
||||||
type GatewayHelloOk,
|
type GatewayHelloOk,
|
||||||
} from "../api/gateway.ts";
|
} from "../api/gateway.ts";
|
||||||
|
import { setAvatarGatewayOrigin } from "../lib/identity-avatar.ts";
|
||||||
import { resolveSessionKey } from "../lib/sessions/index.ts";
|
import { resolveSessionKey } from "../lib/sessions/index.ts";
|
||||||
import { generateUUID } from "../lib/uuid.ts";
|
import { generateUUID } from "../lib/uuid.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -141,6 +142,9 @@ export function createApplicationGateway(
|
|||||||
connectionOverrides.gatewayUrl !== undefined &&
|
connectionOverrides.gatewayUrl !== undefined &&
|
||||||
connectionOverrides.gatewayUrl !== connection.gatewayUrl;
|
connectionOverrides.gatewayUrl !== connection.gatewayUrl;
|
||||||
connection = nextConnection;
|
connection = nextConnection;
|
||||||
|
// Trust the connected gateway's origin for avatar route resolution so
|
||||||
|
// split-origin Control UI deployments load uploaded/proxied avatars.
|
||||||
|
setAvatarGatewayOrigin(nextConnection.gatewayUrl);
|
||||||
updateSettings(
|
updateSettings(
|
||||||
{
|
{
|
||||||
gatewayUrl: nextConnection.gatewayUrl,
|
gatewayUrl: nextConnection.gatewayUrl,
|
||||||
|
|||||||
@@ -1,126 +1,50 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { setAvatarGatewayOrigin } from "../lib/identity-avatar.ts";
|
||||||
import type { PresenceViewer } from "./viewer-facepile.ts";
|
import type { PresenceViewer } from "./viewer-facepile.ts";
|
||||||
import "./viewer-facepile.ts";
|
import "./viewer-facepile.ts";
|
||||||
|
|
||||||
type ViewerAvatarElement = HTMLElement & {
|
type ViewerAvatarElement = HTMLElement & {
|
||||||
user: PresenceViewer;
|
user: PresenceViewer | null;
|
||||||
updateComplete: Promise<boolean>;
|
updateComplete: Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function viewer(overrides: Partial<PresenceViewer> = {}): PresenceViewer {
|
|
||||||
return {
|
|
||||||
id: "profile-1",
|
|
||||||
name: "Test Person",
|
|
||||||
watchedSessions: [],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountViewer(user: PresenceViewer): Promise<ViewerAvatarElement> {
|
|
||||||
const avatar = document.createElement("openclaw-viewer-avatar") as ViewerAvatarElement;
|
|
||||||
avatar.user = user;
|
|
||||||
document.body.append(avatar);
|
|
||||||
await avatar.updateComplete;
|
|
||||||
return avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForImage(avatar: ViewerAvatarElement): Promise<HTMLImageElement> {
|
|
||||||
await vi.waitFor(() => expect(avatar.querySelector("img")).not.toBeNull());
|
|
||||||
return avatar.querySelector<HTMLImageElement>("img")!;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
|
setAvatarGatewayOrigin(null);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("viewer avatar resolution", () => {
|
it("uses the shared resolver and rejects cross-origin presence avatar metadata", async () => {
|
||||||
it("keeps an uploaded avatar ahead of the email fallback", async () => {
|
const avatar = document.createElement("openclaw-viewer-avatar") as ViewerAvatarElement;
|
||||||
const digest = vi.spyOn(globalThis.crypto.subtle, "digest");
|
avatar.user = {
|
||||||
const avatar = await mountViewer(
|
id: "profile-mallory",
|
||||||
viewer({ email: "test@example.com", avatarUrl: "/api/users/profile-1/avatar?v=2" }),
|
name: "Mallory",
|
||||||
);
|
avatarUrl: "https://evil.example/avatar.png",
|
||||||
const image = await waitForImage(avatar);
|
watchedSessions: [],
|
||||||
|
};
|
||||||
|
document.body.append(avatar);
|
||||||
|
|
||||||
expect(image.getAttribute("src")).toBe("/api/users/profile-1/avatar?v=2");
|
await vi.waitFor(async () => {
|
||||||
expect(image.getAttribute("referrerpolicy")).toBe("no-referrer");
|
|
||||||
expect(image.getAttribute("loading")).toBe("lazy");
|
|
||||||
expect(digest).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries a failed uploaded avatar after the user snapshot updates", async () => {
|
|
||||||
const avatarUrl = "/api/users/profile-1/avatar?v=2";
|
|
||||||
const avatar = await mountViewer(viewer({ avatarUrl }));
|
|
||||||
const failedImage = await waitForImage(avatar);
|
|
||||||
|
|
||||||
failedImage.dispatchEvent(new Event("error"));
|
|
||||||
await avatar.updateComplete;
|
await avatar.updateComplete;
|
||||||
expect(avatar.querySelector("img")).toBeNull();
|
expect(avatar.querySelector("img")).toBeNull();
|
||||||
|
expect(avatar.textContent?.trim()).toBe("MA");
|
||||||
avatar.user = viewer({ avatarUrl });
|
|
||||||
await avatar.updateComplete;
|
|
||||||
expect(avatar.querySelector("img")?.getAttribute("src")).toBe(avatarUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes and hashes email with SHA-256 for the Gravatar URL", async () => {
|
|
||||||
const avatar = await mountViewer(viewer({ email: " TEST@example.com " }));
|
|
||||||
|
|
||||||
expect(avatar.querySelector("img")).toBeNull();
|
|
||||||
expect(avatar.textContent?.trim()).toBe("TP");
|
|
||||||
const image = await waitForImage(avatar);
|
|
||||||
expect(image.getAttribute("src")).toBe(
|
|
||||||
"https://gravatar.com/avatar/973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b?d=404&s=128",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to initials when the Gravatar image fails", async () => {
|
|
||||||
const avatar = await mountViewer(viewer({ email: "missing-one@example.test" }));
|
|
||||||
const image = await waitForImage(avatar);
|
|
||||||
|
|
||||||
image.dispatchEvent(new Event("error"));
|
|
||||||
await avatar.updateComplete;
|
|
||||||
|
|
||||||
expect(avatar.querySelector("img")).toBeNull();
|
|
||||||
expect(avatar.textContent?.trim()).toBe("TP");
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it("does not attribute a stale Gravatar error to the next user", async () => {
|
|
||||||
const avatar = await mountViewer(viewer({ email: "first-stale@example.test" }));
|
it("renders trusted presence avatar routes directly", async () => {
|
||||||
const staleImage = await waitForImage(avatar);
|
const avatar = document.createElement("openclaw-viewer-avatar") as ViewerAvatarElement;
|
||||||
|
avatar.user = {
|
||||||
avatar.user = viewer({ id: "profile-2", email: "second-current@example.test" });
|
id: "profile-ada",
|
||||||
staleImage.dispatchEvent(new Event("error"));
|
name: "Ada Lovelace",
|
||||||
|
avatarUrl: "/api/users/profile-ada/avatar",
|
||||||
|
watchedSessions: [],
|
||||||
|
};
|
||||||
|
document.body.append(avatar);
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await avatar.updateComplete;
|
await avatar.updateComplete;
|
||||||
|
expect(avatar.querySelector("img")?.getAttribute("src")).toBe("/api/users/profile-ada/avatar");
|
||||||
const image = await waitForImage(avatar);
|
|
||||||
expect(image.getAttribute("src")).toMatch(
|
|
||||||
/^https:\/\/gravatar\.com\/avatar\/[a-f0-9]{64}\?d=404&s=128$/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders initials without attempting a hash when email is absent", async () => {
|
|
||||||
const digest = vi.spyOn(globalThis.crypto.subtle, "digest");
|
|
||||||
const avatar = await mountViewer(viewer());
|
|
||||||
|
|
||||||
expect(avatar.querySelector("img")).toBeNull();
|
|
||||||
expect(avatar.textContent?.trim()).toBe("TP");
|
|
||||||
expect(digest).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caches missing Gravatars by normalized email", async () => {
|
|
||||||
const digest = vi.spyOn(globalThis.crypto.subtle, "digest");
|
|
||||||
const first = await mountViewer(viewer({ email: " Missing-Two@Example.Test " }));
|
|
||||||
const image = await waitForImage(first);
|
|
||||||
image.dispatchEvent(new Event("error"));
|
|
||||||
await first.updateComplete;
|
|
||||||
|
|
||||||
const second = await mountViewer(
|
|
||||||
viewer({ id: "profile-2", email: "missing-two@example.test" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(second.querySelector("img")).toBeNull();
|
|
||||||
expect(digest).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { html, nothing, type PropertyValues } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property } from "lit/decorators.js";
|
||||||
import type { PresenceEntry } from "../api/types.ts";
|
import type { PresenceEntry } from "../api/types.ts";
|
||||||
|
import { resolveAvatar } from "../lib/identity-avatar.ts";
|
||||||
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
|
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
|
||||||
import "./tooltip.ts";
|
import "./tooltip.ts";
|
||||||
|
|
||||||
@@ -112,58 +113,39 @@ function avatarColor(userId: string): string {
|
|||||||
return `hsl(${(hash >>> 0) % 360} 48% 42%)`;
|
return `hsl(${(hash >>> 0) % 360} 48% 42%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GravatarCacheEntry = string | Promise<string | null> | { failedUntil: number };
|
function renderAvatarInitials(user: PresenceViewer) {
|
||||||
|
return html`<span style=${`background: ${avatarColor(user.id)}`}>${initialsFor(user)}</span>`;
|
||||||
// An <img> error cannot distinguish a Gravatar 404 from a transient network or
|
|
||||||
// 5xx failure, so negative entries expire instead of poisoning the page lifetime.
|
|
||||||
// Accepted tradeoff: re-resolution happens on the next user-prop update (any
|
|
||||||
// presence/snapshot event), not on a timer — a fully idle page keeps initials
|
|
||||||
// until activity resumes rather than each avatar owning a retry timer.
|
|
||||||
const GRAVATAR_NEGATIVE_CACHE_MS = 10 * 60 * 1000;
|
|
||||||
const gravatarUrlCache = new Map<string, GravatarCacheEntry>();
|
|
||||||
|
|
||||||
function negativeEntry(): GravatarCacheEntry {
|
|
||||||
return { failedUntil: Date.now() + GRAVATAR_NEGATIVE_CACHE_MS };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedEmail(email: string | null | undefined): string | undefined {
|
function resolveViewerAvatar(user: PresenceViewer) {
|
||||||
return normalized(email)?.toLowerCase();
|
const avatar = resolveAvatar({
|
||||||
}
|
id: user.email ?? user.id,
|
||||||
|
name: user.name,
|
||||||
function gravatarUrlForEmail(email: string): string | null | Promise<string | null> {
|
profileAvatarUrl: user.avatarUrl,
|
||||||
const normalizedValue = normalizedEmail(email);
|
});
|
||||||
if (!normalizedValue) {
|
if (avatar.kind === "initials") {
|
||||||
return null;
|
return renderAvatarInitials(user);
|
||||||
}
|
}
|
||||||
const cached = gravatarUrlCache.get(normalizedValue);
|
return html`<img
|
||||||
if (cached !== undefined) {
|
src=${avatar.url}
|
||||||
if (typeof cached === "object" && cached !== null && "failedUntil" in cached) {
|
alt=""
|
||||||
if (cached.failedUntil > Date.now()) {
|
referrerpolicy="no-referrer"
|
||||||
return null;
|
@error=${(event: Event) => {
|
||||||
}
|
const image = event.currentTarget;
|
||||||
gravatarUrlCache.delete(normalizedValue);
|
if (image instanceof HTMLImageElement) {
|
||||||
} else {
|
image.closest<HTMLElement>(".viewer-avatar")?.classList.add("is-fallback");
|
||||||
return cached;
|
}
|
||||||
}
|
}}
|
||||||
}
|
@load=${(event: Event) => {
|
||||||
const pending = Promise.resolve()
|
const image = event.currentTarget;
|
||||||
.then(() =>
|
if (image instanceof HTMLImageElement) {
|
||||||
globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(normalizedValue)),
|
image.closest<HTMLElement>(".viewer-avatar")?.classList.remove("is-fallback");
|
||||||
)
|
}
|
||||||
.then((digest) => {
|
}}
|
||||||
const hash = [...new Uint8Array(digest)]
|
/>
|
||||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
<span class="viewer-avatar__fallback" style=${`background: ${avatarColor(user.id)}`}
|
||||||
.join("");
|
>${initialsFor(user)}</span
|
||||||
const url = `https://gravatar.com/avatar/${hash}?d=404&s=128`;
|
>`;
|
||||||
gravatarUrlCache.set(normalizedValue, url);
|
|
||||||
return url;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
gravatarUrlCache.set(normalizedValue, negativeEntry());
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
gravatarUrlCache.set(normalizedValue, pending);
|
|
||||||
return pending;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewerAvatarVariant = "session" | "footer" | "profile";
|
export type ViewerAvatarVariant = "session" | "footer" | "profile";
|
||||||
@@ -172,89 +154,18 @@ class ViewerAvatar extends OpenClawLightDomContentsElement {
|
|||||||
@property({ attribute: false }) user: PresenceViewer | null = null;
|
@property({ attribute: false }) user: PresenceViewer | null = null;
|
||||||
@property() variant: ViewerAvatarVariant = "session";
|
@property() variant: ViewerAvatarVariant = "session";
|
||||||
|
|
||||||
@state() private gravatar: { email: string; url: string } | null = null;
|
|
||||||
|
|
||||||
private failedImageUrl: string | null = null;
|
|
||||||
private resolutionId = 0;
|
|
||||||
|
|
||||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
|
||||||
if (changedProperties.has("user")) {
|
|
||||||
this.failedImageUrl = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override updated(changedProperties: PropertyValues<this>) {
|
|
||||||
if (!changedProperties.has("user")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolutionId = ++this.resolutionId;
|
|
||||||
const email = this.user?.avatarUrl ? undefined : normalizedEmail(this.user?.email);
|
|
||||||
if (!email) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const applyResolvedUrl = (url: string | null) => {
|
|
||||||
if (
|
|
||||||
resolutionId === this.resolutionId &&
|
|
||||||
!this.user?.avatarUrl &&
|
|
||||||
normalizedEmail(this.user?.email) === email
|
|
||||||
) {
|
|
||||||
this.gravatar = url ? { email, url } : null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const resolved = gravatarUrlForEmail(email);
|
|
||||||
if (typeof resolved === "string") {
|
|
||||||
applyResolvedUrl(resolved);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (resolved) {
|
|
||||||
void resolved.then(applyResolvedUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly handleImageError = (event: Event) => {
|
|
||||||
const imageUrl = (event.currentTarget as HTMLImageElement).getAttribute("src");
|
|
||||||
const user = this.user;
|
|
||||||
if (!imageUrl || !user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const email = normalizedEmail(user.email);
|
|
||||||
if (
|
|
||||||
!user.avatarUrl &&
|
|
||||||
email &&
|
|
||||||
imageUrl === this.gravatar?.url &&
|
|
||||||
email === this.gravatar.email
|
|
||||||
) {
|
|
||||||
gravatarUrlCache.set(email, negativeEntry());
|
|
||||||
this.gravatar = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.failedImageUrl = imageUrl;
|
|
||||||
this.requestUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const user = this.user;
|
const user = this.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
const label = presenceViewerLabel(user);
|
const label = presenceViewerLabel(user);
|
||||||
const email = normalizedEmail(user.email);
|
|
||||||
const gravatarUrl = this.gravatar && this.gravatar.email === email ? this.gravatar.url : null;
|
|
||||||
const imageUrl = user.avatarUrl ?? gravatarUrl;
|
|
||||||
return html`<span
|
return html`<span
|
||||||
class="viewer-avatar viewer-avatar--${this.variant}"
|
class="viewer-avatar viewer-avatar--${this.variant}"
|
||||||
data-viewer-id=${user.id}
|
data-viewer-id=${user.id}
|
||||||
aria-label=${label}
|
aria-label=${label}
|
||||||
>
|
>
|
||||||
${imageUrl && imageUrl !== this.failedImageUrl
|
${resolveViewerAvatar(user)}
|
||||||
? html`<img
|
|
||||||
src=${imageUrl}
|
|
||||||
alt=""
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
loading="lazy"
|
|
||||||
@error=${this.handleImageError}
|
|
||||||
/>`
|
|
||||||
: html`<span style=${`background: ${avatarColor(user.id)}`}>${initialsFor(user)}</span>`}
|
|
||||||
</span>`;
|
</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,12 +181,16 @@ describeControlUiE2e("Control UI profile page mocked Gateway E2E", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the shared Gravatar fallback in the profile preview", async () => {
|
it("renders the gateway avatar route in the profile preview", async () => {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const gravatarRequests: string[] = [];
|
const avatarRequests: string[] = [];
|
||||||
await page.route("https://gravatar.com/avatar/**", async (route) => {
|
// The gateway serves the avatar (uploaded first, Gravatar fallback second)
|
||||||
gravatarRequests.push(route.request().url());
|
// behind its own same-origin route; the Control UI renders only that route,
|
||||||
|
// so the preview never requests gravatar.com directly — the Control UI CSP
|
||||||
|
// (img-src 'self') would block it.
|
||||||
|
await page.route("**/api/users/profile-1/avatar*", async (route) => {
|
||||||
|
avatarRequests.push(route.request().url());
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
|
||||||
contentType: "image/svg+xml",
|
contentType: "image/svg+xml",
|
||||||
@@ -228,12 +232,16 @@ describeControlUiE2e("Control UI profile page mocked Gateway E2E", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedUrl =
|
|
||||||
"https://gravatar.com/avatar/973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b?d=404&s=128";
|
|
||||||
const profileAvatar = page.locator("#settings-profile-identity openclaw-viewer-avatar img");
|
const profileAvatar = page.locator("#settings-profile-identity openclaw-viewer-avatar img");
|
||||||
await profileAvatar.waitFor({ timeout: 10_000 });
|
await profileAvatar.waitFor({ timeout: 10_000 });
|
||||||
expect(await profileAvatar.getAttribute("src")).toBe(expectedUrl);
|
// profile-page derives the src from userProfileAvatarUrl(id, updatedAt);
|
||||||
await expect.poll(() => gravatarRequests.includes(expectedUrl)).toBe(true);
|
// the gateway origin may absolutize it, so match the canonical path suffix.
|
||||||
|
expect(await profileAvatar.getAttribute("src")).toMatch(
|
||||||
|
/\/api\/users\/profile-1\/avatar\?v=2$/u,
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() => avatarRequests.some((url) => url.includes("/api/users/profile-1/avatar")))
|
||||||
|
.toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1122,11 +1122,23 @@ export const en: TranslationMap = {
|
|||||||
},
|
},
|
||||||
personal: {
|
personal: {
|
||||||
title: "Personal",
|
title: "Personal",
|
||||||
|
profileTitle: "Profile",
|
||||||
user: "User",
|
user: "User",
|
||||||
you: "You",
|
you: "You",
|
||||||
assistant: "Assistant",
|
assistant: "Assistant",
|
||||||
localIdentity: "Your local chat identity",
|
localIdentity: "Your local chat identity",
|
||||||
|
profileIdentity: "Your gateway profile",
|
||||||
assistantIdentity: "Assistant identity",
|
assistantIdentity: "Assistant identity",
|
||||||
|
displayName: "Display name",
|
||||||
|
displayNamePlaceholder: "Your name",
|
||||||
|
saveProfile: "Save name",
|
||||||
|
saving: "Saving…",
|
||||||
|
uploading: "Processing…",
|
||||||
|
profileHint: "JPEG, PNG, or WebP. Images are center-cropped and resized to 512px.",
|
||||||
|
profileLoadFailed: "Could not load your profile.",
|
||||||
|
profileSaveFailed: "Could not save your profile.",
|
||||||
|
avatarInvalid: "Choose a valid JPEG, PNG, or WebP image.",
|
||||||
|
avatarTooLarge: "The processed avatar is still too large.",
|
||||||
avatarText: "Avatar text / emoji",
|
avatarText: "Avatar text / emoji",
|
||||||
avatarPlaceholder: "JD or 🦞",
|
avatarPlaceholder: "JD or 🦞",
|
||||||
chooseImage: "Choose image",
|
chooseImage: "Choose image",
|
||||||
|
|||||||
@@ -1,41 +1,29 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { resolveAvatar } from "./identity-avatar.ts";
|
import { resolveAvatar, setAvatarGatewayOrigin } from "./identity-avatar.ts";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setAvatarGatewayOrigin(null);
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveAvatar", () => {
|
describe("resolveAvatar", () => {
|
||||||
it("uses a normalized email id for the proxied avatar hash", async () => {
|
it("falls back to initials for a non-email id", () => {
|
||||||
await expect(
|
expect(resolveAvatar({ id: "profile_123" })).toMatchObject({
|
||||||
resolveAvatar({ id: " Alice@Example.com ", avatarProxyBaseUrl: "/api/avatars/" }),
|
|
||||||
).resolves.toEqual({
|
|
||||||
kind: "gravatar",
|
|
||||||
url: "/api/avatars/ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976?s=64",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never contacts a third-party avatar host without a proxy base", async () => {
|
|
||||||
await expect(resolveAvatar({ id: "alice@example.com" })).resolves.toMatchObject({
|
|
||||||
kind: "initials",
|
|
||||||
initials: "A",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to initials for a non-email id", async () => {
|
|
||||||
await expect(resolveAvatar({ id: "profile_123" })).resolves.toMatchObject({
|
|
||||||
kind: "initials",
|
kind: "initials",
|
||||||
initials: "P",
|
initials: "P",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives up to two initials from a display name", async () => {
|
it("derives up to two initials from a display name", () => {
|
||||||
await expect(resolveAvatar({ name: "Ada Lovelace Byron" })).resolves.toMatchObject({
|
expect(resolveAvatar({ name: "Ada Lovelace Byron" })).toMatchObject({
|
||||||
kind: "initials",
|
kind: "initials",
|
||||||
initials: "AL",
|
initials: "AL",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the initials color deterministic", async () => {
|
it("keeps the initials color deterministic", () => {
|
||||||
const first = await resolveAvatar({ id: "profile_123", name: "Ada Lovelace" });
|
const first = resolveAvatar({ id: "profile_123", name: "Ada Lovelace" });
|
||||||
const second = await resolveAvatar({ id: "profile_123", name: "Renamed User" });
|
const second = resolveAvatar({ id: "profile_123", name: "Renamed User" });
|
||||||
expect(first.kind).toBe("initials");
|
expect(first.kind).toBe("initials");
|
||||||
expect(second.kind).toBe("initials");
|
expect(second.kind).toBe("initials");
|
||||||
if (first.kind === "initials" && second.kind === "initials") {
|
if (first.kind === "initials" && second.kind === "initials") {
|
||||||
@@ -43,42 +31,97 @@ describe("resolveAvatar", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets an already-resolved profile avatar win", async () => {
|
it("lets an already-resolved profile avatar win", () => {
|
||||||
await expect(
|
expect(
|
||||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" }),
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/api/users/p1/avatar" }),
|
||||||
).resolves.toEqual({ kind: "profile", url: "/avatars/alice.png" });
|
).toEqual({ kind: "profile", url: "/api/users/p1/avatar" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveAvatar profile URL origin restriction", () => {
|
describe("resolveAvatar profile URL origin restriction", () => {
|
||||||
it("rejects absolute profile URLs from sender metadata", async () => {
|
it("rejects absolute profile URLs from sender metadata", () => {
|
||||||
await expect(
|
expect(
|
||||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "https://evil.example/a.png" }),
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "https://evil.example/a.png" }),
|
||||||
).resolves.toMatchObject({ kind: "initials" });
|
).toMatchObject({ kind: "initials" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects protocol-relative profile URLs", async () => {
|
it("rejects protocol-relative profile URLs", () => {
|
||||||
await expect(
|
expect(
|
||||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "//evil.example/a.png" }),
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "//evil.example/a.png" }),
|
||||||
).resolves.toMatchObject({ kind: "initials" });
|
).toMatchObject({ kind: "initials" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects backslash and control-character parser bypasses", async () => {
|
it("rejects backslash and control-character parser bypasses", () => {
|
||||||
for (const url of [
|
for (const url of [
|
||||||
"/\\evil.example/a.png",
|
"/\\evil.example/a.png",
|
||||||
"\\/evil.example/a.png",
|
"\\/evil.example/a.png",
|
||||||
"/\t/evil.example/a.png",
|
"/\t/evil.example/a.png",
|
||||||
"htt\nps://evil.example/a.png",
|
"htt\nps://evil.example/a.png",
|
||||||
]) {
|
]) {
|
||||||
await expect(
|
expect(resolveAvatar({ id: "alice@example.com", profileAvatarUrl: url })).toMatchObject({
|
||||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: url }),
|
kind: "initials",
|
||||||
).resolves.toMatchObject({ kind: "initials" });
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts same-origin relative profile URLs", async () => {
|
it("accepts the canonical same-origin avatar route", () => {
|
||||||
await expect(
|
expect(
|
||||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" }),
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/api/users/p1/avatar" }),
|
||||||
).resolves.toEqual({ kind: "profile", url: "/avatars/alice.png" });
|
).toEqual({ kind: "profile", url: "/api/users/p1/avatar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a same-origin path that is not the avatar route", () => {
|
||||||
|
expect(
|
||||||
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/api/secrets" }),
|
||||||
|
).toMatchObject({ kind: "initials" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the version query but drops the fragment on the avatar route", () => {
|
||||||
|
expect(
|
||||||
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/api/users/p1/avatar?v=2#f" }),
|
||||||
|
).toEqual({ kind: "profile", url: "/api/users/p1/avatar?v=2" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAvatar gateway origin trust", () => {
|
||||||
|
it("keeps relative avatar paths relative when no gateway origin is set", () => {
|
||||||
|
expect(
|
||||||
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/api/users/p1/avatar" }),
|
||||||
|
).toEqual({ kind: "profile", url: "/api/users/p1/avatar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves relative paths against the configured gateway origin", () => {
|
||||||
|
setAvatarGatewayOrigin("wss://gw.example.com/ws");
|
||||||
|
expect(
|
||||||
|
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/api/users/p1/avatar" }),
|
||||||
|
).toEqual({ kind: "profile", url: "https://gw.example.com/api/users/p1/avatar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an absolute URL only when it matches the gateway origin", () => {
|
||||||
|
setAvatarGatewayOrigin("https://gw.example.com");
|
||||||
|
expect(
|
||||||
|
resolveAvatar({
|
||||||
|
id: "a@example.com",
|
||||||
|
profileAvatarUrl: "https://gw.example.com/api/users/p1/avatar",
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "profile", url: "https://gw.example.com/api/users/p1/avatar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an absolute URL from a different origin than the gateway", () => {
|
||||||
|
setAvatarGatewayOrigin("https://gw.example.com");
|
||||||
|
expect(
|
||||||
|
resolveAvatar({ id: "a@example.com", profileAvatarUrl: "https://evil.example/a.png" }),
|
||||||
|
).toMatchObject({ kind: "initials" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: the trusted origin can only come from setAvatarGatewayOrigin — the
|
||||||
|
// IdentityAvatarInput type has no gatewayOrigin field, so sender metadata
|
||||||
|
// cannot influence it (compile-time enforced; no runtime test needed).
|
||||||
|
|
||||||
|
it("honors the app-wide gateway origin set via setAvatarGatewayOrigin", () => {
|
||||||
|
setAvatarGatewayOrigin("wss://gw.example.com/ws");
|
||||||
|
expect(
|
||||||
|
resolveAvatar({ id: "a@example.com", profileAvatarUrl: "/api/users/p1/avatar" }),
|
||||||
|
).toEqual({ kind: "profile", url: "https://gw.example.com/api/users/p1/avatar" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,75 @@
|
|||||||
import { formatSenderLabel, type SenderIdentity } from "./chat/sender-label.ts";
|
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 & {
|
export type IdentityAvatarInput = SenderIdentity & {
|
||||||
profileAvatarUrl?: string;
|
profileAvatarUrl?: string;
|
||||||
/**
|
|
||||||
* Base URL of a gateway-side avatar proxy (same-origin). When absent, the
|
|
||||||
* email-hash avatar tier is disabled entirely: the browser must never
|
|
||||||
* contact a third-party avatar host directly, because that leaks a
|
|
||||||
* dictionary-recoverable sender email hash plus the viewer's IP per render.
|
|
||||||
*/
|
|
||||||
avatarProxyBaseUrl?: 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 =
|
export type ResolvedIdentityAvatar =
|
||||||
| { kind: "profile"; url: string }
|
| { kind: "profile"; url: string }
|
||||||
| { kind: "gravatar"; url: string }
|
| { kind: "gravatar"; url: string }
|
||||||
| { kind: "initials"; initials: string; colorSeed: number };
|
| { kind: "initials"; initials: string; colorSeed: number };
|
||||||
|
|
||||||
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+$/;
|
|
||||||
|
|
||||||
function initialsFromLabel(label: string): string {
|
function initialsFromLabel(label: string): string {
|
||||||
const words = label.trim().split(/\s+/u).filter(Boolean).slice(0, 2);
|
const words = label.trim().split(/\s+/u).filter(Boolean).slice(0, 2);
|
||||||
const initials = words.map((word) => Array.from(word)[0] ?? "").join("");
|
const initials = words.map((word) => Array.from(word)[0] ?? "").join("");
|
||||||
return initials.toUpperCase() || "?";
|
return initials.toUpperCase() || "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORIGIN_PROBE = "https://origin-probe.invalid";
|
|
||||||
|
|
||||||
/** True only when the value resolves inside the embedding origin for any base. */
|
|
||||||
function isOriginRelativePath(value: string): boolean {
|
|
||||||
try {
|
|
||||||
return new URL(value, ORIGIN_PROBE).origin === ORIGIN_PROBE;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stableColorSeed(value: string): number {
|
function stableColorSeed(value: string): number {
|
||||||
let hash = 0x811c9dc5;
|
let hash = 0x811c9dc5;
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
@@ -44,47 +79,10 @@ function stableColorSeed(value: string): number {
|
|||||||
return hash >>> 0;
|
return hash >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sha256Hex(value: string): Promise<string | null> {
|
export function resolveAvatarInitials(
|
||||||
if (!globalThis.crypto?.subtle) {
|
input: IdentityAvatarInput,
|
||||||
return null;
|
): Extract<ResolvedIdentityAvatar, { kind: "initials" }> {
|
||||||
}
|
|
||||||
try {
|
|
||||||
const digest = await globalThis.crypto.subtle.digest(
|
|
||||||
"SHA-256",
|
|
||||||
new TextEncoder().encode(value),
|
|
||||||
);
|
|
||||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves profile, Gravatar, then deterministic initials without fetching profile data. */
|
|
||||||
export async function resolveAvatar(input: IdentityAvatarInput): Promise<ResolvedIdentityAvatar> {
|
|
||||||
const profileAvatarUrl = input.profileAvatarUrl?.trim();
|
|
||||||
// Same-origin only: profile URLs arrive via sender metadata, and an
|
|
||||||
// absolute URL would let a sender make every viewing browser contact an
|
|
||||||
// arbitrary host. Validate with the URL parser (not string prefixes) so
|
|
||||||
// browser normalization quirks — backslashes as slashes, stripped tab or
|
|
||||||
// newline characters — cannot smuggle in a cross-origin target.
|
|
||||||
if (profileAvatarUrl && isOriginRelativePath(profileAvatarUrl)) {
|
|
||||||
return { kind: "profile", url: profileAvatarUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = input.id?.trim();
|
const id = input.id?.trim();
|
||||||
const proxyBase = input.avatarProxyBaseUrl?.trim().replace(/\/+$/, "");
|
|
||||||
if (proxyBase && id && EMAIL_PATTERN.test(id)) {
|
|
||||||
const hash = await sha256Hex(id.toLowerCase());
|
|
||||||
if (hash) {
|
|
||||||
return {
|
|
||||||
kind: "gravatar",
|
|
||||||
url: `${proxyBase}/${hash}?s=64`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = formatSenderLabel(input) ?? "?";
|
const label = formatSenderLabel(input) ?? "?";
|
||||||
return {
|
return {
|
||||||
kind: "initials",
|
kind: "initials",
|
||||||
@@ -92,3 +90,24 @@ export async function resolveAvatar(input: IdentityAvatarInput): Promise<Resolve
|
|||||||
colorSeed: stableColorSeed(id || 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3142,6 +3142,7 @@ class ChatPane extends OpenClawLightDomElement {
|
|||||||
chatMessageMaxWidth: state.chatMessageMaxWidth,
|
chatMessageMaxWidth: state.chatMessageMaxWidth,
|
||||||
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never),
|
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never),
|
||||||
basePath: state.basePath,
|
basePath: state.basePath,
|
||||||
|
gatewayUrl: state.settings.gatewayUrl,
|
||||||
};
|
};
|
||||||
const chat = renderChat(props);
|
const chat = renderChat(props);
|
||||||
const content =
|
const content =
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export type ChatProps = {
|
|||||||
onSplitRatioChange?: (ratio: number) => void;
|
onSplitRatioChange?: (ratio: number) => void;
|
||||||
onChatScroll?: (event: Event) => void;
|
onChatScroll?: (event: Event) => void;
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
gatewayUrl?: string;
|
||||||
composerControls?: TemplateResult | typeof nothing;
|
composerControls?: TemplateResult | typeof nothing;
|
||||||
replyTarget?: {
|
replyTarget?: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -335,6 +336,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
questionPrompts: props.gatewayQuestionPrompts,
|
questionPrompts: props.gatewayQuestionPrompts,
|
||||||
sessions: props.sessions,
|
sessions: props.sessions,
|
||||||
sessionHost: props.sessionHost,
|
sessionHost: props.sessionHost,
|
||||||
|
gatewayUrl: props.gatewayUrl,
|
||||||
assistantName: props.assistantName,
|
assistantName: props.assistantName,
|
||||||
assistantAvatar: props.assistantAvatar,
|
assistantAvatar: props.assistantAvatar,
|
||||||
assistantAvatarUrl: props.assistantAvatarUrl,
|
assistantAvatarUrl: props.assistantAvatarUrl,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { html, nothing, type TemplateResult } from "lit";
|
import { html, nothing, type TemplateResult } from "lit";
|
||||||
import { until } from "lit/directives/until.js";
|
|
||||||
import { formatSenderLabel } from "../../../lib/chat/sender-label.ts";
|
import { formatSenderLabel } from "../../../lib/chat/sender-label.ts";
|
||||||
import {
|
import {
|
||||||
resolveAvatar,
|
resolveAvatar,
|
||||||
|
resolveAvatarInitials,
|
||||||
type IdentityAvatarInput,
|
type IdentityAvatarInput,
|
||||||
type ResolvedIdentityAvatar,
|
type ResolvedIdentityAvatar,
|
||||||
} from "../../../lib/identity-avatar.ts";
|
} from "../../../lib/identity-avatar.ts";
|
||||||
@@ -63,18 +63,15 @@ export function renderChatAuthorAvatar(
|
|||||||
if (!sender || !label) {
|
if (!sender || !label) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
const resolved = Promise.all([resolveAvatar(sender), resolveAvatar({ name: label })]).then(
|
const fallback = resolveAvatarInitials(sender);
|
||||||
([avatar, fallback]) => {
|
const avatar = resolveAvatar(sender);
|
||||||
const initials =
|
const resolved =
|
||||||
fallback.kind === "initials"
|
avatar.kind === "initials"
|
||||||
? fallback
|
? renderInitialsAvatar(avatar)
|
||||||
: ({ kind: "initials", initials: "?", colorSeed: 0 } as const);
|
: renderResolvedAvatar(avatar, fallback);
|
||||||
return renderResolvedAvatar(avatar, initials);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return html`
|
return html`
|
||||||
<span class="chat-author-avatar" role="img" aria-label=${label} title=${label}>
|
<span class="chat-author-avatar" role="img" aria-label=${label} title=${label}>
|
||||||
${until(resolved, nothing)}
|
${resolved}
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import * as markdown from "../../../components/markdown.ts";
|
import * as markdown from "../../../components/markdown.ts";
|
||||||
import type { MessageGroup } from "../../../lib/chat/chat-types.ts";
|
import type { MessageGroup } from "../../../lib/chat/chat-types.ts";
|
||||||
import { setUiTimeFormatPreference } from "../../../lib/format.ts";
|
import { setUiTimeFormatPreference } from "../../../lib/format.ts";
|
||||||
|
import { setAvatarGatewayOrigin } from "../../../lib/identity-avatar.ts";
|
||||||
import * as localStorageModule from "../../../local-storage.ts";
|
import * as localStorageModule from "../../../local-storage.ts";
|
||||||
import * as chatAvatar from "../chat-avatar.ts";
|
import * as chatAvatar from "../chat-avatar.ts";
|
||||||
import { renderMessageGroup, renderStreamGroup } from "./chat-message.ts";
|
import { renderMessageGroup, renderStreamGroup } from "./chat-message.ts";
|
||||||
@@ -461,6 +462,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
clearDeleteConfirmSkip();
|
clearDeleteConfirmSkip();
|
||||||
setUiTimeFormatPreference("auto");
|
setUiTimeFormatPreference("auto");
|
||||||
|
setAvatarGatewayOrigin(null);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -1225,13 +1227,14 @@ describe("grouped chat rendering", () => {
|
|||||||
expect(avatar?.tagName).toBe("DIV");
|
expect(avatar?.tagName).toBe("DIV");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a durable sender label in the user message metadata", () => {
|
it("renders a durable sender label and avatar chip in user message metadata", async () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const group: MessageGroup = {
|
const group: MessageGroup = {
|
||||||
kind: "group",
|
kind: "group",
|
||||||
key: "attributed-user-group",
|
key: "attributed-user-group",
|
||||||
role: "user",
|
role: "user",
|
||||||
senderLabel: "alice",
|
senderLabel: "alice",
|
||||||
|
sender: { id: "profile-1", name: "Alice Example" },
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "attributed-user-message",
|
key: "attributed-user-message",
|
||||||
@@ -1256,6 +1259,11 @@ describe("grouped chat rendering", () => {
|
|||||||
expect(
|
expect(
|
||||||
container.querySelector<HTMLElement>(".chat-group.user .chat-sender-name")?.textContent,
|
container.querySelector<HTMLElement>(".chat-group.user .chat-sender-name")?.textContent,
|
||||||
).toBe("alice");
|
).toBe("alice");
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>(".chat-author-avatar__initials")?.textContent?.trim(),
|
||||||
|
).toBe("AE");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an author avatar for a user group with sender identity", async () => {
|
it("renders an author avatar for a user group with sender identity", async () => {
|
||||||
@@ -1305,7 +1313,7 @@ describe("grouped chat rendering", () => {
|
|||||||
senderLabel: "alice",
|
senderLabel: "alice",
|
||||||
// profileAvatarUrl exercises the img tier; bare emails render initials
|
// profileAvatarUrl exercises the img tier; bare emails render initials
|
||||||
// only (no third-party avatar fetch without a gateway proxy base).
|
// only (no third-party avatar fetch without a gateway proxy base).
|
||||||
sender: { id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" },
|
sender: { id: "alice@example.com", profileAvatarUrl: "/api/users/alice/avatar" },
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "gravatar-message",
|
key: "gravatar-message",
|
||||||
@@ -1327,6 +1335,7 @@ describe("grouped chat rendering", () => {
|
|||||||
const image = await vi.waitFor(() => {
|
const image = await vi.waitFor(() => {
|
||||||
const result = container.querySelector<HTMLImageElement>(".chat-author-avatar__image");
|
const result = container.querySelector<HTMLImageElement>(".chat-author-avatar__image");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.getAttribute("src")).toBe("/api/users/alice/avatar");
|
||||||
return result!;
|
return result!;
|
||||||
});
|
});
|
||||||
image.dispatchEvent(new Event("error"));
|
image.dispatchEvent(new Event("error"));
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ type ChatThreadProps = {
|
|||||||
/** Host context resolving global-alias session keys (scope=global fleets). */
|
/** Host context resolving global-alias session keys (scope=global fleets). */
|
||||||
/** Includes assistantAgentId so bare-global welcome recents scope to the selected agent. */
|
/** Includes assistantAgentId so bare-global welcome recents scope to the selected agent. */
|
||||||
sessionHost?: UiSessionDefaultsHost | null;
|
sessionHost?: UiSessionDefaultsHost | null;
|
||||||
|
gatewayUrl?: string;
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
assistantAvatar: string | null;
|
assistantAvatar: string | null;
|
||||||
assistantAvatarUrl?: string | null;
|
assistantAvatarUrl?: string | null;
|
||||||
@@ -1254,6 +1255,7 @@ function renderChatThreadContents(
|
|||||||
Math.floor(Date.now() / 60_000),
|
Math.floor(Date.now() / 60_000),
|
||||||
getToolTitlesVersion(),
|
getToolTitlesVersion(),
|
||||||
props.sessionKey,
|
props.sessionKey,
|
||||||
|
props.gatewayUrl,
|
||||||
props.fullMessageAgentId,
|
props.fullMessageAgentId,
|
||||||
showReasoning,
|
showReasoning,
|
||||||
props.showToolCalls,
|
props.showToolCalls,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("profile avatar processing", () => {
|
|||||||
).rejects.toMatchObject({ code: "too-large" } satisfies Partial<ProfileAvatarError>);
|
).rejects.toMatchObject({ code: "too-large" } satisfies Partial<ProfileAvatarError>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not upscale smaller uploads through the upload surface", async () => {
|
it("center-crops without upscaling smaller uploads through the upload surface", async () => {
|
||||||
class StubImage {
|
class StubImage {
|
||||||
decoding = "auto";
|
decoding = "auto";
|
||||||
src = "";
|
src = "";
|
||||||
@@ -67,7 +67,7 @@ describe("profile avatar processing", () => {
|
|||||||
|
|
||||||
await processProfileAvatar(new File(["source"], "avatar.png", { type: "image/png" }));
|
await processProfileAvatar(new File(["source"], "avatar.png", { type: "image/png" }));
|
||||||
|
|
||||||
expect(drawImage).toHaveBeenCalledWith(expect.any(StubImage), 0, 0, 80, 60);
|
expect(drawImage).toHaveBeenCalledWith(expect.any(StubImage), 10, 0, 60, 60, 0, 0, 60, 60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("decodes, downsizes, and encodes an uploaded image before the RPC payload", async () => {
|
it("decodes, downsizes, and encodes an uploaded image before the RPC payload", async () => {
|
||||||
@@ -98,7 +98,7 @@ describe("profile avatar processing", () => {
|
|||||||
new File(["source"], "avatar.jpg", { type: "image/jpeg" }),
|
new File(["source"], "avatar.jpg", { type: "image/jpeg" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(drawImage).toHaveBeenCalledWith(expect.any(StubImage), 0, 0, 256, 128);
|
expect(drawImage).toHaveBeenCalledWith(expect.any(StubImage), 256, 0, 512, 512, 0, 0, 512, 512);
|
||||||
expect(result).toEqual({ mime: "image/png", avatarBase64: "AQID", byteLength: 3 });
|
expect(result).toEqual({ mime: "image/png", avatarBase64: "AQID", byteLength: 3 });
|
||||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:avatar");
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:avatar");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const MAX_PROFILE_AVATAR_EDGE = 256;
|
const MAX_PROFILE_AVATAR_EDGE = 512;
|
||||||
const MAX_PROFILE_AVATAR_BYTES = 512 * 1024;
|
const MAX_PROFILE_AVATAR_BYTES = 512 * 1024;
|
||||||
|
const MAX_PROFILE_AVATAR_BASE64_CHARS = 700_000;
|
||||||
const MAX_PROFILE_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
|
const MAX_PROFILE_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
type ProcessedProfileAvatar = {
|
type ProcessedProfileAvatar = {
|
||||||
@@ -19,10 +20,13 @@ function fitAvatarDimensions(width: number, height: number) {
|
|||||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
throw new ProfileAvatarError("invalid-image");
|
throw new ProfileAvatarError("invalid-image");
|
||||||
}
|
}
|
||||||
const scale = Math.min(1, MAX_PROFILE_AVATAR_EDGE / Math.max(width, height));
|
const sourceEdge = Math.min(width, height);
|
||||||
|
const scale = Math.min(1, MAX_PROFILE_AVATAR_EDGE / sourceEdge);
|
||||||
return {
|
return {
|
||||||
width: Math.max(1, Math.round(width * scale)),
|
sourceEdge,
|
||||||
height: Math.max(1, Math.round(height * scale)),
|
sourceX: Math.max(0, Math.round((width - sourceEdge) / 2)),
|
||||||
|
sourceY: Math.max(0, Math.round((height - sourceEdge) / 2)),
|
||||||
|
edge: Math.max(1, Math.round(sourceEdge * scale)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +71,11 @@ async function encodeAvatarBlob(
|
|||||||
throw new ProfileAvatarError("too-large");
|
throw new ProfileAvatarError("too-large");
|
||||||
}
|
}
|
||||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
return { mime, avatarBase64: bytesToBase64(bytes), byteLength: bytes.byteLength };
|
const avatarBase64 = bytesToBase64(bytes);
|
||||||
|
if (avatarBase64.length > MAX_PROFILE_AVATAR_BASE64_CHARS) {
|
||||||
|
throw new ProfileAvatarError("too-large");
|
||||||
|
}
|
||||||
|
return { mime, avatarBase64, byteLength: bytes.byteLength };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processProfileAvatar(file: File): Promise<ProcessedProfileAvatar> {
|
export async function processProfileAvatar(file: File): Promise<ProcessedProfileAvatar> {
|
||||||
@@ -80,13 +88,23 @@ export async function processProfileAvatar(file: File): Promise<ProcessedProfile
|
|||||||
const image = await loadImage(file);
|
const image = await loadImage(file);
|
||||||
const dimensions = fitAvatarDimensions(image.naturalWidth, image.naturalHeight);
|
const dimensions = fitAvatarDimensions(image.naturalWidth, image.naturalHeight);
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = dimensions.width;
|
canvas.width = dimensions.edge;
|
||||||
canvas.height = dimensions.height;
|
canvas.height = dimensions.edge;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new ProfileAvatarError("invalid-image");
|
throw new ProfileAvatarError("invalid-image");
|
||||||
}
|
}
|
||||||
context.drawImage(image, 0, 0, dimensions.width, dimensions.height);
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
dimensions.sourceX,
|
||||||
|
dimensions.sourceY,
|
||||||
|
dimensions.sourceEdge,
|
||||||
|
dimensions.sourceEdge,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
dimensions.edge,
|
||||||
|
dimensions.edge,
|
||||||
|
);
|
||||||
|
|
||||||
const preferredMime = file.type === "image/webp" ? "image/webp" : "image/png";
|
const preferredMime = file.type === "image/webp" ? "image/webp" : "image/png";
|
||||||
let mime: ProcessedProfileAvatar["mime"] = preferredMime;
|
let mime: ProcessedProfileAvatar["mime"] = preferredMime;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { render } from "lit";
|
import { render } from "lit";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { UserProfile } from "../../../../packages/gateway-protocol/src/index.ts";
|
import type { UserProfile } from "../../../../packages/gateway-protocol/src/index.ts";
|
||||||
|
import { setAvatarGatewayOrigin } from "../../lib/identity-avatar.ts";
|
||||||
import { renderIdentitySection } from "./identity-section.ts";
|
import { renderIdentitySection } from "./identity-section.ts";
|
||||||
|
|
||||||
type IdentitySectionProps = Parameters<typeof renderIdentitySection>[0];
|
type IdentitySectionProps = Parameters<typeof renderIdentitySection>[0];
|
||||||
@@ -35,6 +36,8 @@ function createProps(overrides: Partial<IdentitySectionProps> = {}): IdentitySec
|
|||||||
describe("renderIdentitySection", () => {
|
describe("renderIdentitySection", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
|
setAvatarGatewayOrigin(null);
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the resolved profile through settings rows and the shared avatar", async () => {
|
it("renders the resolved profile through settings rows and the shared avatar", async () => {
|
||||||
@@ -42,7 +45,13 @@ describe("renderIdentitySection", () => {
|
|||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
render(renderIdentitySection(createProps()), container);
|
render(renderIdentitySection(createProps()), container);
|
||||||
const avatar = container.querySelector<HTMLElement>("openclaw-viewer-avatar");
|
const avatar = container.querySelector<HTMLElement>("openclaw-viewer-avatar");
|
||||||
await (avatar as (HTMLElement & { updateComplete?: Promise<unknown> }) | null)?.updateComplete;
|
await vi.waitFor(async () => {
|
||||||
|
await (avatar as (HTMLElement & { updateComplete?: Promise<unknown> }) | null)
|
||||||
|
?.updateComplete;
|
||||||
|
expect(avatar?.querySelector("img")?.getAttribute("src")).toBe(
|
||||||
|
"/api/users/profile-1/avatar?v=2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(container.querySelector("#settings-profile-identity")).not.toBeNull();
|
expect(container.querySelector("#settings-profile-identity")).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
@@ -50,13 +59,10 @@ describe("renderIdentitySection", () => {
|
|||||||
node.textContent?.trim(),
|
node.textContent?.trim(),
|
||||||
),
|
),
|
||||||
).toEqual(["Avatar", "Display name", "Linked emails"]);
|
).toEqual(["Avatar", "Display name", "Linked emails"]);
|
||||||
expect(avatar?.querySelector("img")?.getAttribute("src")).toBe(
|
|
||||||
"/api/users/profile-1/avatar?v=2",
|
|
||||||
);
|
|
||||||
expect(container.textContent).toContain("ada@example.test, ada@work.test");
|
expect(container.textContent).toContain("ada@example.test, ada@work.test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the linked email fallback when the profile has no upload", async () => {
|
it("falls back to initials when no same-origin avatar route is available", async () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
render(
|
render(
|
||||||
@@ -69,13 +75,16 @@ describe("renderIdentitySection", () => {
|
|||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
const avatar = container.querySelector<HTMLElement>("openclaw-viewer-avatar") as
|
const avatar = container.querySelector<HTMLElement>("openclaw-viewer-avatar") as
|
||||||
| (HTMLElement & { updateComplete: Promise<unknown> })
|
| (HTMLElement & { updateComplete?: Promise<unknown> })
|
||||||
| null;
|
| null;
|
||||||
|
await avatar?.updateComplete;
|
||||||
|
|
||||||
await vi.waitFor(() => expect(avatar?.querySelector("img")).not.toBeNull());
|
// The gateway route (userProfileAvatarUrl) serves the Gravatar fallback
|
||||||
expect(avatar?.querySelector("img")?.getAttribute("src")).toMatch(
|
// server-side and stays same-origin under the Control UI CSP. When no route
|
||||||
/^https:\/\/gravatar\.com\/avatar\/[a-f0-9]{64}\?d=404&s=128$/u,
|
// is available — e.g. a cross-origin gateway returns null — the chip shows
|
||||||
);
|
// deterministic initials rather than a CSP-blocked direct gravatar.com image.
|
||||||
|
expect(avatar?.querySelector("img")).toBeNull();
|
||||||
|
expect(avatar?.textContent?.trim()).toBe("AL");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edits and saves the display name with the standard input pattern", () => {
|
it("edits and saves the display name with the standard input pattern", () => {
|
||||||
|
|||||||
@@ -447,13 +447,13 @@ export class ProfilePage extends OpenClawLightDomElement {
|
|||||||
)}
|
)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
const avatarUrl = this.ownProfile.hasAvatar
|
// The gateway route serves an uploaded avatar first and its private Gravatar
|
||||||
? userProfileAvatarUrl(
|
// fallback second, while a 404 still leaves the viewer-avatar initials visible.
|
||||||
this.context.gateway.connection.gatewayUrl,
|
const avatarUrl = userProfileAvatarUrl(
|
||||||
this.ownProfile.id,
|
this.context.gateway.connection.gatewayUrl,
|
||||||
this.ownProfile.updatedAt,
|
this.ownProfile.id,
|
||||||
)
|
this.ownProfile.updatedAt,
|
||||||
: null;
|
);
|
||||||
return renderIdentitySection({
|
return renderIdentitySection({
|
||||||
profile: this.ownProfile,
|
profile: this.ownProfile,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
|||||||
@@ -1919,6 +1919,18 @@ html.openclaw-native-macos
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-avatar__fallback {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-avatar.is-fallback > img {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-avatar.is-fallback > .viewer-avatar__fallback {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-avatar > img {
|
.viewer-avatar > img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("AppSidebar update card wiring", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AppSidebar viewer presence", () => {
|
describe("AppSidebar viewer presence", () => {
|
||||||
it("renders the self user's Gravatar in the footer identity chip", async () => {
|
it("renders the self user's avatar route in the footer identity chip", async () => {
|
||||||
const client = { instanceId: "self-instance" } as GatewayBrowserClient;
|
const client = { instanceId: "self-instance" } as GatewayBrowserClient;
|
||||||
const gatewayHarness = createGatewayHarness(client);
|
const gatewayHarness = createGatewayHarness(client);
|
||||||
const { sidebar } = await mountSidebar(
|
const { sidebar } = await mountSidebar(
|
||||||
@@ -58,7 +58,16 @@ describe("AppSidebar viewer presence", () => {
|
|||||||
presence: [
|
presence: [
|
||||||
{
|
{
|
||||||
instanceId: "self-instance",
|
instanceId: "self-instance",
|
||||||
user: { id: "00-self", email: "test@example.com", name: "Self User" },
|
// Presence publishes the canonical gateway avatar route; the gateway
|
||||||
|
// serves an uploaded avatar or its Gravatar fallback behind it, so the
|
||||||
|
// chip renders that same-origin route (CSP-safe) rather than a direct
|
||||||
|
// gravatar.com URL the Control UI CSP would block.
|
||||||
|
user: {
|
||||||
|
id: "00-self",
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Self User",
|
||||||
|
avatarUrl: "/api/users/00-self/avatar?v=7",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -67,9 +76,7 @@ describe("AppSidebar viewer presence", () => {
|
|||||||
const avatar = sidebar.querySelector<HTMLImageElement>(
|
const avatar = sidebar.querySelector<HTMLImageElement>(
|
||||||
".sidebar-footer-bar__identity openclaw-viewer-avatar img",
|
".sidebar-footer-bar__identity openclaw-viewer-avatar img",
|
||||||
);
|
);
|
||||||
expect(avatar?.src).toBe(
|
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=7");
|
||||||
"https://gravatar.com/avatar/973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b?d=404&s=128",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +104,9 @@ describe("AppSidebar viewer presence", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
instanceId: "alice-1",
|
instanceId: "alice-1",
|
||||||
user: { id: "alice", name: "Alice", avatarUrl: "https://example.test/alice.png" },
|
// Presence publishes avatars as the canonical gateway route; the
|
||||||
|
// resolver renders only that, falling back to initials otherwise.
|
||||||
|
user: { id: "alice", name: "Alice", avatarUrl: "/api/users/alice/avatar" },
|
||||||
watchedSessions: ["agent:main:work"],
|
watchedSessions: ["agent:main:work"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user