Files
ABIANAPP_NODE_PRODUCCION/src/services/tripStatusPhotoStorage.js
T

408 lines
12 KiB
JavaScript

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