mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user