const fs = require('fs'); const path = require('path'); const REMOTE_STORAGE_MODES = new Set(['dual', 'sftp']); const DEFAULT_SFTP_PORT = 22; let sftpClientFactoryOverride = null; const resolveUploadDirCandidate = (uploadDir) => path.isAbsolute(uploadDir) ? uploadDir : path.resolve(__dirname, '..', '..', uploadDir); const getExistingAncestorScore = (targetPath) => { let currentPath = targetPath; let score = 0; while (currentPath && currentPath !== path.dirname(currentPath)) { if (fs.existsSync(currentPath)) { return score; } currentPath = path.dirname(currentPath); score += 1; } return Number.MAX_SAFE_INTEGER; }; const selectUploadDirCandidate = (uploadDirs) => uploadDirs .map((uploadDir, index) => ({ index, path: resolveUploadDirCandidate(uploadDir), score: getExistingAncestorScore(resolveUploadDirCandidate(uploadDir)) })) .sort((left, right) => left.score - right.score || left.index - right.index)[0]?.path; const getTripStatusUploadsDir = () => { const configuredUploadDir = String(process.env.TRIP_STATUS_UPLOAD_DIR || '').trim(); if (configuredUploadDir) { const configuredUploadDirs = configuredUploadDir .split(';') .map((uploadDir) => uploadDir.trim()) .filter(Boolean); return selectUploadDirCandidate(configuredUploadDirs); } return path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status'); }; const getTripStatusPhotoStorageMode = () => String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local') .trim() .toLowerCase(); const shouldUseRemoteStorage = () => REMOTE_STORAGE_MODES.has(getTripStatusPhotoStorageMode()); const getTripDirectorySegment = (tripId) => { const parsedTripId = Number.parseInt(tripId, 10); return Number.isInteger(parsedTripId) && parsedTripId > 0 ? String(parsedTripId) : 'unknown'; }; const normalizeRemoteBaseDir = (rawValue) => String(rawValue || '') .trim() .replace(/\/+$/g, ''); const getSftpConfig = () => { const host = String(process.env.TRIP_STATUS_SFTP_HOST || '').trim(); const username = String(process.env.TRIP_STATUS_SFTP_USERNAME || '').trim(); const password = String(process.env.TRIP_STATUS_SFTP_PASSWORD || '').trim(); const remoteBaseDir = normalizeRemoteBaseDir(process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR); const parsedPort = Number.parseInt(process.env.TRIP_STATUS_SFTP_PORT, 10); const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : DEFAULT_SFTP_PORT; const missing = []; if (!host) { missing.push('TRIP_STATUS_SFTP_HOST'); } if (!username) { missing.push('TRIP_STATUS_SFTP_USERNAME'); } if (!password) { missing.push('TRIP_STATUS_SFTP_PASSWORD'); } if (!remoteBaseDir) { missing.push('TRIP_STATUS_SFTP_REMOTE_BASE_DIR'); } return { host, username, password, port, remoteBaseDir, isValid: missing.length === 0, missing }; }; const getSftpClientFactory = () => { if (typeof sftpClientFactoryOverride === 'function') { return sftpClientFactoryOverride; } try { const SftpClient = require('ssh2-sftp-client'); return () => new SftpClient(); } catch (error) { console.error('SFTP client dependency is unavailable for trip status photos:', { message: error.message }); return null; } }; const isRemoteFileNotFoundError = (error) => { const normalizedMessage = String(error?.message || '').toLowerCase(); return ( error?.code === 'ENOENT' || error?.code === 2 || normalizedMessage.includes('no such file') || normalizedMessage.includes('not exist') ); }; const buildRemoteTripDir = (remoteBaseDir, tripId) => path.posix.join(remoteBaseDir, getTripDirectorySegment(tripId)); const buildRemoteFilePath = (remoteBaseDir, tripId, fileName) => path.posix.join(buildRemoteTripDir(remoteBaseDir, tripId), fileName); const ensureRemoteTripDirectory = async (client, { remoteBaseDir, tripDirectorySegment }) => { const remoteTripDir = buildRemoteTripDir(remoteBaseDir, tripDirectorySegment); const baseExists = await client.exists(remoteBaseDir); if (!baseExists) { await client.mkdir(remoteBaseDir, true); } const tripDirExists = await client.exists(remoteTripDir); if (!tripDirExists) { await client.mkdir(remoteTripDir, false); } return remoteTripDir; }; const withSftpClient = async (handler, { logContext }) => { if (!shouldUseRemoteStorage()) { return false; } const sftpConfig = getSftpConfig(); if (!sftpConfig.isValid) { console.error('Trip status photo remote storage skipped due to missing SFTP config:', { context: logContext, missing: sftpConfig.missing }); return false; } const createClient = getSftpClientFactory(); if (!createClient) { return false; } const client = createClient(); try { await client.connect({ host: sftpConfig.host, port: sftpConfig.port, username: sftpConfig.username, password: sftpConfig.password }); await handler(client, sftpConfig); return true; } catch (error) { console.error('Trip status photo remote storage operation failed:', { context: logContext, message: error.message }); return false; } finally { try { await client.end(); } catch (closeError) { console.error('Failed to close SFTP connection for trip status photos:', { context: logContext, message: closeError.message }); } } }; const replicateUploadedFilesToRemote = async ({ tripId, files }) => { const normalizedFiles = (files || []).filter((file) => file?.path && file?.filename); if (normalizedFiles.length === 0) { return; } const tripDirectorySegment = getTripDirectorySegment(tripId); for (const file of normalizedFiles) { file.tripStatusTripId = tripDirectorySegment; } const remoteOperationSucceeded = await withSftpClient( async (client, sftpConfig) => { await ensureRemoteTripDirectory(client, { remoteBaseDir: sftpConfig.remoteBaseDir, tripDirectorySegment }); for (const file of normalizedFiles) { const remoteFilePath = buildRemoteFilePath( sftpConfig.remoteBaseDir, tripDirectorySegment, file.filename ); try { await client.put(file.path, remoteFilePath); file.tripStatusRemoteUploaded = true; file.tripStatusRemotePath = remoteFilePath; } catch (error) { file.tripStatusRemoteUploaded = false; file.tripStatusRemotePath = null; console.error('Failed to replicate trip status photo to SFTP. Local fallback kept:', { tripId: tripDirectorySegment, fileName: file.filename, message: error.message }); } } }, { logContext: 'replicate_upload' } ); if (shouldUseRemoteStorage() && !remoteOperationSucceeded) { for (const file of normalizedFiles) { file.tripStatusRemoteUploaded = false; } } }; const removeRemoteFiles = async (remoteFilePaths, { logContext }) => { const uniqueRemotePaths = Array.from(new Set((remoteFilePaths || []).filter(Boolean))); if (uniqueRemotePaths.length === 0) { return; } await withSftpClient( async (client) => { for (const remoteFilePath of uniqueRemotePaths) { try { await client.delete(remoteFilePath); } catch (error) { if (isRemoteFileNotFoundError(error)) { continue; } console.error('Failed to remove remote trip status photo:', { context: logContext, remoteFilePath, message: error.message }); } } }, { logContext } ); }; const removeLocalFileIfExists = async (filePath, { logContext, fileName, tripId }) => { try { await fs.promises.unlink(filePath); return true; } catch (error) { if (error.code === 'ENOENT') { return false; } console.error('Failed to remove local trip status photo:', { context: logContext, tripId, file: fileName, path: filePath, message: error.message }); return false; } }; const cleanupUploadedTripStatusFiles = async (files) => { const normalizedFiles = (files || []).filter((file) => file?.path || file?.filename); if (normalizedFiles.length === 0) { return; } const sftpConfig = getSftpConfig(); const remoteFilePaths = []; for (const file of normalizedFiles) { if (file?.tripStatusRemotePath) { remoteFilePaths.push(file.tripStatusRemotePath); continue; } const tripId = file?.tripStatusTripId; if (shouldUseRemoteStorage() && sftpConfig.isValid && tripId && file?.filename) { remoteFilePaths.push( buildRemoteFilePath(sftpConfig.remoteBaseDir, tripId, file.filename) ); } } await removeRemoteFiles(remoteFilePaths, { logContext: 'cleanup_uploaded_files' }); for (const file of normalizedFiles) { if (!file?.path) { continue; } await removeLocalFileIfExists(file.path, { logContext: 'cleanup_uploaded_files', fileName: file.filename || null, tripId: file?.tripStatusTripId || null }); } }; const parsePhotoNames = (rawPhotoReferences) => String(rawPhotoReferences || '') .split(';') .map((item) => item.trim()) .filter(Boolean) .filter((item) => !item.includes('/') && !item.includes('\\')); const removeStatusPhotosByName = async ({ tripId, rawPhotoReferences }) => { const photoNames = parsePhotoNames(rawPhotoReferences); if (photoNames.length === 0) { return; } const uploadsDir = getTripStatusUploadsDir(); const tripUploadsDir = path.join(uploadsDir, getTripDirectorySegment(tripId)); const sftpConfig = getSftpConfig(); const remoteFilePaths = shouldUseRemoteStorage() && sftpConfig.isValid ? photoNames.map((photoName) => buildRemoteFilePath(sftpConfig.remoteBaseDir, tripId, photoName) ) : []; await removeRemoteFiles(remoteFilePaths, { logContext: 'remove_status_photos_by_name' }); for (const photoName of photoNames) { const candidatePaths = [ path.join(tripUploadsDir, photoName), path.join(uploadsDir, photoName) ]; for (const candidatePath of candidatePaths) { const removed = await removeLocalFileIfExists(candidatePath, { logContext: 'remove_status_photos_by_name', fileName: photoName, tripId }); if (removed) { break; } } } }; const removeUploadedTripStatusFiles = (files) => { cleanupUploadedTripStatusFiles(files).catch((error) => { console.error('Failed to cleanup uploaded trip status files:', { message: error.message }); }); }; const __setSftpClientFactoryForTests = (factory) => { sftpClientFactoryOverride = typeof factory === 'function' ? factory : null; }; const __resetSftpClientFactoryForTests = () => { sftpClientFactoryOverride = null; }; module.exports = { getTripStatusUploadsDir, replicateUploadedFilesToRemote, removeUploadedTripStatusFiles, removeStatusPhotosByName, __setSftpClientFactoryForTests, __resetSftpClientFactoryForTests };