Skip to content
Open
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
37 changes: 35 additions & 2 deletions src/models/contact.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', // '<svg'
PD94b: 'svg+xml', // '<?xml'
}
const signature = Object.keys(signatures).find((signature) => 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 = [
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
55 changes: 55 additions & 0 deletions tests/javascript/models/contact.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down