Files
garage-ui/frontend/src/lib/preview-utils.ts
T
Noste c0976a1e1d feat(preview): Implement object preview functionality (#94)
* feat(preview): implement object preview functionality with token-based access

* docs: update README to include inline object preview feature

* test: add range read error handling and attachment content disposition tests
2026-07-15 00:01:04 +02:00

127 lines
4.1 KiB
TypeScript

export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'none';
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
export const TEXT_HIGHLIGHT_MAX_BYTES = 1 * 1024 * 1024;
export const IMAGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
export const PDF_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
const imageMimeByExtension: Record<string, string> = {
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',
svg: 'image/svg+xml',
};
const videoExtensions = new Set(['mp4', 'webm', 'ogv', 'mov']);
const audioExtensions = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
const textExtensions = new Set([
'json', 'yaml', 'yml', 'ini', 'toml', 'xml', 'csv', 'md', 'log', 'txt',
'conf', 'cfg', 'env', 'sh', 'bash', 'py', 'js', 'ts', 'jsx', 'tsx',
'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'sql', 'html', 'css', 'dockerfile',
]);
function getExtension(key: string): string {
const name = key.split('/').pop() ?? '';
if (name.toLowerCase() === 'dockerfile') return 'dockerfile';
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
// Content-Type first, extension fallback. The fallback matters: Garage often
// stores application/octet-stream, and the backend rewrites unsafe types to
// it, so a generic type defers to the file extension.
export function getPreviewKind(contentType: string | undefined, key: string): PreviewKind {
const ct = (contentType ?? '').split(';')[0].trim().toLowerCase();
if (ct && ct !== 'application/octet-stream') {
if (ct.startsWith('image/')) return 'image';
if (ct.startsWith('video/')) return 'video';
if (ct.startsWith('audio/')) return 'audio';
if (ct === 'application/pdf') return 'pdf';
if (ct.startsWith('text/') || ct === 'application/json') return 'text';
}
const ext = getExtension(key);
if (ext in imageMimeByExtension) return 'image';
if (videoExtensions.has(ext)) return 'video';
if (audioExtensions.has(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
if (textExtensions.has(ext)) return 'text';
return 'none';
}
// Byte limit per kind. null means no limit because media streams with Range
// requests and is never fully loaded into memory.
export function getPreviewSizeLimit(kind: PreviewKind): number | null {
switch (kind) {
case 'text':
return TEXT_PREVIEW_MAX_BYTES;
case 'image':
return IMAGE_PREVIEW_MAX_BYTES;
case 'pdf':
return PDF_PREVIEW_MAX_BYTES;
case 'video':
case 'audio':
return null;
case 'none':
return 0;
}
}
// The backend rewrites unsafe Content-Types to application/octet-stream, so
// blob URLs need their MIME type restored for the SVG <img> case and the
// browser PDF viewer.
export function getPreviewMime(kind: PreviewKind, contentType: string | undefined, key: string): string {
if (kind === 'image') {
return imageMimeByExtension[getExtension(key)] ?? contentType ?? 'application/octet-stream';
}
if (kind === 'pdf') return 'application/pdf';
return contentType || 'application/octet-stream';
}
const languageByExtension: Record<string, string> = {
json: 'json',
yaml: 'yaml',
yml: 'yaml',
ini: 'ini',
toml: 'ini',
xml: 'xml',
md: 'markdown',
sh: 'bash',
bash: 'bash',
py: 'python',
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
go: 'go',
sql: 'sql',
html: 'xml',
css: 'css',
dockerfile: 'dockerfile',
};
export function getHighlightLanguage(key: string): string | null {
return languageByExtension[getExtension(key)] ?? null;
}
// A text preview only makes sense for content that decodes as text. More
// than 10 percent replacement or non-whitespace control characters in the
// sample means the file is binary despite its name.
export function looksBinary(sample: string): boolean {
if (sample.length === 0) return false;
let suspicious = 0;
let total = 0;
for (const ch of sample) {
total++;
const code = ch.codePointAt(0) ?? 0;
if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
suspicious++;
}
}
return suspicious / total > 0.1;
}