Files
webmail/lib/data-uri.ts
T
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

48 lines
2.0 KiB
TypeScript

/**
* base64 data: URIs, as contact photos carry them.
*
* Stalwart writes three shapes: `data:<type>;base64,…`, `data:;base64,…`
* (0.16.x) and `data:base64,…` (0.16.0, no parameter separator). The last two
* carry no media type, so it comes from the card's `mediaType` when known.
*/
/** The media type (if any) and base64 payload of a base64 data: URI. */
export function parseBase64DataUri(uri: string): { type?: string; data: string } | null {
if (!uri.toLowerCase().startsWith('data:')) return null;
const comma = uri.indexOf(',');
if (comma < 0) return null;
const header = uri.slice(5, comma).split(';');
if (header[header.length - 1].trim().toLowerCase() !== 'base64') return null;
const type = header.length > 1 ? header[0].trim() : '';
return { type: type || undefined, data: uri.slice(comma + 1) };
}
/** The bytes of a base64 data: URI; `fallbackType` applies when the URI names none. */
export function dataUriToBlob(uri: string, fallbackType?: string): Blob | null {
const parsed = parseBase64DataUri(uri);
if (!parsed) return null;
let binary: string;
try {
binary = atob(parsed.data);
} catch {
return null;
}
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: parsed.type || fallbackType || 'application/octet-stream' });
}
/** A base64 data: URI of the blob, labelled `mediaType` when given, else the blob's own type. */
export function blobToDataUri(blob: Blob, mediaType?: string): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result);
const data = result.slice(result.indexOf(',') + 1);
resolve(`data:${mediaType || blob.type || 'application/octet-stream'};base64,${data}`);
};
reader.onerror = () => reject(reader.error ?? new Error('read-failed'));
reader.readAsDataURL(blob);
});
}