import express from "express"; import cors from "cors"; import session from "express-session"; import MongoStore from "connect-mongo"; import { MongoClient, ObjectId } from "mongodb"; import dotenv from "dotenv"; dotenv.config(); // Проверка обязательных переменных окружения const isProduction = process.env.NODE_ENV === "production"; const requiredEnvVars = [ "MONGODB_URI", "SESSION_SECRET", "DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET", "DISCORD_REDIRECT_URI", "FRONTEND_URL", ]; const missingVars = requiredEnvVars.filter((v) => !process.env[v]); if (missingVars.length > 0) { console.error( "❌ Missing required environment variables:", missingVars.join(", "), ); if (isProduction) { process.exit(1); } else { console.warn("⚠️ Running in development mode with missing vars"); } } const app = express(); const PORT = process.env.PORT || 3001; // MongoDB подключение const mongoClient = new MongoClient(process.env.MONGODB_URI); let db; async function connectDB() { await mongoClient.connect(); db = mongoClient.db("resekai"); console.log("✅ Connected to MongoDB"); } // Middleware // Trust proxy in production (needed for secure cookies behind nginx) if (isProduction) { app.set("trust proxy", 1); } app.use(express.json({ limit: "10mb" })); app.use( cors({ origin: process.env.FRONTEND_URL, credentials: true, }), ); app.use( session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI, dbName: "resekai", collectionName: "sessions", }), cookie: { secure: isProduction, // true в production с HTTPS httpOnly: true, sameSite: "lax", // "lax" needed for OAuth redirects from Discord maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней }, }), ); // Discord OAuth2 конфиг const DISCORD_API = "https://discord.com/api/v10"; const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID; const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET; const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI; // ============ AUTH ROUTES ============ // Whitelist полей для безопасного обновления const ALLOWED_STORY_FIELDS = [ "title", "description", "genre", "setting", "world", "characters", "plot", "firstMessage", "language", "isNsfw", "temperature", "narrativeRules", "protagonist", "coverImage", ]; const ALLOWED_SESSION_FIELDS = [ "name", "messages", "currentState", "storySummary", "keyEvents", "playerId", ]; const ALLOWED_CHARACTER_FIELDS = ["name", "description", "age", "avatarUrl"]; const ALLOWED_NPC_FIELDS = [ "name", "description", "role", "age", "gender", "avatarUrl", "isNsfw", ]; // Функция для фильтрации полей function pickAllowedFields(obj, allowedFields) { // Guard: ensure obj is a plain object if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return {}; } const result = {}; for (const field of allowedFields) { if (Object.prototype.hasOwnProperty.call(obj, field)) { result[field] = obj[field]; } } return result; } // ============ DEEPSEEK RATE LIMITING & VALIDATION ============ const DEEPSEEK_LIMITS = { MAX_TOKENS_LIMIT: 4096, MAX_MESSAGES: 100, MAX_MESSAGE_LENGTH: 50000, // ~12k tokens per message (increased for long context) MAX_TOTAL_LENGTH: 200000, // ~50k tokens total (increased for world state + summary) RATE_LIMIT_WINDOW_MS: 60 * 1000, // 1 minute RATE_LIMIT_MAX_REQUESTS: 20, // 20 requests per minute per user }; // Simple in-memory rate limiter (per userId) const rateLimitStore = new Map(); function checkRateLimit(userId) { const now = Date.now(); const windowStart = now - DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS; let userRequests = rateLimitStore.get(userId) || []; // Remove old requests outside window userRequests = userRequests.filter((ts) => ts > windowStart); if (userRequests.length >= DEEPSEEK_LIMITS.RATE_LIMIT_MAX_REQUESTS) { return { allowed: false, remaining: 0, resetIn: Math.ceil( (userRequests[0] + DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS - now) / 1000, ), }; } userRequests.push(now); rateLimitStore.set(userId, userRequests); return { allowed: true, remaining: DEEPSEEK_LIMITS.RATE_LIMIT_MAX_REQUESTS - userRequests.length, }; } // Cleanup old rate limit entries every 5 minutes setInterval( () => { const cutoff = Date.now() - DEEPSEEK_LIMITS.RATE_LIMIT_WINDOW_MS; for (const [userId, requests] of rateLimitStore.entries()) { const filtered = requests.filter((ts) => ts > cutoff); if (filtered.length === 0) { rateLimitStore.delete(userId); } else { rateLimitStore.set(userId, filtered); } } }, 5 * 60 * 1000, ); function validateDeepSeekRequest(body) { const errors = []; const { messages, temperature, max_tokens } = body; // Validate messages if (!Array.isArray(messages)) { errors.push("messages must be an array"); } else { if (messages.length === 0) { errors.push("messages cannot be empty"); } if (messages.length > DEEPSEEK_LIMITS.MAX_MESSAGES) { errors.push(`too many messages (max ${DEEPSEEK_LIMITS.MAX_MESSAGES})`); } let totalLength = 0; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (!msg || typeof msg !== "object") { errors.push(`messages[${i}] must be an object`); continue; } if (!msg.role || !["system", "user", "assistant"].includes(msg.role)) { errors.push( `messages[${i}].role must be 'system', 'user', or 'assistant'`, ); } if (typeof msg.content !== "string") { errors.push(`messages[${i}].content must be a string`); } else { if (msg.content.length > DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH) { errors.push( `messages[${i}].content too long (max ${DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH} chars)`, ); } totalLength += msg.content.length; } } if (totalLength > DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH) { errors.push( `total message content too long (max ${DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH} chars)`, ); } } // Validate temperature if (temperature !== undefined) { if (typeof temperature !== "number" || temperature < 0 || temperature > 2) { errors.push("temperature must be a number between 0 and 2"); } } // Validate max_tokens if (max_tokens !== undefined) { if ( typeof max_tokens !== "number" || !Number.isInteger(max_tokens) || max_tokens < 1 || max_tokens > DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT ) { errors.push( `max_tokens must be an integer between 1 and ${DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT}`, ); } } return errors; } function sanitizeDeepSeekMessages(messages) { return messages.map((msg) => ({ role: msg.role, content: String(msg.content), })); } // Начало авторизации Discord app.get("/auth/discord", (req, res) => { const params = new URLSearchParams({ client_id: DISCORD_CLIENT_ID, redirect_uri: DISCORD_REDIRECT_URI, response_type: "code", scope: "identify email", }); res.redirect(`https://discord.com/api/oauth2/authorize?${params}`); }); // Callback от Discord app.get("/auth/discord/callback", async (req, res) => { const { code } = req.query; if (!code) { return res.redirect(`${process.env.FRONTEND_URL}?error=no_code`); } try { // Получаем токен const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ client_id: DISCORD_CLIENT_ID, client_secret: DISCORD_CLIENT_SECRET, grant_type: "authorization_code", code, redirect_uri: DISCORD_REDIRECT_URI, }), }); const tokenData = await tokenResponse.json(); if (!tokenData.access_token) { console.error("Token error:", tokenData); return res.redirect(`${process.env.FRONTEND_URL}?error=token_failed`); } // Получаем информацию о пользователе const userResponse = await fetch(`${DISCORD_API}/users/@me`, { headers: { Authorization: `Bearer ${tokenData.access_token}`, }, }); const discordUser = await userResponse.json(); // Сохраняем или обновляем пользователя в БД const users = db.collection("users"); const existingUser = await users.findOne({ discordId: discordUser.id }); let user; if (existingUser) { // Обновляем существующего пользователя await users.updateOne( { discordId: discordUser.id }, { $set: { username: discordUser.username, email: discordUser.email, avatar: discordUser.avatar, updatedAt: new Date(), }, }, ); user = await users.findOne({ discordId: discordUser.id }); } else { // Создаём нового пользователя const newUser = { discordId: discordUser.id, username: discordUser.username, email: discordUser.email, avatar: discordUser.avatar, createdAt: new Date(), updatedAt: new Date(), }; const result = await users.insertOne(newUser); user = { ...newUser, _id: result.insertedId }; } // Сохраняем в сессию req.session.userId = user._id.toString(); req.session.discordId = discordUser.id; res.redirect(`${process.env.FRONTEND_URL}?auth=success`); } catch (error) { console.error("Auth error:", error); res.redirect(`${process.env.FRONTEND_URL}?error=auth_failed`); } }); // Получить текущего пользователя app.get("/auth/me", async (req, res) => { if (!req.session.userId) { return res.json({ user: null }); } try { const users = db.collection("users"); const user = await users.findOne({ _id: new ObjectId(req.session.userId) }); if (!user) { return res.json({ user: null }); } res.json({ user: { id: user._id, discordId: user.discordId, username: user.username, email: user.email, avatar: user.avatar, }, }); } catch (error) { console.error("Get user error:", error); res.json({ user: null }); } }); // Выход app.post("/auth/logout", (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: "Logout failed" }); } res.clearCookie("connect.sid"); res.json({ success: true }); }); }); // ============ STORIES ROUTES ============ // Middleware для проверки авторизации const requireAuth = (req, res, next) => { if (!req.session.userId) { return res.status(401).json({ error: "Unauthorized" }); } next(); }; // Получить все истории пользователя app.get("/api/stories", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const userStories = await stories .find({ userId: req.session.userId }) .sort({ updatedAt: -1 }) .toArray(); res.json(userStories); } catch (error) { console.error("Get stories error:", error); res.status(500).json({ error: "Failed to get stories" }); } }); // Получить одну историю app.get("/api/stories/:id", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const story = await stories.findOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (!story) { return res.status(404).json({ error: "Story not found" }); } res.json(story); } catch (error) { console.error("Get story error:", error); res.status(500).json({ error: "Failed to get story" }); } }); // Создать историю app.post("/api/stories", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS); const newStory = { ...allowedData, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), }; const result = await stories.insertOne(newStory); res.json({ ...newStory, _id: result.insertedId }); } catch (error) { console.error("Create story error:", error); res.status(500).json({ error: "Failed to create story" }); } }); // Обновить историю app.put("/api/stories/:id", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS); const result = await stories.updateOne( { _id: new ObjectId(req.params.id), userId: req.session.userId, }, { $set: { ...allowedData, updatedAt: new Date(), }, }, ); if (result.matchedCount === 0) { return res.status(404).json({ error: "Story not found" }); } res.json({ success: true }); } catch (error) { console.error("Update story error:", error); res.status(500).json({ error: "Failed to update story" }); } }); // Удалить историю app.delete("/api/stories/:id", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const result = await stories.deleteOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (result.deletedCount === 0) { return res.status(404).json({ error: "Story not found" }); } // Также удаляем связанные сессии игры const sessions = db.collection("game_sessions"); await sessions.deleteMany({ storyId: req.params.id }); res.json({ success: true }); } catch (error) { console.error("Delete story error:", error); res.status(500).json({ error: "Failed to delete story" }); } }); // ============ GAME SESSIONS ROUTES ============ // Получить список сессий для истории app.get("/api/sessions/:storyId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); const userSessions = await sessions .find({ storyId: req.params.storyId, userId: req.session.userId, }) .sort({ updatedAt: -1 }) .toArray(); // Возвращаем список с базовой инфой (без полных сообщений) const sessionsList = userSessions.map((s) => ({ id: s._id.toString(), name: s.name || "Сессия", messagesCount: s.messages?.length || 0, createdAt: s.createdAt, updatedAt: s.updatedAt, })); res.json(sessionsList); } catch (error) { console.error("Get sessions list error:", error); res.status(500).json({ error: "Failed to get sessions" }); } }); // Получить конкретную сессию app.get("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); const session = await sessions.findOne({ _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }); if (!session) { return res.status(404).json({ error: "Session not found" }); } res.json({ ...session, id: session._id.toString() }); } catch (error) { console.error("Get session error:", error); res.status(500).json({ error: "Failed to get session" }); } }); // Создать новую сессию app.post("/api/sessions/:storyId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); // Считаем существующие сессии для нумерации const existingCount = await sessions.countDocuments({ storyId: req.params.storyId, userId: req.session.userId, }); const sessionData = { storyId: req.params.storyId, userId: req.session.userId, name: req.body.name || `Сессия ${existingCount + 1}`, playerId: req.body.playerId || null, messages: [], currentState: { location: "start", health: 100, inventory: [], questProgress: {}, }, storySummary: "", keyEvents: [], createdAt: new Date(), updatedAt: new Date(), }; const result = await sessions.insertOne(sessionData); res.json({ id: result.insertedId.toString(), name: sessionData.name, messagesCount: 0, createdAt: sessionData.createdAt, updatedAt: sessionData.updatedAt, }); } catch (error) { console.error("Create session error:", error); res.status(500).json({ error: "Failed to create session" }); } }); // Обновить сессию app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); // Получаем старую сессию для подсчёта новых токенов const oldSession = await sessions.findOne({ _id: new ObjectId(req.params.sessionId), userId: req.session.userId, }); const oldMessageCount = oldSession?.messages?.length || 0; // Фильтруем разрешенные поля const allowedData = pickAllowedFields(req.body, ALLOWED_SESSION_FIELDS); // Преобразуем timestamp строки обратно в Date if (allowedData.messages) { allowedData.messages = allowedData.messages.map((msg) => { const sanitized = { id: msg.id, role: msg.role, content: msg.content, timestamp: new Date(msg.timestamp), }; // Сохраняем версии для редактирования сообщений if (Array.isArray(msg.versions) && msg.versions.length > 0) { sanitized.versions = msg.versions.map((v) => { const ver = { content: v.content, timestamp: new Date(v.timestamp), }; // Сохраняем связанный ответ ИИ для переключения версий if (typeof v.aiResponse === "string") { ver.aiResponse = v.aiResponse; } return ver; }); sanitized.activeVersion = typeof msg.activeVersion === "number" ? msg.activeVersion : 0; } return sanitized; }); } const sessionData = { ...allowedData, updatedAt: new Date(), }; const result = await sessions.updateOne( { _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }, { $set: sessionData }, ); if (result.matchedCount === 0) { return res.status(404).json({ error: "Session not found" }); } // Логируем новые токены (только для новых сообщений) const messages = allowedData.messages || []; const newMessages = messages.slice(oldMessageCount); if (newMessages.length > 0) { const tokenUsage = db.collection("token_usage"); const newTokens = newMessages.reduce((sum, msg) => { return sum + Math.round((msg.content?.length || 0) / 3); }, 0); if (newTokens > 0) { await tokenUsage.insertOne({ userId: req.session.userId, storyId: req.params.storyId, sessionId: req.params.sessionId, tokens: newTokens, messageCount: newMessages.length, createdAt: new Date(), }); } } res.json({ success: true }); } catch (error) { console.error("Update session error:", error); res.status(500).json({ error: "Failed to update session" }); } }); // Удалить сессию app.delete( "/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { try { const sessions = db.collection("game_sessions"); const result = await sessions.deleteOne({ _id: new ObjectId(req.params.sessionId), storyId: req.params.storyId, userId: req.session.userId, }); if (result.deletedCount === 0) { return res.status(404).json({ error: "Session not found" }); } res.json({ success: true }); } catch (error) { console.error("Delete session error:", error); res.status(500).json({ error: "Failed to delete session" }); } }, ); // ============ PLAYER CHARACTERS ROUTES ============ // Получить всех персонажей пользователя app.get("/api/characters", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const userCharacters = await characters .find({ userId: req.session.userId }) .sort({ updatedAt: -1 }) .toArray(); res.json(userCharacters); } catch (error) { console.error("Get characters error:", error); res.status(500).json({ error: "Failed to get characters" }); } }); // Получить одного персонажа app.get("/api/characters/:id", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const character = await characters.findOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (!character) { return res.status(404).json({ error: "Character not found" }); } res.json(character); } catch (error) { console.error("Get character error:", error); res.status(500).json({ error: "Failed to get character" }); } }); // Создать персонажа app.post("/api/characters", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS); const newCharacter = { ...allowedData, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), }; const result = await characters.insertOne(newCharacter); res.json({ ...newCharacter, _id: result.insertedId }); } catch (error) { console.error("Create character error:", error); res.status(500).json({ error: "Failed to create character" }); } }); // Обновить персонажа app.put("/api/characters/:id", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const allowedData = pickAllowedFields(req.body, ALLOWED_CHARACTER_FIELDS); const result = await characters.updateOne( { _id: new ObjectId(req.params.id), userId: req.session.userId, }, { $set: { ...allowedData, updatedAt: new Date(), }, }, ); if (result.matchedCount === 0) { return res.status(404).json({ error: "Character not found" }); } res.json({ success: true }); } catch (error) { console.error("Update character error:", error); res.status(500).json({ error: "Failed to update character" }); } }); // Удалить персонажа app.delete("/api/characters/:id", requireAuth, async (req, res) => { try { const characters = db.collection("player_characters"); const result = await characters.deleteOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (result.deletedCount === 0) { return res.status(404).json({ error: "Character not found" }); } res.json({ success: true }); } catch (error) { console.error("Delete character error:", error); res.status(500).json({ error: "Failed to delete character" }); } }); // ============ NPC CHARACTERS ROUTES ============ // Получить всех NPC пользователя app.get("/api/npc", requireAuth, async (req, res) => { try { const npcCharacters = db.collection("npc_characters"); const userNPCs = await npcCharacters .find({ userId: req.session.userId }) .sort({ updatedAt: -1 }) .toArray(); res.json(userNPCs); } catch (error) { console.error("Get NPCs error:", error); res.status(500).json({ error: "Failed to get NPCs" }); } }); // Получить одного NPC app.get("/api/npc/:id", requireAuth, async (req, res) => { try { const npcCharacters = db.collection("npc_characters"); const npc = await npcCharacters.findOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (!npc) { return res.status(404).json({ error: "NPC not found" }); } res.json(npc); } catch (error) { console.error("Get NPC error:", error); res.status(500).json({ error: "Failed to get NPC" }); } }); // Создать NPC app.post("/api/npc", requireAuth, async (req, res) => { try { const npcCharacters = db.collection("npc_characters"); const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS); const newNPC = { ...allowedData, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), }; const result = await npcCharacters.insertOne(newNPC); res.json({ ...newNPC, _id: result.insertedId }); } catch (error) { console.error("Create NPC error:", error); res.status(500).json({ error: "Failed to create NPC" }); } }); // Обновить NPC app.put("/api/npc/:id", requireAuth, async (req, res) => { try { const npcCharacters = db.collection("npc_characters"); const allowedData = pickAllowedFields(req.body, ALLOWED_NPC_FIELDS); const result = await npcCharacters.updateOne( { _id: new ObjectId(req.params.id), userId: req.session.userId, }, { $set: { ...allowedData, updatedAt: new Date(), }, }, ); if (result.matchedCount === 0) { return res.status(404).json({ error: "NPC not found" }); } res.json({ success: true }); } catch (error) { console.error("Update NPC error:", error); res.status(500).json({ error: "Failed to update NPC" }); } }); // Удалить NPC app.delete("/api/npc/:id", requireAuth, async (req, res) => { try { const npcCharacters = db.collection("npc_characters"); const result = await npcCharacters.deleteOne({ _id: new ObjectId(req.params.id), userId: req.session.userId, }); if (result.deletedCount === 0) { return res.status(404).json({ error: "NPC not found" }); } res.json({ success: true }); } catch (error) { console.error("Delete NPC error:", error); res.status(500).json({ error: "Failed to delete NPC" }); } }); // ============ ADMIN STATS ============ // Получить статистику по всем историям и токенам app.get("/api/admin/stats", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); const gameSessions = db.collection("game_sessions"); const tokenUsage = db.collection("token_usage"); // Получаем все истории пользователя const userStories = await stories .find({ userId: req.session.userId }) .toArray(); // Получаем все сессии пользователя const userSessions = await gameSessions .find({ userId: req.session.userId }) .toArray(); // Получаем общее количество токенов из лога (не зависит от удалённых сессий) const tokenLogs = await tokenUsage .find({ userId: req.session.userId }) .toArray(); const totalTokensLogged = tokenLogs.reduce( (sum, log) => sum + (log.tokens || 0), 0, ); // Считаем токены в текущих сессиях (для сравнения) const currentTokens = userSessions.reduce((sum, session) => { const chars = (session.messages || []).reduce( (s, msg) => s + (msg.content?.length || 0), 0, ); return sum + Math.round(chars / 3); }, 0); // Берём максимум: залогированные или текущие (для обратной совместимости) const totalTokens = Math.max(totalTokensLogged, currentTokens); // Считаем статистику для каждой истории const storyStats = userStories.map((story) => { const storySessions = userSessions.filter( (s) => s.storyId === story._id.toString(), ); const messageCount = storySessions.reduce( (sum, s) => sum + (s.messages?.length || 0), 0, ); // Токены из лога для этой истории const storyTokenLogs = tokenLogs.filter( (l) => l.storyId === story._id.toString(), ); const loggedTokens = storyTokenLogs.reduce( (sum, l) => sum + (l.tokens || 0), 0, ); // Текущие токены в сессиях const currentStoryTokens = storySessions.reduce((sum, session) => { const chars = (session.messages || []).reduce( (s, msg) => s + (msg.content?.length || 0), 0, ); return sum + Math.round(chars / 3); }, 0); const tokens = Math.max(loggedTokens, currentStoryTokens); const lastSession = storySessions.sort( (a, b) => new Date(b.updatedAt) - new Date(a.updatedAt), )[0]; return { id: story._id.toString(), title: story.title, sessionsCount: storySessions.length, messageCount, tokens, lastPlayed: lastSession?.updatedAt || null, }; }); // Сортируем по токенам (больше сверху) storyStats.sort((a, b) => b.tokens - a.tokens); res.json({ totalStories: userStories.length, totalSessions: userSessions.length, totalTokens, stories: storyStats, }); } catch (error) { console.error("Get admin stats error:", error); res.status(500).json({ error: "Failed to get stats" }); } }); // ============ IMAGE GENERATION ============ // Прокси для генерации изображений через Grok (обход CORS) app.post("/api/generate-image", requireAuth, async (req, res) => { try { const { prompt } = req.body; const apiKey = process.env.GEMINIGEN_API_KEY; if (!apiKey) { return res .status(500) .json({ error: "GeminiGen API key not configured" }); } console.log("Generating image with Grok, prompt:", prompt); // Используем FormData для multipart/form-data const formData = new FormData(); formData.append("prompt", prompt); formData.append("orientation", "portrait"); // 9:16 formData.append("num_result", "1"); const response = await fetch( "https://api.geminigen.ai/uapi/v1/imagen/grok", { method: "POST", headers: { "x-api-key": apiKey, }, body: formData, }, ); if (!response.ok) { const error = await response.text(); console.error("Grok API error:", error); return res .status(response.status) .json({ error: "Image generation failed", details: error }); } const data = await response.json(); console.log("Grok response:", data); // Проверяем статус генерации if (data.status === 2 && data.generate_result) { // Готово - возвращаем URL res.json({ url: data.generate_result }); } else if (data.status === 1) { // В процессе - возвращаем uuid для polling res.json({ pending: true, uuid: data.uuid, status_percentage: data.status_percentage, }); } else { res .status(500) .json({ error: data.error_message || "Generation failed" }); } } catch (error) { console.error("Image generation error:", error); res.status(500).json({ error: "Failed to generate image" }); } }); // Проверка статуса генерации app.get("/api/generate-image/status/:uuid", requireAuth, async (req, res) => { try { const apiKey = process.env.GEMINIGEN_API_KEY; const { uuid } = req.params; const response = await fetch( `https://api.geminigen.ai/uapi/v1/history/${uuid}`, { headers: { "x-api-key": apiKey, }, }, ); if (!response.ok) { const errorText = await response.text(); console.error("History API error:", response.status, errorText); return res .status(response.status) .json({ error: "Failed to check status" }); } const data = await response.json(); console.log( "History API status:", data.status, "images:", data.generated_image?.length, ); if (data.status === 2) { // Completed - get image URL from generated_image array const imageUrl = data.generated_image?.[0]?.image_url || data.generated_image?.[0]?.file_download_url || data.generate_result; if (imageUrl) { res.json({ url: imageUrl, done: true }); } else { res.status(500).json({ error: "No image URL in response" }); } } else if (data.status === 1) { res.json({ pending: true, status_percentage: data.status_percentage }); } else if (data.status === 3) { res .status(500) .json({ error: data.error_message || "Generation failed" }); } else { // Unknown status - keep polling res.json({ pending: true, status_percentage: data.status_percentage }); } } catch (error) { console.error("Status check error:", error); res.status(500).json({ error: "Failed to check status" }); } }); // ============ DEEPSEEK PROXY ============ const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"; // Regular chat completion app.post("/api/deepseek/chat", requireAuth, async (req, res) => { try { // Rate limiting const rateCheck = checkRateLimit(req.session.userId); if (!rateCheck.allowed) { return res.status(429).json({ error: "Rate limit exceeded", retryAfter: rateCheck.resetIn, }); } // Validation const validationErrors = validateDeepSeekRequest(req.body); if (validationErrors.length > 0) { console.warn("DeepSeek chat validation failed:", validationErrors); return res .status(400) .json({ error: "Validation failed", details: validationErrors }); } const apiKey = process.env.DEEPSEEK_API_KEY; if (!apiKey) { return res.status(500).json({ error: "DeepSeek API key not configured" }); } const { messages, temperature = 0.8, max_tokens = 1000 } = req.body; const sanitizedMessages = sanitizeDeepSeekMessages(messages); const clampedMaxTokens = Math.min( max_tokens, DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT, ); const response = await fetch(DEEPSEEK_API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: "deepseek-chat", messages: sanitizedMessages, temperature, max_tokens: clampedMaxTokens, top_p: 0.95, }), }); if (!response.ok) { const error = await response.text(); console.error("DeepSeek API error:", response.status, error); return res.status(response.status).json({ error: "DeepSeek API error" }); } const data = await response.json(); res.json(data); } catch (error) { console.error("DeepSeek proxy error:", error); res.status(500).json({ error: "Failed to call DeepSeek API" }); } }); // Streaming chat completion app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => { try { // Rate limiting const rateCheck = checkRateLimit(req.session.userId); if (!rateCheck.allowed) { return res.status(429).json({ error: "Rate limit exceeded", retryAfter: rateCheck.resetIn, }); } // Validation const validationErrors = validateDeepSeekRequest(req.body); if (validationErrors.length > 0) { console.warn("DeepSeek stream validation failed:", validationErrors); return res .status(400) .json({ error: "Validation failed", details: validationErrors }); } const apiKey = process.env.DEEPSEEK_API_KEY; if (!apiKey) { return res.status(500).json({ error: "DeepSeek API key not configured" }); } const { messages, temperature = 0.8, max_tokens = 1000 } = req.body; const sanitizedMessages = sanitizeDeepSeekMessages(messages); const clampedMaxTokens = Math.min( max_tokens, DEEPSEEK_LIMITS.MAX_TOKENS_LIMIT, ); const response = await fetch(DEEPSEEK_API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: "deepseek-chat", messages: sanitizedMessages, temperature, max_tokens: clampedMaxTokens, top_p: 0.95, stream: true, }), }); if (!response.ok) { const error = await response.text(); console.error("DeepSeek stream error:", response.status, error); return res.status(response.status).json({ error: "DeepSeek API error" }); } // Set headers for SSE res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); // Pipe the stream const reader = response.body.getReader(); const decoder = new TextDecoder(); const pump = async () => { try { while (true) { const { done, value } = await reader.read(); if (done) { res.end(); break; } const chunk = decoder.decode(value, { stream: true }); res.write(chunk); } } catch (error) { console.error("Stream pump error:", error); res.end(); } }; // Handle client disconnect req.on("close", () => { reader.cancel(); }); pump(); } catch (error) { console.error("DeepSeek stream proxy error:", error); res.status(500).json({ error: "Failed to call DeepSeek API" }); } }); // Translation endpoint (for image prompts) app.post("/api/deepseek/translate", requireAuth, async (req, res) => { try { // Rate limiting (shares limit with chat endpoints) const rateCheck = checkRateLimit(req.session.userId); if (!rateCheck.allowed) { return res.status(429).json({ error: "Rate limit exceeded", retryAfter: rateCheck.resetIn, }); } // Validate text const { text } = req.body; if (typeof text !== "string") { return res.status(400).json({ error: "text must be a string" }); } if (text.length === 0) { return res.status(400).json({ error: "text cannot be empty" }); } if (text.length > 2000) { return res.status(400).json({ error: "text too long (max 2000 chars)" }); } const apiKey = process.env.DEEPSEEK_API_KEY; if (!apiKey) { return res.status(500).json({ error: "DeepSeek API key not configured" }); } const response = await fetch(DEEPSEEK_API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: "deepseek-chat", messages: [ { role: "system", content: "Translate to English for image generation. Output ONLY the translation, nothing else. Keep character names as-is. Be concise.", }, { role: "user", content: text, }, ], temperature: 0.1, max_tokens: 150, }), }); if (!response.ok) { const error = await response.text(); console.error("DeepSeek translate error:", response.status, error); return res.status(response.status).json({ error: "Translation failed" }); } const data = await response.json(); const translated = data.choices?.[0]?.message?.content?.trim() || text; res.json({ translated }); } catch (error) { console.error("Translation proxy error:", error); res.status(500).json({ error: "Failed to translate" }); } }); // Запуск сервера connectDB().then(() => { app.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); console.log(` Mode: ${isProduction ? "production" : "development"}`); console.log(` Secure cookies: ${isProduction}`); }); });