initial commit
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { loginUser, logoutUser, refreshUserSession, registerUser, loginWithTelegram, loginWithTelegramInitData, linkTelegramAccount, unlinkTelegramAccount, startTelegramLoginFlow, pollTelegramLoginFlow } from '../services/auth.service.js';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import { findUserById, updateUserBalance } from '../repositories/user.repository.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function buildAccessCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.ACCESS_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRefreshCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // фронту надо прочитать и отправить в header
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для работы с __Host- префиксом
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const result = await loginUser({
|
||||
email,
|
||||
password,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
await logoutUser({ sessionId: req.user.sessionId });
|
||||
|
||||
res.clearCookie(env.COOKIE_ACCESS_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_REFRESH_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_CSRF_NAME, { path: '/' });
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const refreshToken = req.cookies?.[env.COOKIE_REFRESH_NAME];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({
|
||||
error: 'REFRESH_TOKEN_MISSING',
|
||||
message: 'Refresh token is missing',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await refreshUserSession({
|
||||
refreshToken,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authRequired, async (req, res) => {
|
||||
try {
|
||||
const dbUser = await findUserById(req.user.id);
|
||||
if (!dbUser) return res.status(401).json({ error: 'USER_NOT_FOUND' });
|
||||
res.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
displayName: dbUser.display_name,
|
||||
role: dbUser.role,
|
||||
status: dbUser.status,
|
||||
balance: Number(dbUser.balance || 0),
|
||||
createdAt: dbUser.created_at,
|
||||
sessionId: req.user.sessionId,
|
||||
telegramLinked: dbUser.telegram_id != null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/balance/update', authRequired, async (req, res) => {
|
||||
const delta = Number(req.body.delta);
|
||||
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'delta required' });
|
||||
try {
|
||||
const newBalance = await updateUserBalance(req.user.id, delta);
|
||||
if (newBalance === null) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ ok: true, balance: Number(newBalance) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/csrf', authRequired, async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
csrfToken: req.cookies?.[env.COOKIE_CSRF_NAME] || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
const result = await registerUser({
|
||||
email,
|
||||
password,
|
||||
displayName: name || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 409) {
|
||||
return res.status(409).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
if (err.statusCode === 400) {
|
||||
return res.status(400).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post('/telegram', async (req, res, next) => {
|
||||
try {
|
||||
const result = await loginWithTelegram({
|
||||
telegramData: req.body,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(200).json({ ok: true, accessToken: result.accessToken, user: result.user });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) {
|
||||
return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.get('/telegram-widget', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send('<!DOCTYPE html>\n<html lang="ru"><head><meta charset="utf-8"/><title>Telegram Auth</title><style>*{margin:0;padding:0}body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:#1a1a2e;font-family:sans-serif;color:#fff}h2{font-size:24px;font-weight:700;margin-bottom:6px}p{font-size:13px;color:#aaa;margin-bottom:28px}.wrap{min-height:48px;display:flex;align-items:center}</style></head><body><h2>One Click</h2><p>Войдите через Telegram</p><div class="wrap" id="tg"></div><script>window.onTelegramAuth=function(u){if(window.opener){window.opener.postMessage({type:"telegram_auth",data:u},"*");}window.close();};</script><script async src="https://telegram.org/js/telegram-widget.js?22" data-telegram-login="One_Click_Auth_bot" data-size="large" data-radius="12" data-onauth="onTelegramAuth(user)" data-request-access="write"></script></body></html>');
|
||||
});
|
||||
|
||||
router.post('/telegram/link', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await linkTelegramAccount({ userId: req.user.id, telegramData: req.body });
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/telegram/unlink', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await unlinkTelegramAccount(req.user.id);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post('/telegram/start', async (req, res, next) => {
|
||||
try {
|
||||
const result = await startTelegramLoginFlow({
|
||||
intent: 'login',
|
||||
userId: null,
|
||||
ipAddress: req.ip || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
});
|
||||
res.json({ ok: true, token: result.token, url: result.url, ttlSec: result.ttlSec });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/telegram/start-link', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await startTelegramLoginFlow({
|
||||
intent: 'link',
|
||||
userId: req.user.id,
|
||||
ipAddress: req.ip || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
});
|
||||
res.json({ ok: true, token: result.token, url: result.url, ttlSec: result.ttlSec });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/telegram/poll/:token', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pollTelegramLoginFlow({
|
||||
token: req.params.token,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
if (result.status === 'authenticated') {
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
return res.json({ ok: true, status: 'authenticated', accessToken: result.accessToken, user: result.user });
|
||||
}
|
||||
if (result.status === 'linked') return res.json({ ok: true, status: 'linked' });
|
||||
if (result.status === 'pending') return res.json({ ok: true, status: 'pending' });
|
||||
if (result.status === 'consumed') return res.status(410).json({ ok: false, status: 'consumed', message: 'Эта ссылка уже использована.' });
|
||||
if (result.status === 'expired') return res.status(410).json({ ok: false, status: 'expired', message: 'Срок действия ссылки истёк.' });
|
||||
if (result.status === 'not_found') return res.status(404).json({ ok: false, status: 'not_found', message: 'Запрос не найден.' });
|
||||
if (result.status === 'error') return res.status(400).json({ ok: false, status: 'error', error: result.code });
|
||||
return res.json({ ok: true, status: result.status });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
export default router;
|
||||
router.post('/telegram/webapp', async (req, res, next) => {
|
||||
try {
|
||||
const result = await loginWithTelegramInitData({
|
||||
initData: req.body && req.body.initData,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(200).json({ ok: true, accessToken: result.accessToken, user: result.user });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) {
|
||||
return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { loginUser, logoutUser, refreshUserSession, registerUser, loginWithTelegram } from '../services/auth.service.js';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import { findUserById, updateUserBalance } from '../repositories/user.repository.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function buildAccessCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.ACCESS_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRefreshCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // фронту надо прочитать и отправить в header
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для работы с __Host- префиксом
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const result = await loginUser({
|
||||
email,
|
||||
password,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
await logoutUser({ sessionId: req.user.sessionId });
|
||||
|
||||
res.clearCookie(env.COOKIE_ACCESS_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_REFRESH_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_CSRF_NAME, { path: '/' });
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const refreshToken = req.cookies?.[env.COOKIE_REFRESH_NAME];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({
|
||||
error: 'REFRESH_TOKEN_MISSING',
|
||||
message: 'Refresh token is missing',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await refreshUserSession({
|
||||
refreshToken,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authRequired, async (req, res) => {
|
||||
try {
|
||||
const dbUser = await findUserById(req.user.id);
|
||||
if (!dbUser) return res.status(401).json({ error: 'USER_NOT_FOUND' });
|
||||
res.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
displayName: dbUser.display_name,
|
||||
role: dbUser.role,
|
||||
status: dbUser.status,
|
||||
balance: Number(dbUser.balance || 0),
|
||||
createdAt: dbUser.created_at,
|
||||
sessionId: req.user.sessionId,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/balance/update', authRequired, async (req, res) => {
|
||||
const delta = Number(req.body.delta);
|
||||
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'delta required' });
|
||||
try {
|
||||
const newBalance = await updateUserBalance(req.user.id, delta);
|
||||
if (newBalance === null) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ ok: true, balance: Number(newBalance) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/csrf', authRequired, async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
csrfToken: req.cookies?.[env.COOKIE_CSRF_NAME] || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
const result = await registerUser({
|
||||
email,
|
||||
password,
|
||||
displayName: name || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 409) {
|
||||
return res.status(409).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
if (err.statusCode === 400) {
|
||||
return res.status(400).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post('/telegram', async (req, res, next) => {
|
||||
try {
|
||||
const result = await loginWithTelegram({
|
||||
telegramData: req.body,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(200).json({ ok: true, accessToken: result.accessToken, user: result.user });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) {
|
||||
return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.get('/telegram-widget', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send('<!DOCTYPE html>\n<html lang="ru"><head><meta charset="utf-8"/><title>Telegram Auth</title><style>*{margin:0;padding:0}body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:#1a1a2e;font-family:sans-serif;color:#fff}h2{font-size:24px;font-weight:700;margin-bottom:6px}p{font-size:13px;color:#aaa;margin-bottom:28px}.wrap{min-height:48px;display:flex;align-items:center}</style></head><body><h2>One Click</h2><p>Войдите через Telegram</p><div class="wrap" id="tg"></div><script>window.onTelegramAuth=function(u){if(window.opener){window.opener.postMessage({type:"telegram_auth",data:u},"*");}window.close();};</script><script async src="https://telegram.org/js/telegram-widget.js?22" data-telegram-login="generatet_bot" data-size="large" data-radius="12" data-onauth="onTelegramAuth(user)" data-request-access="write"></script></body></html>');
|
||||
});
|
||||
export default router;
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { loginUser, logoutUser, refreshUserSession, registerUser, loginWithTelegram } from '../services/auth.service.js';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import { findUserById, updateUserBalance } from '../repositories/user.repository.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function buildAccessCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.ACCESS_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRefreshCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // фронту надо прочитать и отправить в header
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для работы с __Host- префиксом
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const result = await loginUser({
|
||||
email,
|
||||
password,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
await logoutUser({ sessionId: req.user.sessionId });
|
||||
|
||||
res.clearCookie(env.COOKIE_ACCESS_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_REFRESH_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_CSRF_NAME, { path: '/' });
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const refreshToken = req.cookies?.[env.COOKIE_REFRESH_NAME];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({
|
||||
error: 'REFRESH_TOKEN_MISSING',
|
||||
message: 'Refresh token is missing',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await refreshUserSession({
|
||||
refreshToken,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authRequired, async (req, res) => {
|
||||
try {
|
||||
const dbUser = await findUserById(req.user.id);
|
||||
if (!dbUser) return res.status(401).json({ error: 'USER_NOT_FOUND' });
|
||||
res.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
displayName: dbUser.display_name,
|
||||
role: dbUser.role,
|
||||
status: dbUser.status,
|
||||
balance: Number(dbUser.balance || 0),
|
||||
createdAt: dbUser.created_at,
|
||||
sessionId: req.user.sessionId,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/balance/update', authRequired, async (req, res) => {
|
||||
const delta = Number(req.body.delta);
|
||||
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'delta required' });
|
||||
try {
|
||||
const newBalance = await updateUserBalance(req.user.id, delta);
|
||||
if (newBalance === null) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ ok: true, balance: Number(newBalance) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/csrf', authRequired, async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
csrfToken: req.cookies?.[env.COOKIE_CSRF_NAME] || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
const result = await registerUser({
|
||||
email,
|
||||
password,
|
||||
displayName: name || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 409) {
|
||||
return res.status(409).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
if (err.statusCode === 400) {
|
||||
return res.status(400).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post('/telegram', async (req, res, next) => {
|
||||
try {
|
||||
const result = await loginWithTelegram({
|
||||
telegramData: req.body,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(200).json({ ok: true, accessToken: result.accessToken, user: result.user });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) {
|
||||
return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.get('/telegram-widget', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send('<!DOCTYPE html>\n<html lang="ru"><head><meta charset="utf-8"/><title>Telegram Auth</title><style>*{margin:0;padding:0}body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:#1a1a2e;font-family:sans-serif;color:#fff}h2{font-size:24px;font-weight:700;margin-bottom:6px}p{font-size:13px;color:#aaa;margin-bottom:28px}.wrap{min-height:48px;display:flex;align-items:center}</style></head><body><h2>One Click</h2><p>Войдите через Telegram</p><div class="wrap" id="tg"></div><script>window.onTelegramAuth=function(u){if(window.opener){window.opener.postMessage({type:"telegram_auth",data:u},"*");}window.close();};</script><script async src="https://telegram.org/js/telegram-widget.js?22" data-telegram-login="One_Click_Auth_bot" data-size="large" data-radius="12" data-onauth="onTelegramAuth(user)" data-request-access="write"></script></body></html>');
|
||||
});
|
||||
export default router;
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { loginUser, logoutUser, refreshUserSession, registerUser, loginWithTelegram, linkTelegramAccount, unlinkTelegramAccount } from '../services/auth.service.js';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import { findUserById, updateUserBalance } from '../repositories/user.repository.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function buildAccessCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.ACCESS_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRefreshCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // фронту надо прочитать и отправить в header
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для работы с __Host- префиксом
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const result = await loginUser({
|
||||
email,
|
||||
password,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
await logoutUser({ sessionId: req.user.sessionId });
|
||||
|
||||
res.clearCookie(env.COOKIE_ACCESS_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_REFRESH_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_CSRF_NAME, { path: '/' });
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const refreshToken = req.cookies?.[env.COOKIE_REFRESH_NAME];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({
|
||||
error: 'REFRESH_TOKEN_MISSING',
|
||||
message: 'Refresh token is missing',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await refreshUserSession({
|
||||
refreshToken,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authRequired, async (req, res) => {
|
||||
try {
|
||||
const dbUser = await findUserById(req.user.id);
|
||||
if (!dbUser) return res.status(401).json({ error: 'USER_NOT_FOUND' });
|
||||
res.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
displayName: dbUser.display_name,
|
||||
role: dbUser.role,
|
||||
status: dbUser.status,
|
||||
balance: Number(dbUser.balance || 0),
|
||||
createdAt: dbUser.created_at,
|
||||
sessionId: req.user.sessionId,
|
||||
telegramLinked: dbUser.telegram_id != null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/balance/update', authRequired, async (req, res) => {
|
||||
const delta = Number(req.body.delta);
|
||||
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'delta required' });
|
||||
try {
|
||||
const newBalance = await updateUserBalance(req.user.id, delta);
|
||||
if (newBalance === null) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ ok: true, balance: Number(newBalance) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/csrf', authRequired, async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
csrfToken: req.cookies?.[env.COOKIE_CSRF_NAME] || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
const result = await registerUser({
|
||||
email,
|
||||
password,
|
||||
displayName: name || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 409) {
|
||||
return res.status(409).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
if (err.statusCode === 400) {
|
||||
return res.status(400).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post('/telegram', async (req, res, next) => {
|
||||
try {
|
||||
const result = await loginWithTelegram({
|
||||
telegramData: req.body,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(200).json({ ok: true, accessToken: result.accessToken, user: result.user });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) {
|
||||
return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.get('/telegram-widget', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send('<!DOCTYPE html>\n<html lang="ru"><head><meta charset="utf-8"/><title>Telegram Auth</title><style>*{margin:0;padding:0}body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:#1a1a2e;font-family:sans-serif;color:#fff}h2{font-size:24px;font-weight:700;margin-bottom:6px}p{font-size:13px;color:#aaa;margin-bottom:28px}.wrap{min-height:48px;display:flex;align-items:center}</style></head><body><h2>One Click</h2><p>Войдите через Telegram</p><div class="wrap" id="tg"></div><script>window.onTelegramAuth=function(u){if(window.opener){window.opener.postMessage({type:"telegram_auth",data:u},"*");}window.close();};</script><script async src="https://telegram.org/js/telegram-widget.js?22" data-telegram-login="One_Click_Auth_bot" data-size="large" data-radius="12" data-onauth="onTelegramAuth(user)" data-request-access="write"></script></body></html>');
|
||||
});
|
||||
|
||||
router.post('/telegram/link', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await linkTelegramAccount({ userId: req.user.id, telegramData: req.body });
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/telegram/unlink', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await unlinkTelegramAccount(req.user.id);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { loginUser, logoutUser, refreshUserSession, registerUser, loginWithTelegram, linkTelegramAccount, unlinkTelegramAccount, startTelegramLoginFlow, pollTelegramLoginFlow } from '../services/auth.service.js';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import { findUserById, updateUserBalance } from '../repositories/user.repository.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function buildAccessCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.ACCESS_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRefreshCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для __Host- префикса (требование RFC 6265)
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // фронту надо прочитать и отправить в header
|
||||
secure: env.COOKIE_SECURE,
|
||||
sameSite: env.COOKIE_SAME_SITE,
|
||||
// Domain не указывается для работы с __Host- префиксом
|
||||
path: '/',
|
||||
maxAge: env.REFRESH_TOKEN_TTL_SEC * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const result = await loginUser({
|
||||
email,
|
||||
password,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
await logoutUser({ sessionId: req.user.sessionId });
|
||||
|
||||
res.clearCookie(env.COOKIE_ACCESS_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_REFRESH_NAME, { path: '/' });
|
||||
res.clearCookie(env.COOKIE_CSRF_NAME, { path: '/' });
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const refreshToken = req.cookies?.[env.COOKIE_REFRESH_NAME];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({
|
||||
error: 'REFRESH_TOKEN_MISSING',
|
||||
message: 'Refresh token is missing',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await refreshUserSession({
|
||||
refreshToken,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authRequired, async (req, res) => {
|
||||
try {
|
||||
const dbUser = await findUserById(req.user.id);
|
||||
if (!dbUser) return res.status(401).json({ error: 'USER_NOT_FOUND' });
|
||||
res.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
displayName: dbUser.display_name,
|
||||
role: dbUser.role,
|
||||
status: dbUser.status,
|
||||
balance: Number(dbUser.balance || 0),
|
||||
createdAt: dbUser.created_at,
|
||||
sessionId: req.user.sessionId,
|
||||
telegramLinked: dbUser.telegram_id != null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/balance/update', authRequired, async (req, res) => {
|
||||
const delta = Number(req.body.delta);
|
||||
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'delta required' });
|
||||
try {
|
||||
const newBalance = await updateUserBalance(req.user.id, delta);
|
||||
if (newBalance === null) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ ok: true, balance: Number(newBalance) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/csrf', authRequired, async (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
csrfToken: req.cookies?.[env.COOKIE_CSRF_NAME] || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
const result = await registerUser({
|
||||
email,
|
||||
password,
|
||||
displayName: name || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 409) {
|
||||
return res.status(409).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
if (err.statusCode === 400) {
|
||||
return res.status(400).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post('/telegram', async (req, res, next) => {
|
||||
try {
|
||||
const result = await loginWithTelegram({
|
||||
telegramData: req.body,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
|
||||
res.status(200).json({ ok: true, accessToken: result.accessToken, user: result.user });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) {
|
||||
return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.get('/telegram-widget', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send('<!DOCTYPE html>\n<html lang="ru"><head><meta charset="utf-8"/><title>Telegram Auth</title><style>*{margin:0;padding:0}body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:#1a1a2e;font-family:sans-serif;color:#fff}h2{font-size:24px;font-weight:700;margin-bottom:6px}p{font-size:13px;color:#aaa;margin-bottom:28px}.wrap{min-height:48px;display:flex;align-items:center}</style></head><body><h2>One Click</h2><p>Войдите через Telegram</p><div class="wrap" id="tg"></div><script>window.onTelegramAuth=function(u){if(window.opener){window.opener.postMessage({type:"telegram_auth",data:u},"*");}window.close();};</script><script async src="https://telegram.org/js/telegram-widget.js?22" data-telegram-login="One_Click_Auth_bot" data-size="large" data-radius="12" data-onauth="onTelegramAuth(user)" data-request-access="write"></script></body></html>');
|
||||
});
|
||||
|
||||
router.post('/telegram/link', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await linkTelegramAccount({ userId: req.user.id, telegramData: req.body });
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/telegram/unlink', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await unlinkTelegramAccount(req.user.id);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post('/telegram/start', async (req, res, next) => {
|
||||
try {
|
||||
const result = await startTelegramLoginFlow({
|
||||
intent: 'login',
|
||||
userId: null,
|
||||
ipAddress: req.ip || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
});
|
||||
res.json({ ok: true, token: result.token, url: result.url, ttlSec: result.ttlSec });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/telegram/start-link', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const result = await startTelegramLoginFlow({
|
||||
intent: 'link',
|
||||
userId: req.user.id,
|
||||
ipAddress: req.ip || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
});
|
||||
res.json({ ok: true, token: result.token, url: result.url, ttlSec: result.ttlSec });
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500;
|
||||
if (status < 500) return res.status(status).json({ ok: false, error: err.code, message: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/telegram/poll/:token', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pollTelegramLoginFlow({
|
||||
token: req.params.token,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
ipAddress: req.ip || null,
|
||||
});
|
||||
if (result.status === 'authenticated') {
|
||||
res.cookie(env.COOKIE_ACCESS_NAME, result.accessToken, buildAccessCookieOptions());
|
||||
res.cookie(env.COOKIE_REFRESH_NAME, result.refreshToken, buildRefreshCookieOptions());
|
||||
res.cookie(env.COOKIE_CSRF_NAME, result.csrfToken, buildCsrfCookieOptions());
|
||||
return res.json({ ok: true, status: 'authenticated', accessToken: result.accessToken, user: result.user });
|
||||
}
|
||||
if (result.status === 'linked') return res.json({ ok: true, status: 'linked' });
|
||||
if (result.status === 'pending') return res.json({ ok: true, status: 'pending' });
|
||||
if (result.status === 'consumed') return res.status(410).json({ ok: false, status: 'consumed', message: 'Эта ссылка уже использована.' });
|
||||
if (result.status === 'expired') return res.status(410).json({ ok: false, status: 'expired', message: 'Срок действия ссылки истёк.' });
|
||||
if (result.status === 'not_found') return res.status(404).json({ ok: false, status: 'not_found', message: 'Запрос не найден.' });
|
||||
if (result.status === 'error') return res.status(400).json({ ok: false, status: 'error', error: result.code });
|
||||
return res.json({ ok: true, status: result.status });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Router } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import multer from "multer";
|
||||
import {
|
||||
S3Client, PutObjectCommand, GetObjectCommand,
|
||||
CreateBucketCommand, HeadBucketCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import pg from "pg";
|
||||
import { authRequired } from "../middleware/authRequired.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
const router = Router();
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.S3_REGION || "us-east-1",
|
||||
endpoint: process.env.S3_ENDPOINT || "http://127.0.0.1:9000",
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "UN0-admin",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "RAygtZHqGN49qKn",
|
||||
},
|
||||
});
|
||||
const BUCKET = process.env.S3_BUCKET || "uno-click";
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.PG_HOST || "127.0.0.1",
|
||||
port: Number(process.env.PG_PORT || 5432),
|
||||
database: process.env.PG_DATABASE || "n8n",
|
||||
user: process.env.PG_USER || "n8n",
|
||||
password: process.env.PG_PASSWORD,
|
||||
});
|
||||
|
||||
async function ensureBucket() {
|
||||
try { await s3.send(new HeadBucketCommand({ Bucket: BUCKET })); }
|
||||
catch { await s3.send(new CreateBucketCommand({ Bucket: BUCKET })); }
|
||||
}
|
||||
ensureBucket().catch(console.error);
|
||||
|
||||
// Сохраняем таблицу video_jobs для обратной совместимости (старые задания)
|
||||
pool.query(`CREATE TABLE IF NOT EXISTS video_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
input_s3_key TEXT,
|
||||
output_s3_key TEXT,
|
||||
error_msg TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`).then(() => console.log("[media] video_jobs table ready")).catch(console.error);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 500 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith("video/")) cb(null, true);
|
||||
else cb(new Error("Only video files allowed"));
|
||||
},
|
||||
});
|
||||
|
||||
// Рандомные параметры уникализации для ffmpeg-api
|
||||
function buildFFmpegArgs() {
|
||||
const r = (a, b) => Math.random() * (b - a) + a;
|
||||
const hue = r(0, 5).toFixed(1);
|
||||
const sat = r(0, 5).toFixed(1);
|
||||
const br = r(-0.05, 0.05).toFixed(2);
|
||||
const con = r(0.98, 1.05).toFixed(2);
|
||||
const satv = r(0.95, 1.1).toFixed(2);
|
||||
const noise = Math.round(r(10, 20));
|
||||
const speed = r(0.97, 0.99).toFixed(3);
|
||||
const tempo = (1 / parseFloat(speed)).toFixed(3);
|
||||
const scale = r(0.94, 0.98).toFixed(2);
|
||||
const pad = (1 / parseFloat(scale)).toFixed(4);
|
||||
const vol = r(1.0, 1.1).toFixed(2);
|
||||
const crf = Math.round(r(20, 24));
|
||||
|
||||
return [
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", String(crf),
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-vf", [
|
||||
`hue=s=${sat}:h=${hue}`,
|
||||
`eq=brightness=${br}:contrast=${con}:saturation=${satv}`,
|
||||
`noise=alls=${noise}:allf=t+u`,
|
||||
`setpts=${speed}*PTS`,
|
||||
`scale=iw*${scale}:ih*${scale}`,
|
||||
`pad=iw*${pad}:ih*${pad}:(ow-iw)/2:(oh-ih)/2`,
|
||||
].join(","),
|
||||
"-af", `volume=${vol},atempo=${tempo}`,
|
||||
"-max_muxing_queue_size", "1024",
|
||||
];
|
||||
}
|
||||
|
||||
// ─── POST /api/media/upload-video ─────────────────────────────────────────────
|
||||
// Принимает видеофайл, сохраняет в MinIO, привязывает к активному сценарию.
|
||||
// Прямой вызов FFmpeg удалён — запуск уникализации происходит через
|
||||
// POST /api/scenario/basic-unique/step/run-video → n8n → ffmpeg-api.
|
||||
router.post("/upload-video", authRequired, upload.single("video"), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: "Field video required" });
|
||||
|
||||
const jobId = randomUUID();
|
||||
const userId = req.user.id;
|
||||
const inputKey = `videos/input/${userId}/${jobId}.mp4`;
|
||||
const outputKey = `videos/output/${userId}/${jobId}.mp4`;
|
||||
|
||||
// generationUuid может прийти в теле формы (FormData) или из JSON
|
||||
const generationUuid = req.body?.generationUuid || null;
|
||||
|
||||
try {
|
||||
// 1. Сохраняем оригинальное видео в MinIO
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: inputKey,
|
||||
Body: req.file.buffer,
|
||||
ContentType: req.file.mimetype || "video/mp4",
|
||||
}));
|
||||
|
||||
// 2. Если передан generationUuid — сохраняем шаг upload-video в БД,
|
||||
// чтобы шаг run-video мог прочитать ключи S3 и аргументы ffmpeg.
|
||||
if (generationUuid) {
|
||||
const stepPayload = {
|
||||
input_s3_key: inputKey,
|
||||
output_s3_key: outputKey,
|
||||
ffmpeg_args: buildFFmpegArgs(),
|
||||
};
|
||||
await pool.query(`
|
||||
INSERT INTO uno_bff.generation_steps
|
||||
(generation_uuid, scenario_id, step_id, step_order, status, request_payload, started_at, finished_at)
|
||||
VALUES ($1, 'basic-unique', 'upload-video', 1, 'completed', $2, NOW(), NOW())
|
||||
ON CONFLICT (generation_uuid, step_id) DO UPDATE
|
||||
SET status = 'completed',
|
||||
request_payload = $2,
|
||||
finished_at = NOW(),
|
||||
updated_at = NOW()
|
||||
`, [generationUuid, JSON.stringify(stepPayload)]);
|
||||
|
||||
console.log(`[upload-video] Linked to generation ${generationUuid}, inputKey=${inputKey}`);
|
||||
}
|
||||
|
||||
// 3. Отвечаем немедленно — FFmpeg НЕ вызываем здесь
|
||||
return res.json({
|
||||
ok: true,
|
||||
job_id: jobId,
|
||||
input_s3_key: inputKey,
|
||||
output_s3_key: outputKey,
|
||||
generationUuid: generationUuid || null,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[upload-video]", err);
|
||||
if (!res.headersSent) res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/media/status/:job_id ────────────────────────────────────────────
|
||||
// Оставлен для обратной совместимости (старые задания через video_jobs).
|
||||
router.get("/status/:job_id", async (req, res) => {
|
||||
const { job_id } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT status, output_s3_key, error_msg FROM video_jobs WHERE job_id=$1",
|
||||
[job_id],
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Job not found" });
|
||||
|
||||
const job = rows[0];
|
||||
const PUBLIC_BFF = process.env.PUBLIC_BFF_URL || "https://uno-click.pip-test.ru";
|
||||
const url = job.status === "done"
|
||||
? `${PUBLIC_BFF}/api/media/download/${job_id}`
|
||||
: null;
|
||||
|
||||
res.json({ status: job.status, url, error: job.error_msg || null });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/media/download/:job_id ──────────────────────────────────────────
|
||||
// Оставлен для обратной совместимости (старые задания через video_jobs).
|
||||
router.get("/download/:job_id", async (req, res) => {
|
||||
const { job_id } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT status, output_s3_key FROM video_jobs WHERE job_id=$1",
|
||||
[job_id],
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Job not found" });
|
||||
if (rows[0].status !== "done") return res.status(409).json({ error: "Not ready yet" });
|
||||
|
||||
const s3Resp = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: rows[0].output_s3_key }),
|
||||
);
|
||||
res.setHeader("Content-Type", "video/mp4");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="uniqueized_${job_id}.mp4"`);
|
||||
if (s3Resp.ContentLength) res.setHeader("Content-Length", s3Resp.ContentLength);
|
||||
s3Resp.Body.pipe(res);
|
||||
} catch (err) {
|
||||
console.error("[download]", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── GET /api/media/video/:generationUuid ─────────────────────────────────────
|
||||
// Стримит уникализованное видео из MinIO по generationUuid.
|
||||
// URL используется n8n result-воркфлоу в ответе на поллинг.
|
||||
router.get('/video/:generationUuid', async (req, res) => {
|
||||
const { generationUuid } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT response_payload FROM uno_bff.generation_steps
|
||||
WHERE generation_uuid = $1 AND step_id = 'run-video' AND status = 'completed'
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[generationUuid]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Video not ready or not found' });
|
||||
|
||||
const payload = rows[0].response_payload;
|
||||
const outputKey = typeof payload === 'string' ? JSON.parse(payload).output_s3_key : payload.output_s3_key;
|
||||
if (!outputKey) return res.status(404).json({ error: 'output_s3_key missing' });
|
||||
|
||||
const s3Resp = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: outputKey })
|
||||
);
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=video_${generationUuid}.mp4`);
|
||||
if (s3Resp.ContentLength) res.setHeader('Content-Length', s3Resp.ContentLength);
|
||||
s3Resp.Body.pipe(res);
|
||||
} catch (err) {
|
||||
console.error('[video-by-generation]', err);
|
||||
if (!res.headersSent) res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Router } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import multer from "multer";
|
||||
import {
|
||||
S3Client, PutObjectCommand, GetObjectCommand,
|
||||
CreateBucketCommand, HeadBucketCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
const router = Router();
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.S3_REGION || "us-east-1",
|
||||
endpoint: process.env.S3_ENDPOINT || "http://127.0.0.1:9000",
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "UN0-admin",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "RAygtZHqGN49qKn",
|
||||
},
|
||||
});
|
||||
const BUCKET = process.env.S3_BUCKET || "uno-click";
|
||||
|
||||
// ffmpeg-api теперь доступен на хосте через 127.0.0.1:8000
|
||||
const FFMPEG_API = process.env.FFMPEG_API_URL || "http://127.0.0.1:8000";
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.PG_HOST || "127.0.0.1",
|
||||
port: Number(process.env.PG_PORT || 5432),
|
||||
database: process.env.PG_DATABASE || "n8n",
|
||||
user: process.env.PG_USER || "n8n",
|
||||
password: process.env.PG_PASSWORD,
|
||||
});
|
||||
|
||||
async function ensureBucket() {
|
||||
try { await s3.send(new HeadBucketCommand({ Bucket: BUCKET })); }
|
||||
catch { await s3.send(new CreateBucketCommand({ Bucket: BUCKET })); }
|
||||
}
|
||||
ensureBucket().catch(console.error);
|
||||
|
||||
pool.query(`CREATE TABLE IF NOT EXISTS video_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
input_s3_key TEXT,
|
||||
output_s3_key TEXT,
|
||||
error_msg TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`).then(() => console.log("[media] video_jobs table ready")).catch(console.error);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 500 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith("video/")) cb(null, true);
|
||||
else cb(new Error("Only video files allowed"));
|
||||
},
|
||||
});
|
||||
|
||||
// Рандомные параметры уникализации для ffmpeg-api
|
||||
function buildFFmpegArgs() {
|
||||
const r = (a, b) => Math.random() * (b - a) + a;
|
||||
const hue = r(0, 5).toFixed(1);
|
||||
const sat = r(0, 5).toFixed(1);
|
||||
const br = r(-0.05, 0.05).toFixed(2);
|
||||
const con = r(0.98, 1.05).toFixed(2);
|
||||
const satv = r(0.95, 1.1).toFixed(2);
|
||||
const noise = Math.round(r(10, 20));
|
||||
const speed = r(0.97, 0.99).toFixed(3);
|
||||
const tempo = (1 / parseFloat(speed)).toFixed(3);
|
||||
const scale = r(0.94, 0.98).toFixed(2);
|
||||
const pad = (1 / parseFloat(scale)).toFixed(4);
|
||||
const vol = r(1.0, 1.1).toFixed(2);
|
||||
const crf = Math.round(r(20, 24));
|
||||
|
||||
return [
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", String(crf),
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-vf", [
|
||||
`hue=s=${sat}:h=${hue}`,
|
||||
`eq=brightness=${br}:contrast=${con}:saturation=${satv}`,
|
||||
`noise=alls=${noise}:allf=t+u`,
|
||||
`setpts=${speed}*PTS`,
|
||||
`scale=iw*${scale}:ih*${scale}`,
|
||||
`pad=iw*${pad}:ih*${pad}:(ow-iw)/2:(oh-ih)/2`,
|
||||
].join(","),
|
||||
"-af", `volume=${vol},atempo=${tempo}`,
|
||||
"-max_muxing_queue_size", "1024",
|
||||
];
|
||||
}
|
||||
|
||||
// Вызывает ffmpeg-api /run-s3 асинхронно, потом обновляет БД
|
||||
async function processVideoJob(jobId, inputKey, outputKey) {
|
||||
console.log("[media] calling ffmpeg-api for job:", jobId);
|
||||
try {
|
||||
await pool.query(
|
||||
"UPDATE video_jobs SET status='processing', updated_at=NOW() WHERE job_id=$1",
|
||||
[jobId],
|
||||
);
|
||||
|
||||
const body = {
|
||||
job_id: jobId,
|
||||
input_s3: `s3://${BUCKET}/${inputKey}`,
|
||||
output_s3: `s3://${BUCKET}/${outputKey}`,
|
||||
ffmpeg_args: buildFFmpegArgs(),
|
||||
};
|
||||
|
||||
const resp = await fetch(`${FFMPEG_API}/run-s3`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(600_000), // 10 минут
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
console.log("[media] ffmpeg-api response:", resp.status, JSON.stringify(data).slice(0, 200));
|
||||
|
||||
if (!resp.ok || !data.ok) {
|
||||
throw new Error(data.detail ? JSON.stringify(data.detail) : "ffmpeg-api error");
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
"UPDATE video_jobs SET status='done', updated_at=NOW() WHERE job_id=$1",
|
||||
[jobId],
|
||||
);
|
||||
console.log("[media] job done:", jobId);
|
||||
} catch (err) {
|
||||
console.error("[media] job failed:", jobId, err.message);
|
||||
await pool.query(
|
||||
"UPDATE video_jobs SET status='error', error_msg=$2, updated_at=NOW() WHERE job_id=$1",
|
||||
[jobId, err.message],
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /api/media/upload-video ─────────────────────────────────────────────
|
||||
router.post("/upload-video", upload.single("video"), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: "Field video required" });
|
||||
const jobId = randomUUID();
|
||||
const inputKey = `videos/input/${jobId}.mp4`;
|
||||
const outputKey = `videos/output/${jobId}.mp4`;
|
||||
|
||||
try {
|
||||
// 1. Загружаем в MinIO
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET, Key: inputKey,
|
||||
Body: req.file.buffer, ContentType: req.file.mimetype || "video/mp4",
|
||||
}));
|
||||
|
||||
// 2. Создаём задачу в БД
|
||||
await pool.query(
|
||||
"INSERT INTO video_jobs (job_id, status, input_s3_key, output_s3_key) VALUES ($1,$2,$3,$4)",
|
||||
[jobId, "queued", inputKey, outputKey],
|
||||
);
|
||||
|
||||
// 3. Отвечаем фронту немедленно
|
||||
res.json({ ok: true, job_id: jobId });
|
||||
|
||||
// 4. Запускаем FFmpeg через ffmpeg-api в фоне (не блокирует ответ)
|
||||
processVideoJob(jobId, inputKey, outputKey);
|
||||
|
||||
} catch (err) {
|
||||
console.error("[upload-video]", err);
|
||||
if (!res.headersSent) res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/media/status/:job_id ────────────────────────────────────────────
|
||||
router.get("/status/:job_id", async (req, res) => {
|
||||
const { job_id } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT status, output_s3_key, error_msg FROM video_jobs WHERE job_id=$1",
|
||||
[job_id],
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Job not found" });
|
||||
|
||||
const job = rows[0];
|
||||
const PUBLIC_BFF = process.env.PUBLIC_BFF_URL || "https://uno-click.pip-test.ru";
|
||||
const url = job.status === "done"
|
||||
? `${PUBLIC_BFF}/api/media/download/${job_id}`
|
||||
: null;
|
||||
|
||||
res.json({ status: job.status, url, error: job.error_msg || null });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/media/download/:job_id ──────────────────────────────────────────
|
||||
router.get("/download/:job_id", async (req, res) => {
|
||||
const { job_id } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT status, output_s3_key FROM video_jobs WHERE job_id=$1",
|
||||
[job_id],
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: "Job not found" });
|
||||
if (rows[0].status !== "done") return res.status(409).json({ error: "Not ready yet" });
|
||||
|
||||
const s3Resp = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: rows[0].output_s3_key }),
|
||||
);
|
||||
res.setHeader("Content-Type", "video/mp4");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="uniqueized_${job_id}.mp4"`);
|
||||
if (s3Resp.ContentLength) res.setHeader("Content-Length", s3Resp.ContentLength);
|
||||
s3Resp.Body.pipe(res);
|
||||
} catch (err) {
|
||||
console.error("[download]", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Router } from 'express';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import * as resultService from '../services/result.service.js';
|
||||
import axios from 'axios';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/result/:generationUuid
|
||||
* Получить результат генерации
|
||||
*/
|
||||
router.get('/:generationUuid', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { generationUuid } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Получаем метаданные генерации из БД (только для получения scenarioId)
|
||||
const generationMeta = await resultService.getGenerationMeta({ userId, generationUuid });
|
||||
|
||||
if (!generationMeta) {
|
||||
return res.status(404).json({
|
||||
error: 'NOT_FOUND',
|
||||
message: 'Result not found or access denied',
|
||||
});
|
||||
}
|
||||
|
||||
// Вызываем n8n webhook для получения результата
|
||||
const stepData = {};
|
||||
|
||||
const n8nUrl = 'https://n8n.uno-click.pip-test.ru/webhook/result';
|
||||
const n8nResponse = await axios.post(n8nUrl, {
|
||||
meta: { generationUuid, userId, scenarioId: generationMeta.scenarioId, stepData },
|
||||
body: stepData
|
||||
});
|
||||
|
||||
// Проверяем формат ответа от n8n
|
||||
const n8nData = n8nResponse.data;
|
||||
console.log('[result] n8n response:', JSON.stringify(n8nData, null, 2));
|
||||
|
||||
// n8n может вернуть данные в разных форматах
|
||||
// Формат 1: { response: { body: { success: {...} } } }
|
||||
// Формат 2: { success: {...} }
|
||||
// Формат 3: [{ response: { body: { success: {...} } } }] - массив
|
||||
let responseData = n8nData;
|
||||
|
||||
// Если массив - берём первый элемент
|
||||
if (Array.isArray(n8nData) && n8nData.length > 0) {
|
||||
responseData = n8nData[0];
|
||||
}
|
||||
|
||||
// Если есть response.body - извлекаем
|
||||
if (responseData?.response?.body) {
|
||||
responseData = responseData.response.body;
|
||||
}
|
||||
|
||||
console.log('[result] extracted responseData:', JSON.stringify(responseData, null, 2));
|
||||
|
||||
// Вариант 1: n8n вернул output_s3 в response_payload
|
||||
if (responseData?.success?.response_payload?.output_s3) {
|
||||
const s3Key = responseData.success.response_payload.output_s3.replace('s3://uno-click/', '');
|
||||
const publicUrl = `/files/${s3Key}`;
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
status: 'completed',
|
||||
files: [{
|
||||
contentType: 'video',
|
||||
url: publicUrl,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 2: n8n вернул files массив с s3:// URL
|
||||
if (responseData?.success?.files && Array.isArray(responseData.success.files)) {
|
||||
const convertedFiles = responseData.success.files.map(file => {
|
||||
// Конвертируем s3:// в /files/...
|
||||
// Поддерживаем оба формата: { url: "s3://..." } и { output_s3: "s3://..." }
|
||||
let fileUrl = file.url || file.output_s3;
|
||||
|
||||
if (fileUrl && fileUrl.startsWith('s3://uno-click/')) {
|
||||
const s3Key = fileUrl.replace('s3://uno-click/', '');
|
||||
return {
|
||||
contentType: file.contentType,
|
||||
url: `/files/${s3Key}`,
|
||||
};
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
status: 'completed',
|
||||
files: convertedFiles,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 3: success на верхнем уровне (для совместимости)
|
||||
if (n8nData?.success?.files && Array.isArray(n8nData.success.files)) {
|
||||
const convertedFiles = n8nData.success.files.map(file => {
|
||||
if (file.url && file.url.startsWith('s3://uno-click/')) {
|
||||
const s3Key = file.url.replace('s3://uno-click/', '');
|
||||
return {
|
||||
...file,
|
||||
url: `/files/${s3Key}`,
|
||||
};
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
status: 'completed',
|
||||
files: convertedFiles,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 4: провайдер вернул fail с сообщением — пробрасываем наверх
|
||||
if (responseData?.success?.code === 'fail') {
|
||||
return res.json({
|
||||
success: {
|
||||
code: 'fail',
|
||||
message: responseData.success.message || responseData.success.msg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 5: ошибка от n8n с code/message — пробрасываем наверх
|
||||
if (responseData?.error?.code) {
|
||||
return res.json({
|
||||
error: {
|
||||
code: responseData.error.code,
|
||||
message: responseData.error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Pass-through для не-успешных кодов (waiting/fail) от universal result-workflow (n8n).
|
||||
// n8n возвращает {success:{code:'fail'|'waiting', message}} — фронт ждёт success на корне.
|
||||
// Без этого блока success теряется в spread внутри data.result, и фронт крутится
|
||||
// в "Рендерит..." до таймаута вместо показа реальной ошибки kie.ai.
|
||||
if (responseData?.success && typeof responseData.success === 'object'
|
||||
&& responseData.success.code && responseData.success.code !== 'success') {
|
||||
console.log('[result] pass-through non-success:', JSON.stringify(responseData.success));
|
||||
return res.json({ success: responseData.success });
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
...responseData,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[result] Error:', err);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Router } from 'express';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import * as resultService from '../services/result.service.js';
|
||||
import axios from 'axios';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/result/:generationUuid
|
||||
* Получить результат генерации
|
||||
*/
|
||||
router.get('/:generationUuid', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { generationUuid } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Получаем метаданные генерации из БД (только для получения scenarioId)
|
||||
const generationMeta = await resultService.getGenerationMeta({ userId, generationUuid });
|
||||
|
||||
if (!generationMeta) {
|
||||
return res.status(404).json({
|
||||
error: 'NOT_FOUND',
|
||||
message: 'Result not found or access denied',
|
||||
});
|
||||
}
|
||||
|
||||
// Вызываем n8n webhook для получения результата
|
||||
const stepData = {};
|
||||
|
||||
const n8nUrl = 'https://n8n.uno-click.pip-test.ru/webhook/result';
|
||||
const n8nResponse = await axios.post(n8nUrl, {
|
||||
meta: { generationUuid, userId, scenarioId: generationMeta.scenarioId, stepData },
|
||||
body: stepData
|
||||
});
|
||||
|
||||
// Проверяем формат ответа от n8n
|
||||
const n8nData = n8nResponse.data;
|
||||
console.log('[result] n8n response:', JSON.stringify(n8nData, null, 2));
|
||||
|
||||
// n8n может вернуть данные в разных форматах
|
||||
// Формат 1: { response: { body: { success: {...} } } }
|
||||
// Формат 2: { success: {...} }
|
||||
// Формат 3: [{ response: { body: { success: {...} } } }] - массив
|
||||
let responseData = n8nData;
|
||||
|
||||
// Если массив - берём первый элемент
|
||||
if (Array.isArray(n8nData) && n8nData.length > 0) {
|
||||
responseData = n8nData[0];
|
||||
}
|
||||
|
||||
// Если есть response.body - извлекаем
|
||||
if (responseData?.response?.body) {
|
||||
responseData = responseData.response.body;
|
||||
}
|
||||
|
||||
console.log('[result] extracted responseData:', JSON.stringify(responseData, null, 2));
|
||||
|
||||
// Вариант 1: n8n вернул output_s3 в response_payload
|
||||
if (responseData?.success?.response_payload?.output_s3) {
|
||||
const s3Key = responseData.success.response_payload.output_s3.replace('s3://uno-click/', '');
|
||||
const publicUrl = `/files/${s3Key}`;
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
status: 'completed',
|
||||
files: [{
|
||||
contentType: 'video',
|
||||
url: publicUrl,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 2: n8n вернул files массив с s3:// URL
|
||||
if (responseData?.success?.files && Array.isArray(responseData.success.files)) {
|
||||
const convertedFiles = responseData.success.files.map(file => {
|
||||
// Конвертируем s3:// в /files/...
|
||||
// Поддерживаем оба формата: { url: "s3://..." } и { output_s3: "s3://..." }
|
||||
let fileUrl = file.url || file.output_s3;
|
||||
|
||||
if (fileUrl && fileUrl.startsWith('s3://uno-click/')) {
|
||||
const s3Key = fileUrl.replace('s3://uno-click/', '');
|
||||
return {
|
||||
contentType: file.contentType,
|
||||
url: `/files/${s3Key}`,
|
||||
};
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
status: 'completed',
|
||||
files: convertedFiles,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 3: success на верхнем уровне (для совместимости)
|
||||
if (n8nData?.success?.files && Array.isArray(n8nData.success.files)) {
|
||||
const convertedFiles = n8nData.success.files.map(file => {
|
||||
if (file.url && file.url.startsWith('s3://uno-click/')) {
|
||||
const s3Key = file.url.replace('s3://uno-click/', '');
|
||||
return {
|
||||
...file,
|
||||
url: `/files/${s3Key}`,
|
||||
};
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
status: 'completed',
|
||||
files: convertedFiles,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 4: провайдер вернул fail с сообщением — пробрасываем наверх
|
||||
if (responseData?.success?.code === 'fail') {
|
||||
return res.json({
|
||||
success: {
|
||||
code: 'fail',
|
||||
message: responseData.success.message || responseData.success.msg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вариант 5: ошибка от n8n с code/message — пробрасываем наверх
|
||||
if (responseData?.error?.code) {
|
||||
return res.json({
|
||||
error: {
|
||||
code: responseData.error.code,
|
||||
message: responseData.error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
result: {
|
||||
generationUuid,
|
||||
scenarioId: generationMeta.scenarioId,
|
||||
scenarioName: generationMeta.scenarioName,
|
||||
...responseData,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[result] Error:', err);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Router } from 'express';
|
||||
import { authRequired } from '../middleware/authRequired.js';
|
||||
import { csrfRequired } from '../middleware/csrfRequired.js';
|
||||
import * as scenarioService from '../services/scenario.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/scenario/:scenarioId/start
|
||||
* Запуск сценария. body должен быть пустым {}.
|
||||
*/
|
||||
router.post('/:scenarioId/start', authRequired, csrfRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { scenarioId } = req.params;
|
||||
const input = req.body;
|
||||
|
||||
// 1) проверить, что пользователь имеет право на этот scenarioId
|
||||
await scenarioService.assertUserCanStartScenario({
|
||||
userId: req.user.id,
|
||||
scenarioId,
|
||||
});
|
||||
|
||||
// 2) создать generation и вызвать n8n
|
||||
const generation = await scenarioService.startScenario({
|
||||
userId: req.user.id,
|
||||
scenarioId,
|
||||
input,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
ok: true,
|
||||
generationUuid: generation.generationUuid,
|
||||
status: generation.status,
|
||||
currentStepId: generation.currentStepId,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/scenario/:scenarioId/step/:stepId
|
||||
* Выполнение шага сценария. body должен соответствовать input_schema из БД.
|
||||
*/
|
||||
router.post('/:scenarioId/step/:stepId', authRequired, csrfRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { scenarioId, stepId } = req.params;
|
||||
const input = req.body;
|
||||
|
||||
// 1) проверить, что пользователь может выполнять этот шаг
|
||||
await scenarioService.assertUserCanExecuteStep({
|
||||
userId: req.user.id,
|
||||
scenarioId,
|
||||
stepId,
|
||||
});
|
||||
|
||||
// 2) выполнить шаг (с валидацией input_schema)
|
||||
const result = await scenarioService.executeStep({
|
||||
userId: req.user.id,
|
||||
scenarioId,
|
||||
stepId,
|
||||
input,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
ok: true,
|
||||
generationUuid: result.generationUuid,
|
||||
stepState: result.stepState,
|
||||
nextStepId: result.nextStepId,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/scenario/:scenarioId/step/:stepId/record
|
||||
* Создать запись generation_step и вернуть её ID (для загрузки файлов).
|
||||
* Используется когда нужно загрузить файл ДО выполнения шага.
|
||||
*/
|
||||
router.post('/:scenarioId/step/:stepId/record', authRequired, csrfRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { scenarioId, stepId } = req.params;
|
||||
const input = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Проверка прав
|
||||
await scenarioService.assertUserCanExecuteStep({
|
||||
userId,
|
||||
scenarioId,
|
||||
stepId,
|
||||
});
|
||||
|
||||
// Найти активную generation
|
||||
const { pool } = await import('../db.js');
|
||||
const genQuery = `
|
||||
SELECT generation_uuid, current_step_id, status
|
||||
FROM uno_bff.generations
|
||||
WHERE user_id = $1 AND scenario_id = $2 AND status IN ('running', 'waiting_for_input')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const genResult = await pool.query(genQuery, [userId, scenarioId]);
|
||||
const generation = genResult.rows[0];
|
||||
|
||||
if (!generation) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'GENERATION_NOT_FOUND',
|
||||
message: `No active generation found for scenario '${scenarioId}'`,
|
||||
});
|
||||
}
|
||||
|
||||
// Найти шаг сценария для получения step_order
|
||||
const step = await scenarioService.getScenarioStep(scenarioId, stepId);
|
||||
if (!step) {
|
||||
return res.status(404).json({
|
||||
ok: false,
|
||||
error: 'STEP_NOT_FOUND',
|
||||
message: `Step '${stepId}' not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Создать/обновить запись generation_step
|
||||
const stepQuery = `
|
||||
INSERT INTO uno_bff.generation_steps (generation_uuid, scenario_id, step_id, step_order, status, request_payload)
|
||||
VALUES ($1, $2, $3, $4, 'running', $5)
|
||||
ON CONFLICT (generation_uuid, step_id) DO UPDATE
|
||||
SET status = 'running', request_payload = $5, updated_at = now()
|
||||
RETURNING id
|
||||
`;
|
||||
const stepResult = await pool.query(stepQuery, [
|
||||
generation.generation_uuid,
|
||||
scenarioId,
|
||||
stepId,
|
||||
step.step_order,
|
||||
JSON.stringify(input),
|
||||
]);
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
stepRecordId: stepResult.rows[0].id,
|
||||
generationUuid: generation.generation_uuid,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Router } from "express";
|
||||
import { confirmTelegramFromBot } from "../services/auth.service.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||
const WEBHOOK_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
|
||||
async function tgSendMessage(chatId, text) {
|
||||
if (!BOT_TOKEN) return;
|
||||
try {
|
||||
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chat_id: chatId, text, parse_mode: "HTML" }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[tg] sendMessage failed", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/webhook", async (req, res) => {
|
||||
// Telegram добавит заголовок если мы установили secret_token при setWebhook
|
||||
if (WEBHOOK_SECRET) {
|
||||
const got = req.get("x-telegram-bot-api-secret-token");
|
||||
if (got !== WEBHOOK_SECRET) {
|
||||
console.warn("[tg] webhook: bad secret token");
|
||||
return res.status(401).json({ ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Всегда отвечаем 200 — иначе Telegram будет ретраить
|
||||
res.json({ ok: true });
|
||||
|
||||
try {
|
||||
const update = req.body || {};
|
||||
const msg = update.message;
|
||||
if (!msg || !msg.text || !msg.from || !msg.chat) return;
|
||||
|
||||
const text = String(msg.text).trim();
|
||||
const m = text.match(/^\/start\s+(\S+)/);
|
||||
if (!m) {
|
||||
// /start без параметра или другая команда — приветствие
|
||||
if (text === "/start" || text === "/help") {
|
||||
await tgSendMessage(msg.chat.id,
|
||||
"Привет! Это бот авторизации <b>One Click</b>.\n\nЧтобы войти на сайт, нажмите кнопку «Войти через Telegram» на сайте — она пришлёт вам сюда специальную ссылку.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const token = m[1];
|
||||
const result = await confirmTelegramFromBot({ token, telegramUser: msg.from });
|
||||
|
||||
await tgSendMessage(msg.chat.id, result.message || (result.ok ? "Готово!" : "Не получилось войти."));
|
||||
} catch (err) {
|
||||
console.error("[tg] webhook handler error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,516 @@
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,478 @@
|
||||
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 allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
|
||||
if (!allowedTypes.includes(contentType)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'INVALID_CONTENT_TYPE',
|
||||
message: 'Разрешены только видео форматы: MP4, MOV, AVI, WebM',
|
||||
});
|
||||
}
|
||||
|
||||
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 allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
|
||||
if (!allowedTypes.includes(file.mimetype)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'INVALID_FILE_TYPE',
|
||||
message: 'Разрешены только видео форматы: MP4, MOV, AVI, WebM',
|
||||
});
|
||||
}
|
||||
|
||||
// Для файлов > 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