From 433a63bf1a569478cbb781cbf16069b5d28a79f2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 18:42:12 +0200 Subject: [PATCH] fix: normalize malformed contact photo data URIs #307 --- components/contacts/contact-form.tsx | 5 ++- stores/__tests__/contact-store.test.ts | 42 +++++++++++++++++++++++++- stores/contact-store.ts | 19 +++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 5fa6c363..c99491fa 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -6,6 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; +import { normalizeContactPhotoUri } from "@/stores/contact-store"; import { cn } from "@/lib/utils"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; @@ -341,7 +342,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const initialPhotoEntry = useMemo(() => { if (!contact?.media) return null; for (const [key, m] of Object.entries(contact.media)) { - if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType }; + if (m.kind === "photo" && m.uri) { + return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType }; + } } return null; }, [contact]); diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index 093e40d3..dd9dbec7 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { useContactStore } from '../contact-store'; +import { useContactStore, getContactPhotoUri, normalizeContactPhotoUri } from '../contact-store'; import type { ContactCard } from '@/lib/jmap/types'; vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' }); @@ -496,6 +496,46 @@ describe('contact-store', () => { }); }); + describe('normalizeContactPhotoUri', () => { + it('rewrites malformed data:base64,... URIs using the media mediaType', () => { + expect(normalizeContactPhotoUri('data:base64,AAAA', 'image/png')) + .toBe('data:image/png;base64,AAAA'); + }); + + it('rewrites data:;base64,... URIs using the media mediaType', () => { + expect(normalizeContactPhotoUri('data:;base64,AAAA', 'image/gif')) + .toBe('data:image/gif;base64,AAAA'); + }); + + it('defaults to image/jpeg when no mediaType is available', () => { + expect(normalizeContactPhotoUri('data:base64,AAAA')) + .toBe('data:image/jpeg;base64,AAAA'); + }); + + it('leaves well-formed data URIs unchanged', () => { + const good = 'data:image/png;base64,AAAA'; + expect(normalizeContactPhotoUri(good)).toBe(good); + }); + + it('leaves http(s) URIs unchanged', () => { + const url = 'https://example.com/photo.jpg'; + expect(normalizeContactPhotoUri(url)).toBe(url); + }); + }); + + describe('getContactPhotoUri', () => { + it('returns a normalized data URI for malformed Stalwart photos (#307)', () => { + const contact = makeContact({ + media: { m0: { kind: 'photo', uri: 'data:base64,AAAA', mediaType: 'image/png' } }, + }); + expect(getContactPhotoUri(contact)).toBe('data:image/png;base64,AAAA'); + }); + + it('returns undefined when no photo media is present', () => { + expect(getContactPhotoUri(makeContact())).toBeUndefined(); + }); + }); + describe('persistence/partialize', () => { it('should persist contacts when supportsSync is false', () => { const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record) => Record } } }).persist.getOptions(); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index a376ed7a..df33ddd9 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -37,10 +37,27 @@ export function getContactPrimaryEmail(contact: ContactCard): string { return Object.values(contact.emails)[0]?.address || ''; } +// Some JMAP servers (notably Stalwart, see issue #307) emit photo data URIs +// without a mediatype, like `data:base64,...` or `data:;base64,...`. Per +// RFC 2397 the missing/empty mediatype defaults to `text/plain`, so browsers +// won't render the bytes as an image. Rewrite to include a mediatype. +export function normalizeContactPhotoUri(uri: string, mediaType?: string): string { + const mime = mediaType && mediaType.includes('/') ? mediaType : 'image/jpeg'; + if (uri.startsWith('data:base64,')) { + return `data:${mime};base64,${uri.slice('data:base64,'.length)}`; + } + if (uri.startsWith('data:;base64,')) { + return `data:${mime};base64,${uri.slice('data:;base64,'.length)}`; + } + return uri; +} + export function getContactPhotoUri(contact: ContactCard): string | undefined { if (!contact.media) return undefined; for (const media of Object.values(contact.media)) { - if (media.kind === 'photo' && media.uri) return media.uri; + if (media.kind === 'photo' && media.uri) { + return normalizeContactPhotoUri(media.uri, media.mediaType); + } } return undefined; }