Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <info type>', 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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
16 changes: 13 additions & 3 deletions packages/fluux-sdk/src/core/modules/Profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <info type>, 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 })
}
Expand Down
94 changes: 94 additions & 0 deletions packages/fluux-sdk/src/utils/imageType.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading