Files
2026-06-05 00:53:35 +03:00

193 lines
7.7 KiB
JavaScript

const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const JSONbig = require('json-bigint')({ storeAsString: true });
const app = express();
// Используем json-bigint, чтобы не терять точность больших чисел
app.use(express.text({ type: 'application/json' }));
app.use((req, res, next) => {
if (req.body && typeof req.body === 'string') {
try {
req.body = JSONbig.parse(req.body);
} catch (e) {
return res.status(400).json({ error: 'Invalid JSON' });
}
}
next();
});
const UPLOAD_BASE = process.env.UPLOAD_PATH || '/app/uploads';
app.post('/api/files/merge', async (req, res) => {
try {
const { userId, fileId, parts, fileName } = req.body;
// Защита от выхода за пределы каталога (path traversal)
if (!fileName || typeof fileName !== 'string') {
return res.status(400).json({ error: 'Invalid fileName parameter' });
}
// Блокируем попытки обхода пути
if (fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
console.error(`Заблокирована попытка обхода пути - fileName: ${fileName}, IP: ${req.ip}`);
return res.status(400).json({ error: 'Invalid fileName: path traversal not allowed' });
}
// Очищаем имя файла: оставляем только буквы, цифры, дефис, подчёркивание и точки
const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
if (sanitizedFileName !== fileName) {
console.warn(`Имя файла очищено: "${fileName}" -> "${sanitizedFileName}"`);
}
console.log(`Merging file from local filesystem: userId=${userId}, fileId=${fileId}, parts=${parts}, fileName=${fileName}`);
// Части файла хранятся в формате: {userId}-{actualFileId}-{partNumber}
// fileId из command-server может отличаться от того, что использует file-server,
// поэтому реальный fileId находим, перебирая файлы этого пользователя
console.log(`Searching for files with userId: ${userId}`);
// Получаем список всех файлов этого пользователя
const allFiles = await fs.readdir(UPLOAD_BASE);
const userFiles = allFiles.filter(f => f.startsWith(`${userId}-`));
console.log(`Found ${userFiles.length} files for user ${userId}`);
if (userFiles.length === 0) {
return res.status(404).json({ error: 'No files found for user' });
}
// Группируем файлы по fileId (берём его из имени: userId-fileId-partNumber)
const fileGroups = {};
for (const file of userFiles) {
const match = file.match(/^(\d+)-(-?\d+)-(\d+)$/);
if (match) {
const [, uid, fid, partNum] = match;
if (!fileGroups[fid]) {
fileGroups[fid] = [];
}
fileGroups[fid].push({ file, partNum: parseInt(partNum) });
}
}
console.log(`Found ${Object.keys(fileGroups).length} file groups:`, Object.keys(fileGroups));
// Ищем группу с нужным количеством частей
let actualFileId = null;
let partFiles = null;
for (const [fid, files] of Object.entries(fileGroups)) {
if (files.length === parts) {
actualFileId = fid;
partFiles = files.sort((a, b) => a.partNum - b.partNum);
console.log(`Found matching file group: fileId=${fid}, parts=${files.length}`);
break;
}
}
if (!actualFileId || !partFiles) {
console.error(`No file group with ${parts} parts found`);
console.log('Available groups:', Object.entries(fileGroups).map(([fid, files]) => `${fid} (${files.length} parts)`));
return res.status(404).json({ error: `No file group with ${parts} parts found` });
}
// Проверяем, что все части на месте
const partPaths = [];
for (let i = 0; i < parts; i++) {
const partFile = partFiles.find(f => f.partNum === i);
if (!partFile) {
console.error(`Missing part ${i}`);
return res.status(404).json({ error: `Missing file part ${i}` });
}
partPaths.push(path.join(UPLOAD_BASE, partFile.file));
}
console.log(`All ${parts} parts found, merging...`);
// Склеиваем части
const mergedDir = path.join(UPLOAD_BASE, 'merged');
await fs.mkdir(mergedDir, { recursive: true });
// Используем очищенное имя файла
const extension = sanitizedFileName.split('.').pop() || '';
const mergedFileName = `${userId}-${actualFileId}${extension ? '.' + extension : ''}`;
// Дополнительно убеждаемся, что итоговый путь остаётся внутри разрешённого каталога
const mergedFilePath = path.join(mergedDir, mergedFileName);
const resolvedPath = path.resolve(mergedFilePath);
const resolvedMergedDir = path.resolve(mergedDir);
if (!resolvedPath.startsWith(resolvedMergedDir)) {
console.error(`Обнаружен выход за пределы каталога. Запрошенный путь: ${resolvedPath}`);
return res.status(400).json({ error: 'Invalid file path' });
}
const writeStream = require('fs').createWriteStream(mergedFilePath);
let totalSize = 0;
// Ограничение на размер файла (максимум 2 ГБ)
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2 ГБ
for (const partPath of partPaths) {
const partData = await fs.readFile(partPath);
// Проверяем размер перед записью
if (totalSize + partData.length > MAX_FILE_SIZE) {
console.error(`Превышен лимит размера файла - запрошенный размер: ${totalSize + partData.length}, лимит: ${MAX_FILE_SIZE}`);
// Удаляем недописанный файл
try {
writeStream.close();
await fs.unlink(mergedFilePath);
} catch (e) {}
return res.status(413).json({ error: 'File too large: Maximum size is 2GB' });
}
writeStream.write(partData);
totalSize += partData.length;
}
console.log(`Проверка размера пройдена: ${totalSize} bytes (limit: ${MAX_FILE_SIZE})`);
writeStream.end();
await new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
console.log(`Merged ${parts} parts into ${mergedFileName}, total size: ${totalSize} bytes`);
// Удаляем части после склейки
for (const partPath of partPaths) {
try {
await fs.unlink(partPath);
} catch (e) {
console.error(`Failed to remove part ${partPath}:`, e.message);
}
}
res.json({
filePath: path.join('merged', mergedFileName),
totalSize: totalSize
});
} catch (error) {
console.error('Merge error:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`File merge proxy listening on port ${PORT}`);
console.log(`Upload base path: ${UPLOAD_BASE}`);
});