Files
Linus Rath c23e306789 fix: show and keep contact photos on Stalwart 1.0
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including every photo Bulwark saved as a data:
URI. Bulwark read only `uri`, so avatars, the contact list and detail,
print, vCard export and the "has photo" filter showed nothing, and the
form sent `media` without the photo on every save, so editing a phone
number deleted the photo on the server.

- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`; `uri`
  is optional, and `getContactPhotoRef`/`hasContactPhoto` read both.
- Blob photos are downloaded once each, at most two at a time and at a
  paced rate, only while on screen, into an LRU of object URLs revoked
  on eviction, account switch and logout. They share Stalwart's
  per-account budget of 4 concurrent requests and 1000 a minute.
- Avatars look senders up in an index built once per contact list.
- The form leaves `media` out unless the photo changed, and otherwise
  passes the other entries through as the server returned them.
- A new photo is uploaded into the user's own account and sent as a
  blobId where the blob capability lists ContactCard (1.0). 0.16 refuses
  blobIds in media and keeps getting a data: URI; a refusal at runtime
  falls back the same way for the rest of the session.
- Print and vCard export download blob media first; duplicate and
  cross-account move send the bytes instead of the source's blobIds.
- vCard export also writes photos whose data: URI names no media type
  (`data:;base64,`, `data:base64,`), which it dropped before.
2026-09-25 17:35:56 +02:00

32 lines
1.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { blobToDataUri, dataUriToBlob, parseBase64DataUri } from '../data-uri';
describe('parseBase64DataUri', () => {
it('reads the three shapes Stalwart writes', () => {
expect(parseBase64DataUri('data:image/jpeg;base64,/9j/')).toEqual({ type: 'image/jpeg', data: '/9j/' });
expect(parseBase64DataUri('data:;base64,/9j/')).toEqual({ type: undefined, data: '/9j/' });
expect(parseBase64DataUri('data:base64,/9j/')).toEqual({ type: undefined, data: '/9j/' });
expect(parseBase64DataUri('data:image/png;charset=x;base64,iVBO')).toEqual({ type: 'image/png', data: 'iVBO' });
});
it('refuses what is not a base64 data: URI', () => {
expect(parseBase64DataUri('https://x.test/p.jpg')).toBeNull();
expect(parseBase64DataUri('data:image/svg+xml,<svg/>')).toBeNull();
expect(parseBase64DataUri('data:image/jpeg;base64')).toBeNull();
});
});
describe('dataUriToBlob / blobToDataUri', () => {
it('round-trips the bytes', async () => {
const blob = dataUriToBlob('data:;base64,/9j/4AAQ', 'image/jpeg')!;
expect(blob.type).toBe('image/jpeg');
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
expect(await blobToDataUri(blob)).toBe('data:image/jpeg;base64,/9j/4AAQ');
expect(await blobToDataUri(blob, 'image/png')).toBe('data:image/png;base64,/9j/4AAQ');
});
it('gives up on broken base64', () => {
expect(dataUriToBlob('data:image/jpeg;base64,***')).toBeNull();
});
});