mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
205 lines
8.1 KiB
TypeScript
205 lines
8.1 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 {
|
|
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));
|
|
} |