Files
dticket.tootaio.com/server/utils/transaction-documents.ts
xiaomai b64a2b4c1c feat(bookings): add transaction document uploads for bank payments
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
2026-05-09 12:56:32 +08:00

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)
}