import { Router } from 'express'; import multer from 'multer'; import { authRequired } from '../middleware/authRequired.js'; import { validateImage } from '../middleware/validateImage.js'; import { csrfRequired } from '../middleware/csrfRequired.js'; import { fileService } from '../services/file.service.js'; const router = Router(); // Настройки multer для малых файлов (< 10MB) - для обратной совместимости const storage = multer.memoryStorage(); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024, // 10MB для простой загрузки }, }); // ───────────────────────────────────────────────────────────────────────────── // Multipart Upload endpoints (прямая загрузка в S3) // ───────────────────────────────────────────────────────────────────────────── /** * POST /api/upload/video/init * Инициировать multipart upload для прямой загрузки в S3 */ router.post('/video/init', authRequired, csrfRequired, async (req, res, next) => { try { const userId = req.user.id; const { filename, contentType, fileSize, generationUuid, generationStepId } = req.body; // Валидация const allowedVideoTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm']; const allowedAudioTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/aac', 'audio/mp4', 'audio/x-m4a', 'audio/ogg']; const allowedTypes = [...allowedVideoTypes, ...allowedAudioTypes]; if (!allowedTypes.includes(contentType)) { return res.status(400).json({ ok: false, error: 'INVALID_CONTENT_TYPE', message: 'Разрешены форматы: MP4, MOV, AVI, WebM, MP3, WAV, AAC, M4A, OGG', }); } const maxSize = 500 * 1024 * 1024; if (!fileSize || fileSize > maxSize) { return res.status(400).json({ ok: false, error: 'INVALID_FILE_SIZE', message: 'Размер файла должен быть от 1 байта до 500MB', }); } if (!filename) { return res.status(400).json({ ok: false, error: 'INVALID_FILENAME', message: 'Необходимо указать имя файла', }); } const result = await fileService.initMultipartUpload({ userId, filename, contentType, fileSize, folder: 'videos_input', generationUuid: generationUuid || null, generationStepId: generationStepId ? Number(generationStepId) : null, }); res.status(201).json({ ok: true, data: { fileId: result.fileId, s3Key: result.s3Key, uploadId: result.uploadId, parts: result.parts, // Array of { partNumber, presignedUrl } partCount: result.partCount, }, }); } catch (err) { next(err); } }); /** * POST /api/upload/video/complete * Завершить multipart upload */ router.post('/video/complete', authRequired, csrfRequired, async (req, res, next) => { try { const userId = req.user.id; const { fileId, uploadId, parts } = req.body; if (!fileId || !uploadId || !parts || !Array.isArray(parts)) { return res.status(400).json({ ok: false, error: 'BAD_REQUEST', message: 'Необходимо указать fileId, uploadId и parts', }); } // Валидация parts: каждый part должен иметь ETag for (let i = 0; i < parts.length; i++) { if (!parts[i].ETag) { return res.status(400).json({ ok: false, error: 'INVALID_PART', message: `Part ${i + 1} должен иметь ETag`, }); } } const result = await fileService.completeMultipartUpload({ fileId, userId, uploadId, parts, }); res.json({ ok: true, data: result, }); } catch (err) { if (err.code === 'FILE_NOT_FOUND') { return res.status(404).json({ ok: false, error: 'FILE_NOT_FOUND', message: err.message, }); } next(err); } }); /** * POST /api/upload/video/abort * Отменить multipart upload */ router.post('/video/abort', authRequired, csrfRequired, async (req, res, next) => { try { const userId = req.user.id; const { fileId, uploadId } = req.body; if (!fileId || !uploadId) { return res.status(400).json({ ok: false, error: 'BAD_REQUEST', message: 'Необходимо указать fileId и uploadId', }); } const result = await fileService.abortMultipartUpload({ fileId, userId, uploadId, }); res.json({ ok: true, data: result, }); } catch (err) { if (err.code === 'FILE_NOT_FOUND') { return res.status(404).json({ ok: false, error: 'FILE_NOT_FOUND', message: err.message, }); } next(err); } }); // ───────────────────────────────────────────────────────────────────────────── // Старые endpoints (для обратной совместимости с малыми файлами) // ───────────────────────────────────────────────────────────────────────────── /** * POST /api/upload/image * Загрузка изображения в S3 */ router.post('/image', authRequired, csrfRequired, upload.single('file'), validateImage, async (req, res, next) => { try { const file = req.file; const userId = req.user.id; const { generationUuid, generationStepId } = req.body; const fileRecord = await fileService.uploadImage({ userId, file, folder: 'images_input', generationUuid: generationUuid || null, generationStepId: generationStepId ? Number(generationStepId) : null, }); res.status(201).json({ ok: true, data: { id: fileRecord.id, s3Key: fileRecord.s3_key, filename: fileRecord.original_filename, size: fileRecord.file_size, contentType: fileRecord.content_type, generationUuid: fileRecord.generation_uuid, generationStepId: fileRecord.generation_step_id, createdAt: fileRecord.created_at, }, }); } catch (err) { next(err); } }); /** * POST /api/upload/video * Загрузка видео в S3 (для малых файлов < 10MB) * Для больших файлов используйте /api/upload/video/init + прямая загрузка */ router.post('/video', authRequired, csrfRequired, upload.single('file'), async (req, res, next) => { try { const file = req.file; const userId = req.user.id; const { generationUuid, generationStepId } = req.body; if (!file) { return res.status(400).json({ ok: false, error: 'NO_FILE', message: 'Файл не загружен', }); } // Валидация типа файла const allowedVideoTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm']; const allowedAudioTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/aac', 'audio/mp4', 'audio/x-m4a', 'audio/ogg']; const allowedTypes = [...allowedVideoTypes, ...allowedAudioTypes]; if (!allowedTypes.includes(file.mimetype)) { return res.status(400).json({ ok: false, error: 'INVALID_FILE_TYPE', message: 'Разрешены форматы: MP4, MOV, AVI, WebM, MP3, WAV, AAC, M4A, OGG', }); } // Для файлов > 10MB рекомендуем multipart upload if (file.size > 10 * 1024 * 1024) { return res.status(400).json({ ok: false, error: 'FILE_TOO_LARGE_FOR_SIMPLE_UPLOAD', message: 'Для файлов больше 10MB используйте multipart upload: POST /api/upload/video/init', }); } const fileRecord = await fileService.uploadVideo({ userId, file, folder: 'videos_input', generationUuid: generationUuid || null, generationStepId: generationStepId ? Number(generationStepId) : null, }); res.status(201).json({ ok: true, data: { id: fileRecord.id, s3Key: fileRecord.s3_key, filename: fileRecord.original_filename, size: fileRecord.file_size, contentType: fileRecord.content_type, generationUuid: fileRecord.generation_uuid, generationStepId: fileRecord.generation_step_id, createdAt: fileRecord.created_at, }, }); } catch (err) { next(err); } }); /** * POST /api/upload/url * Получить presigned URL для доступа к файлу * Требует: авторизация, CSRF, владение файлом */ router.post('/url', authRequired, csrfRequired, async (req, res, next) => { try { const { fileId, s3Key, expiresIn = 3600 } = req.body; if (!fileId && !s3Key) { return res.status(400).json({ ok: false, error: 'BAD_REQUEST', message: 'Необходимо указать fileId или s3Key', }); } const result = await fileService.getPresignedUrl({ userId: req.user.id, fileId, s3Key, expiresIn: Math.min(Number(expiresIn), 86400), // макс. 24 часа }); res.json({ ok: true, data: result, }); } catch (err) { if (err.code === 'FILE_NOT_FOUND') { return res.status(404).json({ ok: false, error: 'FILE_NOT_FOUND', message: err.message, }); } next(err); } }); /** * GET /api/upload/files * Получить список файлов пользователя * Требует: авторизация */ router.get('/files', authRequired, async (req, res, next) => { try { const { fileType = 'image', folder = 'images_input', limit = 50, offset = 0, } = req.query; const result = await fileService.listUserFiles(req.user.id, { fileType, folder, limit: Math.min(Number(limit), 100), offset: Number(offset), }); res.json({ ok: true, data: result, }); } catch (err) { next(err); } }); /** * GET /api/upload/stats * Получить статистику по файлам пользователя * Требует: авторизация */ router.get('/stats', authRequired, async (req, res, next) => { try { const stats = await fileService.getFileStats(req.user.id); res.json({ ok: true, data: stats, }); } catch (err) { next(err); } }); /** * DELETE /api/upload/file/:fileId * Удалить файл * Требует: авторизация, CSRF, владение файлом */ router.delete('/file/:fileId', authRequired, csrfRequired, async (req, res, next) => { try { const { fileId } = req.params; const userId = req.user.id; const result = await fileService.deleteFile({ userId, fileId: Number(fileId), }); res.json({ ok: true, data: result, }); } catch (err) { if (err.code === 'FILE_NOT_FOUND') { return res.status(404).json({ ok: false, error: 'FILE_NOT_FOUND', message: err.message, }); } next(err); } }); /** * GET /api/upload/generation/:generationUuid/files * Получить файлы генерации * Требует: авторизация, владение генерацией */ router.get('/generation/:generationUuid/files', authRequired, async (req, res, next) => { try { const { generationUuid } = req.params; const userId = req.user.id; const files = await fileService.getGenerationFiles(generationUuid, userId); res.json({ ok: true, data: files, }); } catch (err) { next(err); } }); /** * GET /api/upload/generation/:generationUuid/step/:stepId/files * Получить файлы шага генерации * Требует: авторизация, владение генерацией */ router.get('/generation/:generationUuid/step/:stepId/files', authRequired, async (req, res, next) => { try { const { generationUuid, stepId } = req.params; const userId = req.user.id; const files = await fileService.getGenerationStepFiles(generationUuid, stepId, userId); res.json({ ok: true, data: files, }); } catch (err) { next(err); } }); /** * PUT /api/upload/file/:fileId/link * Связать файл с генерацией * Требует: авторизация, CSRF, владение файлом и генерацией */ router.put('/file/:fileId/link', authRequired, csrfRequired, async (req, res, next) => { try { const { fileId } = req.params; const { generationUuid, generationStepId } = req.body; const userId = req.user.id; // Проверяем владение файлом const fileRecord = await userFileRepository.findByIdAndOwner(Number(fileId), userId); if (!fileRecord) { return res.status(404).json({ ok: false, error: 'FILE_NOT_FOUND', message: 'Файл не найден или у вас нет доступа к нему', }); } // Обновляем связь const { pool } = await import('../db.js'); const query = ` UPDATE uno_bff.user_files SET generation_uuid = $1, generation_step_id = $2, updated_at = now() WHERE id = $3 AND user_id = $4 RETURNING id, generation_uuid, generation_step_id `; const result = await pool.query(query, [generationUuid, generationStepId ? Number(generationStepId) : null, fileId, userId]); res.json({ ok: true, data: result.rows[0], }); } catch (err) { next(err); } }); export default router;