initial commit

This commit is contained in:
root
2026-05-13 14:20:41 +00:00
commit 6e178d2012
6022 changed files with 399872 additions and 0 deletions
+315
View File
@@ -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;
+232
View File
@@ -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;
+214
View File
@@ -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;
+178
View File
@@ -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;
+152
View File
@@ -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;
+60
View File
@@ -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;
+516
View File
@@ -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;
+482
View File
@@ -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;