Files
uno-click/bff/routes/upload.routes.js.bak.20260430
2026-05-13 14:20:41 +00:00

483 lines
14 KiB
Plaintext

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;