Add payment method selection (Cash/Bank) to booking details Support uploading, downloading, and deleting transaction documents Update database schema and API endpoints to handle file storage
193 lines
5.6 KiB
TypeScript
193 lines
5.6 KiB
TypeScript
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)
|
|
}
|