Files
everclone/src/lib/storage.ts
T

108 lines
2.9 KiB
TypeScript

import crypto from 'crypto';
import fs from 'fs/promises';
import path from 'path';
export function getAttachmentsDir(): string {
return process.env.ATTACHMENTS_DIR || path.join(process.cwd(), 'data', 'attachments');
}
export async function saveAttachmentBuffer(
buffer: Buffer,
originalFilename: string,
mimeType: string
): Promise<{
md5Hash: string;
storagePath: string;
sizeBytes: number;
extractedText: string | null;
}> {
const md5Hash = crypto.createHash('md5').update(buffer).digest('hex');
const ext = path.extname(originalFilename) || guessExtensionFromMime(mimeType);
const subDir = md5Hash.slice(0, 2);
const baseDir = getAttachmentsDir();
const targetDir = path.join(baseDir, subDir);
await fs.mkdir(targetDir, { recursive: true });
const fileNameOnDisk = `${md5Hash}${ext}`;
const fullPath = path.join(targetDir, fileNameOnDisk);
try {
await fs.access(fullPath);
} catch {
await fs.writeFile(fullPath, buffer);
}
let extractedText: string | null = null;
if (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml' ||
originalFilename.endsWith('.md') ||
originalFilename.endsWith('.txt') ||
originalFilename.endsWith('.csv')
) {
extractedText = buffer.toString('utf-8').slice(0, 50000);
}
return {
md5Hash,
storagePath: path.join(subDir, fileNameOnDisk),
sizeBytes: buffer.length,
extractedText,
};
}
export function getFullAttachmentPath(storagePath: string): string {
const baseDir = getAttachmentsDir();
const resolved = path.resolve(baseDir, storagePath);
if (!resolved.startsWith(path.resolve(baseDir))) {
throw new Error('Invalid storage path');
}
return resolved;
}
export function guessExtensionFromMime(mime: string): string {
const map: Record<string, string> = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'application/pdf': '.pdf',
'audio/mpeg': '.mp3',
'audio/mp3': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/webm': '.webm',
'audio/mp4': '.m4a',
'video/mp4': '.mp4',
'text/plain': '.txt',
'text/html': '.html',
'text/markdown': '.md',
};
return map[mime.toLowerCase()] || '.bin';
}
export function stripHtmlToText(html: string): string {
if (!html) return '';
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/\s+/g, ' ')
.trim();
}
export function makeExcerpt(text: string, maxLen = 220): string {
if (!text) return '';
const clean = text.replace(/\s+/g, ' ').trim();
return clean.length > maxLen ? clean.slice(0, maxLen) + '…' : clean;
}