From c9fb26b7fd7a8d630e30e75ca1f487e1d6392260 Mon Sep 17 00:00:00 2001 From: MiMoHo <37556964+MiMoHo@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:24:09 +0200 Subject: [PATCH 1/2] fix(contacts): show photos that have no TYPE parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TYPE parameter of PHOTO is optional, but getPhotoUrl() called toLowerCase() on it unconditionally. Contacts stored e.g. as 'PHOTO;ENCODING=b:…' (without TYPE) threw a TypeError and showed initials instead of the photo, while other CardDAV clients displayed them fine. Detect the image type from the magic bytes of the base64 data when the TYPE parameter is missing. Unknown signatures fall back to jpeg, which browsers happily content-sniff in an img element anyway. Also log the contact itself in getPhotoUrl() error messages instead of the undefined this.contact. Resolves #5401 Assisted-by: Claude:claude-fable-5 Signed-off-by: MiMoHo <37556964+MiMoHo@users.noreply.github.com> --- src/models/contact.js | 33 +++++++++++++++++-- tests/javascript/models/contact.test.js | 44 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/models/contact.js b/src/models/contact.js index 325b641c8f..c5cb46b09c 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -22,6 +22,29 @@ 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)) + // browsers detect raster images in an img element from the content, + // so a wrong subtype still renders fine + return signature ? signatures[signature] : 'jpeg' +} + export const ContactKindProperties = ['KIND', 'X-ADDRESSBOOKSERVER-KIND'] export const MinimalContactProperties = [ @@ -271,13 +294,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 +316,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..a4baaa3f5b 100644 --- a/tests/javascript/models/contact.test.js +++ b/tests/javascript/models/contact.test.js @@ -9,6 +9,50 @@ 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 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 From cf151a90200e47e1da15faf66fcae4c14c4481bb Mon Sep 17 00:00:00 2001 From: MiMoHo <37556964+MiMoHo@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:12:15 +0200 Subject: [PATCH 2/2] fix(contacts): warn when guessing the photo type Address review feedback on #5565: instead of silently assuming JPEG for a PHOTO whose magic bytes match no known signature, log a warning so the guess is visible in the console. The fallback itself stays, as browsers content-sniff raster images in an img element regardless of the declared subtype. Add a test for the unknown-signature path, closing the coverage gap codecov reported on the fallback branch. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: MiMoHo <37556964+MiMoHo@users.noreply.github.com> --- src/models/contact.js | 10 +++++++--- tests/javascript/models/contact.test.js | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/models/contact.js b/src/models/contact.js index c5cb46b09c..a1d7017353 100644 --- a/src/models/contact.js +++ b/src/models/contact.js @@ -40,9 +40,13 @@ function detectImageType(photoB64Data) { PD94b: 'svg+xml', // ' photoB64Data.startsWith(signature)) - // browsers detect raster images in an img element from the content, - // so a wrong subtype still renders fine - return signature ? signatures[signature] : 'jpeg' + 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'] diff --git a/tests/javascript/models/contact.test.js b/tests/javascript/models/contact.test.js index a4baaa3f5b..45b9a0ea4e 100644 --- a/tests/javascript/models/contact.test.js +++ b/tests/javascript/models/contact.test.js @@ -45,6 +45,17 @@ describe('Test getPhotoUrl', () => { 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')