483 lines
14 KiB
Plaintext
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;
|