import { randomUUID } from 'node:crypto' import { createReadStream } from 'node:fs' import { mkdir, stat, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' import { httpError } from './http' export const TRANSACTION_DOCUMENT_MAX_SIZE = 10 * 1024 * 1024 export type TransactionDocumentFileType = { mimeType: string extension: string } const pdfFileType = { mimeType: 'application/pdf', extension: 'pdf' } const jpegFileType = { mimeType: 'image/jpeg', extension: 'jpg' } const pngFileType = { mimeType: 'image/png', extension: 'png' } const webpFileType = { mimeType: 'image/webp', extension: 'webp' } const heicFileType = { mimeType: 'image/heic', extension: 'heic' } const heifFileType = { mimeType: 'image/heif', extension: 'heif' } const allowedFileTypes: TransactionDocumentFileType[] = [ pdfFileType, jpegFileType, pngFileType, webpFileType, heicFileType, heifFileType ] export function getTransactionDocumentDirectory() { const config = useRuntimeConfig() const configured = String(config.transactionDocumentDir || '.data/transaction-documents').trim() return path.resolve(process.cwd(), configured || '.data/transaction-documents') } export async function saveTransactionDocument(data: Buffer, fileType: TransactionDocumentFileType) { const directory = getTransactionDocumentDirectory() await mkdir(directory, { recursive: true }) const storageName = `${randomUUID()}.${fileType.extension}` const filePath = getTransactionDocumentPath(storageName) await writeFile(filePath, data, { flag: 'wx' }) return storageName } export function getTransactionDocumentPath(storageName: string) { if (!/^[a-f0-9-]+\.(pdf|jpg|png|webp|heic|heif)$/.test(storageName)) { httpError(404, 'Transaction document not found') } const directory = getTransactionDocumentDirectory() const filePath = path.resolve(directory, storageName) const directoryPrefix = directory.endsWith(path.sep) ? directory : `${directory}${path.sep}` if (!filePath.startsWith(directoryPrefix)) { httpError(404, 'Transaction document not found') } return filePath } export async function getTransactionDocumentFile(storageName: string) { const filePath = getTransactionDocumentPath(storageName) const fileStat = await stat(filePath).catch(() => null) if (!fileStat?.isFile()) { httpError(404, 'Transaction document not found') } return { filePath, size: fileStat.size, stream: createReadStream(filePath) } } export async function deleteTransactionDocument(storageName: string | null | undefined) { if (!storageName) { return } try { await unlink(getTransactionDocumentPath(storageName)) } catch { // A missing or invalid old file should not block the booking update. } } export function sanitizeTransactionDocumentName(filename: string | undefined) { const baseName = path.basename(filename || 'transaction-document') const normalized = baseName .replace(/[\u0000-\u001f\u007f"\\/:*?<>|]+/g, ' ') .replace(/\s+/g, ' ') .trim() return (normalized || 'transaction-document').slice(0, 180) } export function getSafeDownloadName(filename: string) { const sanitized = sanitizeTransactionDocumentName(filename) return sanitized.replace(/[^\x20-\x7e]+/g, '_').replace(/"/g, '') } export function validateTransactionDocumentUpload(input: { data: Buffer filename?: string contentType?: string }) { if (!input.data.length) { httpError(400, 'Transaction document is empty') } if (input.data.length > TRANSACTION_DOCUMENT_MAX_SIZE) { httpError(413, 'Transaction document must be 10MB or smaller') } const detectedType = detectTransactionDocumentFileType(input.data) if (!detectedType) { httpError(400, 'Transaction document must be a PDF or supported image file') } const declaredType = String(input.contentType || '').toLowerCase() if (declaredType && !isCompatibleDeclaredType(declaredType, detectedType)) { httpError(400, 'Transaction document file type does not match the uploaded file') } return { originalName: sanitizeTransactionDocumentName(input.filename), fileType: detectedType } } function isCompatibleDeclaredType(declaredType: string, detectedType: TransactionDocumentFileType) { if (declaredType === detectedType.mimeType) { return true } return detectedType.mimeType === 'image/jpeg' && declaredType === 'image/jpg' } function detectTransactionDocumentFileType(data: Buffer): TransactionDocumentFileType | null { if (data.subarray(0, 5).toString('ascii') === '%PDF-') { return pdfFileType } if (data.length >= 3 && data.readUInt8(0) === 0xff && data.readUInt8(1) === 0xd8 && data.readUInt8(2) === 0xff) { return jpegFileType } if ( data.length >= 8 && data.readUInt8(0) === 0x89 && data.readUInt8(1) === 0x50 && data.readUInt8(2) === 0x4e && data.readUInt8(3) === 0x47 && data.readUInt8(4) === 0x0d && data.readUInt8(5) === 0x0a && data.readUInt8(6) === 0x1a && data.readUInt8(7) === 0x0a ) { return pngFileType } if ( data.length >= 12 && data.subarray(0, 4).toString('ascii') === 'RIFF' && data.subarray(8, 12).toString('ascii') === 'WEBP' ) { return webpFileType } if (isHeifFamily(data)) { const brand = data.subarray(8, 12).toString('ascii') return brand === 'heif' || brand === 'mif1' || brand === 'msf1' ? heifFileType : heicFileType } return null } function isHeifFamily(data: Buffer) { if (data.length < 12 || data.subarray(4, 8).toString('ascii') !== 'ftyp') { return false } const brand = data.subarray(8, 12).toString('ascii') return ['heic', 'heix', 'hevc', 'hevx', 'heif', 'mif1', 'msf1'].includes(brand) }