fix: normalize malformed contact photo data URIs #307

This commit is contained in:
Linus Rath
2026-05-20 18:42:12 +02:00
parent de847b9e9f
commit 433a63bf1a
3 changed files with 63 additions and 3 deletions
+4 -1
View File
@@ -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]);
+41 -1
View File
@@ -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<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
+18 -1
View File
@@ -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;
}