diff --git a/src/models/contact.js b/src/models/contact.js index 325b641c8f..a1d7017353 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -22,6 +22,33 @@ function isEmpty(value) { return (Array.isArray(value) && value.join('') === '') || (!Array.isArray(value) && value === '') } +/** + * Detect the image type from the magic bytes of base64 encoded image data + * + * @param {string} photoB64Data the base64 encoded image data + * @return {string} the image type, jpeg if unknown + */ +function detectImageType(photoB64Data) { + // base64 encodings of the magic bytes of the common image formats + const signatures = { + '/9j/': 'jpeg', + iVBOR: 'png', + R0lGO: 'gif', + UklGR: 'webp', + Qk: 'bmp', + PHN2Z: 'svg+xml', // ' photoB64Data.startsWith(signature)) + if (!signature) { + // browsers detect raster images in an img element from the content, + // so an assumed subtype still renders fine, but warn about the guess + console.warn('Could not detect the photo type from its content, assuming JPEG') + return 'jpeg' + } + return signatures[signature] +} + export const ContactKindProperties = ['KIND', 'X-ADDRESSBOOKSERVER-KIND'] export const MinimalContactProperties = [ @@ -271,13 +298,19 @@ export default class Contact { photoType = photoB64.split(';')[0].split('/').pop() } + // The TYPE parameter is optional (e.g. `PHOTO;ENCODING=b:…`), + // so fall back to the magic bytes of the image data (see issue #5401) + if (!photoType) { + photoType = detectImageType(photoB64Data) + } + // Verify if SVG is valid if (photoType.toLowerCase().startsWith('svg')) { const imageSvg = atob(photoB64Data) const cleanSvg = await sanitizeSVG(imageSvg) if (!cleanSvg) { - console.error('Invalid SVG for the following contact. Ignoring...', this.contact, { photoB64, photoType }) + console.error('Invalid SVG for the following contact. Ignoring...', this, { photoB64, photoType }) return false } } @@ -287,7 +320,7 @@ export default class Contact { const blob = b64toBlob(photoB64Data, `image/${photoType}`) return URL.createObjectURL(blob) } catch { - console.error('Invalid photo for the following contact. Ignoring...', this.contact, { photoB64, photoType }) + console.error('Invalid photo for the following contact. Ignoring...', this, { photoB64, photoType }) return false } } diff --git a/tests/javascript/models/contact.test.js b/tests/javascript/models/contact.test.js index c4c429e46a..45b9a0ea4e 100644 --- a/tests/javascript/models/contact.test.js +++ b/tests/javascript/models/contact.test.js @@ -9,6 +9,61 @@ const getPropertyLines = (property, vcard) => { return vcard.match(new RegExp(`^${property}[;:].*`, 'gmi')) } +describe('Test getPhotoUrl', () => { + + // 1x1 transparent PNG + const pngB64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' + + const buildContact = (photoLine, version = '3.0') => new Contact(` + BEGIN:VCARD + VERSION:${version} + UID:123456789-123465-123456-123456789 + FN:Test contact + ${photoLine} + END:VCARD`.replace(/\t/gmi, ''), + { id: 'addressbook1' }) + + beforeAll(() => { + global.URL.createObjectURL = jest.fn((blob) => `blob:${blob.type}`) + }) + + test('photo with an explicit TYPE parameter', async () => { + const contact = buildContact(`PHOTO;ENCODING=b;TYPE=png:${pngB64}`) + + expect(await contact.getPhotoUrl()).toStrictEqual('blob:image/png') + }) + + test('photo without a TYPE parameter is detected from the image data (issue #5401)', async () => { + const contact = buildContact(`PHOTO;ENCODING=b:${pngB64}`) + + expect(await contact.getPhotoUrl()).toStrictEqual('blob:image/png') + }) + + test('jpeg photo without a TYPE parameter', async () => { + const contact = buildContact('PHOTO;ENCODING=b:/9j/4AAQSkZJRgABAQ==') + + expect(await contact.getPhotoUrl()).toStrictEqual('blob:image/jpeg') + }) + + test('photo of an unknown type falls back to jpeg and warns', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + // base64 that matches none of the known magic byte signatures + const contact = buildContact('PHOTO;ENCODING=b:Zm9vYmFyYmF6cXV4') + + expect(await contact.getPhotoUrl()).toStrictEqual('blob:image/jpeg') + expect(warn).toHaveBeenCalled() + + warn.mockRestore() + }) + + test('photo from a data uri (vCard 4.0)', async () => { + const contact = buildContact(`PHOTO:data:image/png;base64,${pngB64}`, '4.0') + + expect(await contact.getPhotoUrl()).toStrictEqual('blob:image/png') + }) + +}) + describe('Test stripping quotes from TYPE', () => { let contact