diff --git a/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts b/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts index b3ed21d0..29d0ec09 100644 --- a/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts +++ b/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts @@ -16,6 +16,15 @@ import { type MockStoreBindings, } from '../test-utils' +/** Base64-encode a small byte array, for crafting avatar payloads with real magic bytes. */ +const toBase64 = (bytes: number[]) => btoa(String.fromCharCode(...bytes)) + +// Minimal payloads whose leading bytes identify a non-png image format. +const GIF_BASE64 = toBase64([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00]) // "GIF89a" +const WEBP_BASE64 = toBase64([ + 0x52, 0x49, 0x46, 0x46, 0x1a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, +]) // "RIFF....WEBP" + let mockXmppClientInstance: MockXmppClient // Use vi.hoisted to create the mock factory at hoist time @@ -295,6 +304,73 @@ describe('XMPPClient Own Avatar', () => { expect(mockXmppClientInstance.iqCaller.request).toHaveBeenCalledTimes(1) expect(emitSDKSpy).toHaveBeenCalledWith('connection:own-avatar', { avatar: 'blob:cached-existing', hash: 'abc123hash' }) }) + + it('caches the own avatar with its sniffed type, overriding a mislabeled ', async () => { + mockXmppClientInstance.iqCaller.request.mockClear() + + const { getCachedAvatar, cacheAvatar } = await import('../../utils/avatarCache') + vi.mocked(getCachedAvatar).mockResolvedValue(null) + vi.mocked(cacheAvatar).mockResolvedValue('blob:own-gif') + + // Metadata advertises image/png... + const metadataResponse = createMockElement('iq', { type: 'result' }, [ + { + name: 'pubsub', + attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, + children: [ + { + name: 'items', + attrs: { node: 'urn:xmpp:avatar:metadata' }, + children: [ + { + name: 'item', + attrs: { id: 'own-hash' }, + children: [ + { + name: 'metadata', + attrs: { xmlns: 'urn:xmpp:avatar:metadata' }, + children: [ + { name: 'info', attrs: { id: 'own-hash', type: 'image/png', bytes: '128' } }, + ], + }, + ], + }, + ], + }, + ], + }, + ]) + // ...but the actual bytes are a GIF. + const dataResponse = createMockElement('iq', { type: 'result' }, [ + { + name: 'pubsub', + attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, + children: [ + { + name: 'items', + attrs: { node: 'urn:xmpp:avatar:data' }, + children: [ + { + name: 'item', + attrs: { id: 'own-hash' }, + children: [ + { name: 'data', attrs: { xmlns: 'urn:xmpp:avatar:data' }, text: GIF_BASE64 }, + ], + }, + ], + }, + ], + }, + ]) + mockXmppClientInstance.iqCaller.request + .mockResolvedValueOnce(metadataResponse) + .mockResolvedValueOnce(dataResponse) + + await xmppClient.profile.fetchOwnAvatar() + + // The advertised image/png is only a fallback; the GIF bytes win. + expect(cacheAvatar).toHaveBeenCalledWith('own-hash', GIF_BASE64, 'image/gif') + }) }) describe('fetchContactAvatarMetadata', () => { @@ -1455,6 +1531,35 @@ describe('XMPPClient Own Avatar', () => { avatarHash: 'avatar-hash', }) }) + + it('caches a non-png occupant PEP avatar with the MIME type sniffed from its bytes', async () => { + mockXmppClientInstance.iqCaller.request.mockClear() + + const { getCachedAvatar, cacheAvatar } = await import('../../utils/avatarCache') + vi.mocked(getCachedAvatar).mockResolvedValueOnce(null) + vi.mocked(cacheAvatar).mockResolvedValue('blob:webp-occupant') + + // Occupant PEP data node carries WebP bytes (no advertised type on the wire). + const pepResponse = createMockElement('iq', { type: 'result' }, [ + { name: 'pubsub', attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, children: [ + { name: 'items', children: [ + { name: 'item', attrs: { id: 'webp-hash' }, children: [ + { name: 'data', attrs: { xmlns: 'urn:xmpp:avatar:data' }, text: WEBP_BASE64 }, + ] }, + ] }, + ] }, + ]) + mockXmppClientInstance.iqCaller.request.mockResolvedValueOnce(pepResponse) + + await xmppClient.profile.fetchOccupantAvatar( + 'room@conference.example.com', + 'WebpUser', + 'webp-hash', + 'webpuser@example.com' + ) + + expect(cacheAvatar).toHaveBeenCalledWith('webp-hash', WEBP_BASE64, 'image/webp') + }) }) describe('fetchOccupantAvatar saves JID→hash mapping', () => { @@ -1680,6 +1785,44 @@ describe('XMPPClient Own Avatar', () => { expect(cacheAvatar).toHaveBeenCalledWith('new-hash', 'iVBORw0KGgo=', 'image/png') expect(saveAvatarHash).toHaveBeenCalledWith('contact@example.com', 'new-hash', 'contact') }) + + it('caches a non-png PEP avatar with the MIME type sniffed from its bytes', async () => { + mockXmppClientInstance.iqCaller.request.mockClear() + emitSDKSpy.mockClear() + + const { getCachedAvatar, cacheAvatar } = await import('../../utils/avatarCache') + vi.mocked(getCachedAvatar).mockResolvedValueOnce(null) + vi.mocked(cacheAvatar).mockResolvedValueOnce('blob:gif-avatar') + + // PEP data node carries GIF bytes (XEP-0084 data responses have no type). + const dataResponse = createMockElement('iq', { type: 'result' }, [ + { + name: 'pubsub', + attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, + children: [ + { + name: 'items', + attrs: { node: 'urn:xmpp:avatar:data' }, + children: [ + { + name: 'item', + attrs: { id: 'gif-hash' }, + children: [ + { name: 'data', attrs: { xmlns: 'urn:xmpp:avatar:data' }, text: GIF_BASE64 }, + ], + }, + ], + }, + ], + }, + ]) + mockXmppClientInstance.iqCaller.request.mockResolvedValueOnce(dataResponse) + + await xmppClient.profile.fetchAvatarData('contact@example.com', 'gif-hash') + + // Cached as image/gif, not the old hardcoded image/png. + expect(cacheAvatar).toHaveBeenCalledWith('gif-hash', GIF_BASE64, 'image/gif') + }) }) describe('avatarMetadataUpdate event dedup', () => { diff --git a/packages/fluux-sdk/src/core/modules/Profile.ts b/packages/fluux-sdk/src/core/modules/Profile.ts index ac91a47c..2348f684 100644 --- a/packages/fluux-sdk/src/core/modules/Profile.ts +++ b/packages/fluux-sdk/src/core/modules/Profile.ts @@ -5,6 +5,7 @@ import { getBareJid, getLocalPart, getDomain } from '../jid' import type { VCardInfo } from '../types/roster' import { generateUUID } from '../../utils/uuid' import { getCachedAvatar, getAvatarHash, cacheAvatar, saveAvatarHash, getAllAvatarHashes, hasNoAvatar, markNoAvatar, clearNoAvatar, refreshAllBlobUrls, isPepForbiddenDomain, markPepForbiddenDomain, loadPepForbiddenDomains } from '../../utils/avatarCache' +import { sniffImageMimeType } from '../../utils/imageType' import { NS_PUBSUB, NS_NICK, @@ -86,8 +87,12 @@ export class Profile extends BaseModule { const data = result.getChild('pubsub', NS_PUBSUB)?.getChild('items')?.getChild('item')?.getChild('data', 'urn:xmpp:avatar:data')?.text() if (data) { + // XEP-0084 data responses carry no MIME type, so sniff the bytes rather + // than assume PNG — otherwise animated GIF/WebP/APNG avatars get a Blob + // typed image/png and any consumer trusting blob.type is misled. + const mimeType = sniffImageMimeType(data) ?? 'image/png' // Cache to IndexedDB and get a blob URL - const blobUrl = await cacheAvatar(hash, data, 'image/png') + const blobUrl = await cacheAvatar(hash, data, mimeType) await saveAvatarHash(bareJid, hash, 'contact') this.updateAvatar(bareJid, blobUrl, hash) // Clear negative cache since we found an avatar @@ -317,7 +322,9 @@ export class Profile extends BaseModule { const data = result.getChild('pubsub', NS_PUBSUB)?.getChild('items')?.getChild('item')?.getChild('data', NS_AVATAR_DATA)?.text() if (data) { - const mimeType = 'image/png' + // The data node has no MIME type; sniff the bytes so animated + // avatars aren't cached as image/png (see fetchAvatarData). + const mimeType = sniffImageMimeType(data) ?? 'image/png' const blobUrl = await cacheAvatar(avatarHash, data, mimeType) await clearNoAvatar(bareJid) // Persist JID→hash mapping so we can restore from cache on next session @@ -802,7 +809,10 @@ export class Profile extends BaseModule { if (data) { const base64 = data.text() if (base64) { - const blobUrl = await cacheAvatar(hash, base64, mimeType) + // Prefer the sniffed type over the advertised , which the + // publishing client may have mislabeled; fall back to it when unknown. + const sniffedType = sniffImageMimeType(base64) ?? mimeType + const blobUrl = await cacheAvatar(hash, base64, sniffedType) await saveAvatarHash(bareJid, hash, 'contact') this.deps.emitSDK('connection:own-avatar', { avatar: blobUrl, hash }) } diff --git a/packages/fluux-sdk/src/utils/imageType.test.ts b/packages/fluux-sdk/src/utils/imageType.test.ts new file mode 100644 index 00000000..7644ec28 --- /dev/null +++ b/packages/fluux-sdk/src/utils/imageType.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { sniffImageMimeType } from './imageType' + +/** Base64-encode a byte array (small fixtures only). */ +const b64 = (bytes: number[]) => btoa(String.fromCharCode(...bytes)) + +/** Build a PNG chunk: 4-byte big-endian length, 4-byte ASCII type, data, 4-byte CRC. */ +function pngChunk(type: string, dataLen = 0): number[] { + const typeBytes = [...type].map((c) => c.charCodeAt(0)) + const len = [ + (dataLen >>> 24) & 0xff, + (dataLen >>> 16) & 0xff, + (dataLen >>> 8) & 0xff, + dataLen & 0xff, + ] + const data = new Array(dataLen).fill(0) + const crc = [0, 0, 0, 0] + return [...len, ...typeBytes, ...data, ...crc] +} + +const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +describe('sniffImageMimeType', () => { + it('detects a still PNG from its 8-byte signature', () => { + const png = b64([...PNG_SIG, ...pngChunk('IHDR', 13), ...pngChunk('IDAT', 0)]) + expect(sniffImageMimeType(png)).toBe('image/png') + }) + + it('detects an animated PNG (acTL before IDAT) as image/apng', () => { + const apng = b64([ + ...PNG_SIG, + ...pngChunk('IHDR', 13), + ...pngChunk('acTL', 8), + ...pngChunk('IDAT', 0), + ]) + expect(sniffImageMimeType(apng)).toBe('image/apng') + }) + + it('treats an acTL chunk after IDAT as a still PNG (not APNG)', () => { + // Per the APNG spec the animation-control chunk must precede image data. + const png = b64([ + ...PNG_SIG, + ...pngChunk('IHDR', 13), + ...pngChunk('IDAT', 0), + ...pngChunk('acTL', 8), + ]) + expect(sniffImageMimeType(png)).toBe('image/png') + }) + + it('detects a GIF87a image', () => { + expect(sniffImageMimeType(b64([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]))).toBe('image/gif') + }) + + it('detects a GIF89a image', () => { + expect(sniffImageMimeType(b64([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]))).toBe('image/gif') + }) + + it('detects a WebP image (RIFF....WEBP)', () => { + const webp = b64([ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x1a, 0x00, 0x00, 0x00, // file size + 0x57, 0x45, 0x42, 0x50, // "WEBP" + 0x56, 0x50, 0x38, 0x20, // "VP8 " + ]) + expect(sniffImageMimeType(webp)).toBe('image/webp') + }) + + it('detects a JPEG image', () => { + const jpeg = b64([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]) + expect(sniffImageMimeType(jpeg)).toBe('image/jpeg') + }) + + it('tolerates whitespace/newlines in the base64 (XML-wrapped payloads)', () => { + const gif = b64([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + const wrapped = gif.slice(0, 4) + '\n ' + gif.slice(4) + expect(sniffImageMimeType(wrapped)).toBe('image/gif') + }) + + it('returns null for an unrecognized format', () => { + expect(sniffImageMimeType(b64([0x00, 0x01, 0x02, 0x03, 0x04]))).toBeNull() + }) + + it('returns null for an empty string', () => { + expect(sniffImageMimeType('')).toBeNull() + }) + + it('returns null for invalid base64', () => { + expect(sniffImageMimeType('!!!!not-base64')).toBeNull() + }) + + it('returns null for data too short to identify', () => { + expect(sniffImageMimeType(b64([0x89, 0x50]))).toBeNull() + }) +}) diff --git a/packages/fluux-sdk/src/utils/imageType.ts b/packages/fluux-sdk/src/utils/imageType.ts new file mode 100644 index 00000000..7501d2c4 --- /dev/null +++ b/packages/fluux-sdk/src/utils/imageType.ts @@ -0,0 +1,122 @@ +/** + * Sniff an image's MIME type from its magic bytes. + * + * Avatars arrive with a self-reported type: XEP-0084 metadata advertises a + * `` and vCard-temp a ``, both set by the *publishing* + * client. The SDK historically ignored those and hardcoded `image/png` when + * caching PEP avatars, so animated GIF/WebP/APNG avatars were stored as a Blob + * typed `image/png`. Browsers content-sniff `` so they still render, but + * any consumer that trusts `blob.type` (notification file extensions, the GIF + * freeze-frame extractor) is misled. + * + * Rather than trust the advertised type, we sniff the actual bytes — the only + * authoritative source, and robust against clients that mislabel their avatars. + * + * @param base64 - Base64-encoded image data (whitespace tolerated). + * @returns The detected MIME type, or null when the format isn't recognized so + * callers can fall back to a sensible default. + */ +export function sniffImageMimeType(base64: string): string | null { + if (!base64) return null + + const bytes = decodeBase64(base64) + if (!bytes || bytes.length < 3) return null + + // JPEG: FF D8 FF + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + + // GIF: "GIF8" (covers both GIF87a and GIF89a) + if ( + bytes.length >= 4 && + bytes[0] === 0x47 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x38 + ) { + return 'image/gif' + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A. An animated PNG carries an 'acTL' chunk + // before the first 'IDAT'; report those as image/apng. + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return hasApngAnimationChunk(bytes) ? 'image/apng' : 'image/png' + } + + // WebP: "RIFF" <4-byte size> "WEBP" + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return 'image/webp' + } + + return null +} + +/** + * Decode base64 to bytes, returning null on malformed input. Avatars are small, + * so the whole payload is decoded; whitespace (XML-wrapped base64) is stripped. + */ +function decodeBase64(base64: string): Uint8Array | null { + try { + const clean = base64.replace(/\s/g, '') + if (!clean) return null + const binary = atob(clean) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i) + } + return out + } catch { + return null + } +} + +/** + * Walk PNG chunks looking for an 'acTL' animation-control chunk positioned + * before the first 'IDAT'. Caller guarantees `bytes` starts with the PNG + * signature. + */ +function hasApngAnimationChunk(bytes: Uint8Array): boolean { + let offset = 8 // skip the 8-byte PNG signature + while (offset + 8 <= bytes.length) { + // Big-endian 4-byte chunk length (avoid << to stay unsigned). + const length = + bytes[offset] * 0x1000000 + + (bytes[offset + 1] << 16) + + (bytes[offset + 2] << 8) + + bytes[offset + 3] + const t0 = bytes[offset + 4] + const t1 = bytes[offset + 5] + const t2 = bytes[offset + 6] + const t3 = bytes[offset + 7] + + // 'acTL' + if (t0 === 0x61 && t1 === 0x63 && t2 === 0x54 && t3 === 0x4c) return true + // 'IDAT' — animation control must precede image data, so stop here. + if (t0 === 0x49 && t1 === 0x44 && t2 === 0x41 && t3 === 0x54) return false + + // Advance past length(4) + type(4) + data(length) + CRC(4). + offset += 12 + length + } + return false +}