initial commit
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user