Files
webmail/lib/file-preview.ts

209 lines
8.4 KiB
TypeScript

export type FilePreviewKind = 'image' | 'html' | 'eml' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'mov', 'm4v', 'avi', 'mkv']);
const TEXT_EXTENSIONS = new Set([
'txt', 'text', 'log', 'csv', 'json', 'xml', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'sql', 'graphql', 'html', 'htm',
'md', 'markdown',
]);
const TEXT_MIME_TYPES = new Set([
'application/json',
'application/ld+json',
'application/xml',
'application/javascript',
'application/x-javascript',
'application/typescript',
]);
function normalizeMimeType(type?: string): string {
return type?.split(';')[0]?.trim().toLowerCase() || '';
}
function getExtension(name?: string): string {
const parts = name?.toLowerCase().split('.') || [];
return parts.length > 1 ? parts.pop() || '' : '';
}
export function getFilePreviewKind(name?: string, type?: string): FilePreviewKind {
const ext = getExtension(name);
const mimeType = normalizeMimeType(type);
if (mimeType.startsWith('image/') || IMAGE_EXTENSIONS.has(ext)) {
return 'image';
}
if (mimeType === 'text/html' || mimeType === 'application/xhtml+xml' || ext === 'html' || ext === 'htm') {
return 'html';
}
// Embedded email message (e.g. a bounce/DSN, or forward-as-attachment).
// Rendered by parsing it and showing it like an email, not as a blob.
if (mimeType === 'message/rfc822' || ext === 'eml') {
return 'eml';
}
if (mimeType === 'application/pdf' || ext === 'pdf') {
return 'pdf';
}
if (mimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(ext)) {
return 'audio';
}
if (mimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)) {
return 'video';
}
if (ext === 'md' || ext === 'markdown') {
return 'markdown';
}
if (mimeType.startsWith('text/') || TEXT_MIME_TYPES.has(mimeType) || TEXT_EXTENSIONS.has(ext)) {
return 'text';
}
return 'unsupported';
}
export function isFilePreviewable(name?: string, type?: string): boolean {
return getFilePreviewKind(name, type) !== 'unsupported';
}
// Canonical types for the extensions the preview renders from a blob: PDF and
// every image, audio and video extension above except SVG. No script-capable
// type (SVG, HTML) may appear here: the preview trusts these over whatever
// the sender declared.
const EXT_TO_MIME: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
flac: 'audio/flac',
aac: 'audio/aac',
// Ogg Opus (RFC 7845); audio/opus names the bare RTP payload.
opus: 'audio/ogg',
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/mp4',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
};
const GENERIC_MIME_TYPES = new Set(['', 'application/octet-stream', 'binary/octet-stream']);
// Kinds the preview renders from the file's text, not from a blob: URL.
const DOCUMENT_KINDS = new Set<FilePreviewKind>(['html', 'eml', 'text', 'markdown']);
// Blob types the browser actually shows in each blob-backed preview.
const RENDERABLE_FOR_KIND: Partial<Record<FilePreviewKind, (mimeType: string) => boolean>> = {
pdf: (t) => t === 'application/pdf',
image: (t) => t.startsWith('image/'),
audio: (t) => t.startsWith('audio/'),
video: (t) => t.startsWith('video/'),
};
/**
* MIME type to give a preview's blob. Browsers pick the viewer from the
* blob's type, not the file name, and download anything they cannot show -
* under the blob's UUID. Neither the sender's declaration (octet-stream, or a
* catch-all such as the application/x-as400attachment IBM i systems put on
* every attachment) nor Stalwart's download response is reliably a type the
* viewer accepts, so a PDF, image, audio or video preview takes the first
* type its viewer can show: declared, then served, then the one the file
* name implies. Every other preview keeps the declared type unless it is
* generic.
*/
export function previewBlobType(name: string | undefined, declaredType?: string, blobType?: string): string {
const declared = normalizeMimeType(declaredType);
const served = normalizeMimeType(blobType);
const fromName = EXT_TO_MIME[getExtension(name)];
const kind = getFilePreviewKind(name, declared || served);
// Documents are previewed from their text (sanitised, in a srcdoc frame),
// never from a blob: URL. Should one still be minted for them, it must be
// nothing a browser would render as the webmail origin.
if (DOCUMENT_KINDS.has(kind)) return 'application/octet-stream';
const renderable = RENDERABLE_FOR_KIND[kind];
if (renderable) {
const match = [declared, served, fromName].find((t): t is string => !!t && renderable(t));
if (match) return match;
}
if (declaredType && !GENERIC_MIME_TYPES.has(declared)) return declaredType;
return fromName ?? blobType ?? '';
}
const INLINE_PREVIEW_SAFE_MIME_PREFIXES = ['image/', 'audio/', 'video/'];
const INLINE_PREVIEW_SAFE_MIME_TYPES = new Set(['application/pdf', 'text/plain']);
const INLINE_PREVIEW_UNSAFE_MIME_TYPES = new Set([
'image/svg+xml',
'image/svg',
]);
// Whether a Blob with this MIME type is safe to open as a top-level navigation
// (e.g. window.open on a blob: URL). Blob URLs inherit the creator's origin, so
// script-bearing types like text/html, application/xhtml+xml, image/svg+xml, and
// XML variants would execute in our origin. Only an explicit allowlist of inert
// types is permitted; everything else must be downloaded.
export function isMimeTypeSafeForInlinePreview(type?: string): boolean {
// new Blob() drops a type with any character outside U+0020-U+007E to "",
// and Firefox sniffs an untyped blob: document. A winmail.dat MIME tag is
// free text, so judge only types the Blob keeps as written.
if (!type || !/^[\x20-\x7e]+$/.test(type)) return false;
const mimeType = type.split(';')[0]?.trim().toLowerCase() || '';
if (!mimeType) return false;
if (INLINE_PREVIEW_UNSAFE_MIME_TYPES.has(mimeType)) return false;
if (INLINE_PREVIEW_SAFE_MIME_TYPES.has(mimeType)) return true;
return INLINE_PREVIEW_SAFE_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix));
}
const INERT_BLOB_TYPE = 'application/octet-stream';
/**
* MIME type for a Blob whose blob: URL ends up inside untrusted markup (the
* cid: parts of a rendered message body). Blob URLs inherit the creating
* document's origin, so a sender-declared text/html or image/svg+xml part
* navigated to as a top-level document would run script as the webmail origin
* (GHSA-xvjh-v9c6-qcvc). Inert types keep their MIME so img/video/audio keep
* rendering them; anything else becomes octet-stream, which no browser executes.
*/
export function inertBlobType(type?: string): string {
return type && isMimeTypeSafeForInlinePreview(type) ? type : INERT_BLOB_TYPE;
}
/** Re-type a fetched Blob per inertBlobType() without copying its bytes. */
export function toInertBlob(blob: Blob): Blob {
return isMimeTypeSafeForInlinePreview(blob.type) ? blob : blob.slice(0, blob.size, INERT_BLOB_TYPE);
}
/**
* A URL an <img> can show whose target is inert when opened on its own
* ("Open image in new tab", dragging it to the tab strip). An object URL
* shares the webmail origin, so an image/svg+xml one would run the
* sender's script there. SVG is handed over as a data: URL instead - its
* document gets an opaque origin - and every other type as an object URL
* re-typed per inertBlobType(). Revoking the result is safe either way.
*/
export async function imageBlobUrl(blob: Blob, type?: string): Promise<string> {
const mimeType = (type || blob.type).split(';')[0].trim().toLowerCase();
if (INLINE_PREVIEW_UNSAFE_MIME_TYPES.has(mimeType)) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob.slice(0, blob.size, 'image/svg+xml'));
});
}
return URL.createObjectURL(toInertBlob(type ? blob.slice(0, blob.size, type) : blob));
}