Fix inline raster avatars in agent catalog (#3581)

## Summary

- render existing shared personas whose catalog avatar is a bounded
inline PNG, JPEG, GIF, or WebP data URL
- keep rejecting arbitrary, malformed, unsupported, and oversized
`data:` URLs
- preserve hosted relay URLs as the forward format; catalog browsing
remains read-only

## Root cause

Paul's live shared kind:30175 head contains a 144,878-character
`data:image/png;base64,...` avatar. The catalog projection accepted
HTTP(S) URLs and bounded percent-encoded SVG emoji avatars only, so it
projected Paul's avatar to `null` before `ProfileAvatar` rendered it.
The owner still saw the local persona avatar, producing the reported
owner/viewer mismatch.

This patch accepts only four raster MIME types with strict base64 shape
and a 256 KiB total URL cap at the existing catalog parsing boundary. It
repairs already-signed heads such as Paul without viewer-side uploads or
publication side effects.

Hosted media remains the canonical forward path. #3578 uploads inline
raster avatars during snapshot import, preventing the known source from
creating future inline persona/profile values; existing signed catalog
heads still need this compatibility path until their owners republish.

## Agent instruction finding

The catalog publishes `AgentDefinition.system_prompt` verbatim as the
user-authored **Agent instruction**, as documented by the sharing UI and
NIP-AP. No Buzz base/core/runtime prompt is concatenated in the publish,
catalog, or import path. This PR therefore does not remove authored
instructions and accidentally strip copied agents of their behavior.

## Validation

- targeted `personaCatalogRelay.test.mjs`: 24 passed
- Desktop typecheck: passed
- pre-push Desktop frontend suite: 3,771 passed
- pre-push Desktop checks: passed
- `git diff --check`: passed
- independent review: no blockers, 9.4/10

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-29 11:36:12 -06:00
committed by GitHub
parent ddd468723a
commit 7e9b77f72d
2 changed files with 48 additions and 1 deletions

View File

@@ -205,6 +205,30 @@ test("test_non_svg_data_avatar_is_rejected", () => {
assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null);
});
test("test_legacy_inline_raster_avatar_survives_the_catalog", () => {
for (const mime of ["png", "jpeg", "gif", "webp"]) {
const avatar = `data:image/${mime};base64,iVBORw0KGgo=`;
assert.equal(catalogAvatarUrl(avatar), avatar);
}
});
test("test_inline_raster_avatar_rejects_unbounded_or_malformed_payloads", () => {
const prefix = "data:image/png;base64,";
const payloadLength = 256 * 1_024 - prefix.length;
const validPayloadLength = payloadLength - (payloadLength % 4);
const withinCap = `${prefix}${"a".repeat(validPayloadLength - 2)}==`;
assert.ok(withinCap.length <= 256 * 1_024);
assert.equal(catalogAvatarUrl(withinCap), withinCap);
assert.equal(
catalogAvatarUrl(
`${withinCap}${"a".repeat(256 * 1_024 - withinCap.length + 1)}`,
),
null,
);
assert.equal(catalogAvatarUrl("data:image/png;base64,not base64"), null);
assert.equal(catalogAvatarUrl("data:image/bmp;base64,aA=="), null);
});
test("test_oversized_inline_svg_avatar_is_rejected", () => {
const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`;
assert.equal(withinCap.length, 8_192);

View File

@@ -93,6 +93,16 @@ function isSafeHttpUrl(value: unknown): value is string {
const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,";
const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192;
/**
* Shared persona heads can carry an uploaded avatar as an inline raster. Keep
* those self-contained images renderable without accepting arbitrary `data:`
* URLs: only the raster MIME types browsers decode in `<img>`, strict base64
* shape, and a bound no larger than the relay's event-content ceiling.
*/
const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024;
const INLINE_RASTER_AVATAR_RE =
/^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u;
function isInlineSvgAvatar(value: unknown): value is string {
return (
typeof value === "string" &&
@@ -101,6 +111,17 @@ function isInlineSvgAvatar(value: unknown): value is string {
);
}
function isInlineRasterAvatar(value: unknown): value is string {
if (
typeof value !== "string" ||
value.length > MAX_INLINE_RASTER_AVATAR_LENGTH
) {
return false;
}
const match = INLINE_RASTER_AVATAR_RE.exec(value);
return match !== null && (match[1]?.length ?? 0) % 4 === 0;
}
function optionalString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
@@ -121,7 +142,9 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null {
}
const avatarUrl =
isSafeHttpUrl(parsed.avatar_url) || isInlineSvgAvatar(parsed.avatar_url)
isSafeHttpUrl(parsed.avatar_url) ||
isInlineSvgAvatar(parsed.avatar_url) ||
isInlineRasterAvatar(parsed.avatar_url)
? parsed.avatar_url
: null;
const namePool = Array.isArray(parsed.name_pool)