517 lines
16 KiB
JavaScript
517 lines
16 KiB
JavaScript
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();
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MIME-нормализация: браузер-алиасы (Safari/Firefox/legacy) → канонические
|
|
// типы, которые принимает kie.ai. Применять до любой валидации и до загрузки
|
|
// в S3, чтобы файл лёг в MinIO с правильным Content-Type.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
const MIME_NORMALIZE = {
|
|
'audio/mp3': 'audio/mpeg',
|
|
'audio/x-m4a': 'audio/mp4',
|
|
'audio/m4a': 'audio/mp4',
|
|
'audio/wave': 'audio/wav',
|
|
};
|
|
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
|
|
const ALLOWED_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/aac', 'audio/mp4', 'audio/ogg'];
|
|
const ALLOWED_MEDIA_TYPES = [...ALLOWED_VIDEO_TYPES, ...ALLOWED_AUDIO_TYPES];
|
|
const MAX_AUDIO_SIZE = 10 * 1024 * 1024; // 10 МБ — лимит kie.ai для аудио
|
|
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 МБ — multipart upload
|
|
|
|
// Настройки 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;
|
|
|
|
// Нормализуем браузер-алиасы (audio/mp3 → audio/mpeg и т.п.) до валидации
|
|
const canonical = MIME_NORMALIZE[contentType] || contentType;
|
|
|
|
// Валидация по канонической форме
|
|
if (!ALLOWED_MEDIA_TYPES.includes(canonical)) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'INVALID_CONTENT_TYPE',
|
|
message: 'Разрешены форматы: MP4, MOV, AVI, WebM, MP3, WAV, AAC, M4A, OGG',
|
|
});
|
|
}
|
|
|
|
// Размер: для аудио — 10 МБ (лимит kie.ai), для видео — 500 МБ (multipart)
|
|
const isAudio = ALLOWED_AUDIO_TYPES.includes(canonical);
|
|
const maxSize = isAudio ? MAX_AUDIO_SIZE : MAX_VIDEO_SIZE;
|
|
if (!fileSize || fileSize > maxSize) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'INVALID_FILE_SIZE',
|
|
message: isAudio
|
|
? 'Размер аудиофайла должен быть от 1 байта до 10 МБ (требование kie.ai)'
|
|
: 'Размер видеофайла должен быть от 1 байта до 500 МБ',
|
|
});
|
|
}
|
|
|
|
if (!filename) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'INVALID_FILENAME',
|
|
message: 'Необходимо указать имя файла',
|
|
});
|
|
}
|
|
|
|
const result = await fileService.initMultipartUpload({
|
|
userId,
|
|
filename,
|
|
contentType: canonical,
|
|
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: 'Файл не загружен',
|
|
});
|
|
}
|
|
|
|
// Нормализуем браузер-алиасы (audio/mp3 → audio/mpeg и т.п.) до валидации.
|
|
// Перезаписываем file.mimetype, чтобы и S3, и БД использовали канонический Content-Type.
|
|
const canonical = MIME_NORMALIZE[file.mimetype] || file.mimetype;
|
|
file.mimetype = canonical;
|
|
|
|
// Валидация по канонической форме
|
|
if (!ALLOWED_MEDIA_TYPES.includes(canonical)) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'INVALID_FILE_TYPE',
|
|
message: 'Разрешены форматы: MP4, MOV, AVI, WebM, MP3, WAV, AAC, M4A, OGG',
|
|
});
|
|
}
|
|
|
|
const isAudio = ALLOWED_AUDIO_TYPES.includes(canonical);
|
|
|
|
// Аудио: 10 МБ — лимит kie.ai
|
|
if (isAudio && file.size > MAX_AUDIO_SIZE) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: 'AUDIO_TOO_LARGE',
|
|
message: 'Аудио до 10 МБ — требование kie.ai',
|
|
});
|
|
}
|
|
|
|
// Видео > 10 МБ — отправляем на multipart endpoint
|
|
if (!isAudio && 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;
|