From 68c2b129fa510c9043278fd5feb96fb9a2039558 Mon Sep 17 00:00:00 2001 From: Alexej Wolff Date: Tue, 5 May 2026 23:41:52 +0200 Subject: [PATCH] Major refactor: security, performance, and code organization Security: - DeepSeek API moved to server-side proxy with rate limiting (20 req/min) - Whitelist validation for all POST/PUT routes - Cookie security (secure, sameSite, httpOnly in production) - Input validation for messages, tokens, temperature - Sanitized hasOwnProperty to prevent prototype pollution Performance: - Lazy loading for chat messages (sliding window of 20) - Streaming response throttling (50ms batches) - Scroll optimization (only scroll on new messages) - AbortController fix for stop button Code organization: - GamePage refactored from ~1170 to ~750 lines - New hooks: useGameSession, useStreamingResponse, useCharacterDetection, useLazyMessages - New components: MessageList, ChatInput, SessionSelector, CharacterPanel - Fixed ESLint errors Features: - OOC mode button for direct AI instructions - Message versions (aiResponse) now persist to DB - playerId saved in sessions --- .env.example | 3 - README.md | 4 +- server/.env.example | 7 + server/index.js | 501 ++++++++++++++++- src/components/game/CharacterPanel.tsx | 19 + src/components/game/ChatInput.tsx | 88 +++ src/components/game/MessageList.tsx | 208 +++++++ src/components/game/SessionSelector.tsx | 94 ++++ src/components/game/index.ts | 10 + src/contexts/AuthContext.tsx | 3 + src/hooks/index.ts | 4 + src/hooks/useCharacterDetection.ts | 57 ++ src/hooks/useGameSession.ts | 300 ++++++++++ src/hooks/useLazyMessages.ts | 121 ++++ src/hooks/useStreamingResponse.ts | 101 ++++ src/pages/AdminPage.tsx | 2 +- src/pages/GamePage.css | 64 +++ src/pages/GamePage.tsx | 700 ++++++++---------------- src/pages/StoriesPage.tsx | 2 +- src/pages/StoryDetailPage.tsx | 3 +- src/services/api.ts | 10 +- src/services/deepseek.ts | 35 +- src/services/imageGen.ts | 34 +- 23 files changed, 1817 insertions(+), 553 deletions(-) create mode 100644 src/components/game/CharacterPanel.tsx create mode 100644 src/components/game/ChatInput.tsx create mode 100644 src/components/game/MessageList.tsx create mode 100644 src/components/game/SessionSelector.tsx create mode 100644 src/components/game/index.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useCharacterDetection.ts create mode 100644 src/hooks/useGameSession.ts create mode 100644 src/hooks/useLazyMessages.ts create mode 100644 src/hooks/useStreamingResponse.ts diff --git a/.env.example b/.env.example index d6c7c24..066155a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,2 @@ -# DeepSeek API ключ для генерации историй -VITE_DEEPSEEK_API_KEY=your_api_key_here - # URL бэкенд сервера VITE_API_URL=http://localhost:3001 diff --git a/README.md b/README.md index 483262f..89af81a 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ cd resekai npm install cd server && npm install && cd .. -# Создать .env файл -echo "VITE_DEEPSEEK_API_KEY=your_api_key" > .env +# Настроить backend (скопировать .env.example и заполнить) +# Обязательно указать DEEPSEEK_API_KEY в server/.env # Запустить в режиме разработки npm run dev # Frontend (порт 5173) diff --git a/server/.env.example b/server/.env.example index b374bb8..c5c5c4f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -14,3 +14,10 @@ FRONTEND_URL=http://localhost:5174 # Server PORT=3001 +NODE_ENV=development # 'production' for secure cookies and strict checks + +# DeepSeek API (for story generation) +DEEPSEEK_API_KEY=your_deepseek_api_key + +# GeminiGen API (for image generation) +GEMINIGEN_API_KEY=your_geminigen_api_key diff --git a/server/index.js b/server/index.js index c8b0cdf..a6cedaf 100644 --- a/server/index.js +++ b/server/index.js @@ -7,6 +7,30 @@ 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; @@ -21,7 +45,7 @@ async function connectDB() { } // Middleware -app.use(express.json()); +app.use(express.json({ limit: "10mb" })); app.use( cors({ origin: process.env.FRONTEND_URL, @@ -40,8 +64,9 @@ app.use( collectionName: "sessions", }), cookie: { - secure: false, // true в production с HTTPS + secure: isProduction, // true в production с HTTPS httpOnly: true, + sameSite: isProduction ? "strict" : "lax", maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней }, }), @@ -55,6 +80,192 @@ 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: 32000, // ~8k tokens per message + MAX_TOTAL_LENGTH: 128000, // ~32k tokens total + 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({ @@ -241,8 +452,9 @@ app.get("/api/stories/:id", requireAuth, async (req, res) => { app.post("/api/stories", requireAuth, async (req, res) => { try { const stories = db.collection("stories"); + const allowedData = pickAllowedFields(req.body, ALLOWED_STORY_FIELDS); const newStory = { - ...req.body, + ...allowedData, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), @@ -260,6 +472,8 @@ app.post("/api/stories", requireAuth, async (req, res) => { 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), @@ -267,7 +481,7 @@ app.put("/api/stories/:id", requireAuth, async (req, res) => { }, { $set: { - ...req.body, + ...allowedData, updatedAt: new Date(), }, }, @@ -416,18 +630,40 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { }); const oldMessageCount = oldSession?.messages?.length || 0; - // Преобразуем timestamp строки обратно в Date - const messages = (req.body.messages || []).map((msg) => ({ - ...msg, - timestamp: new Date(msg.timestamp), - })); + // Фильтруем разрешенные поля + const allowedData = pickAllowedFields(req.body, ALLOWED_SESSION_FIELDS); - // Убираем _id и createdAt из body чтобы не было конфликтов - const { createdAt, _id, id, ...bodyWithoutMeta } = req.body; + // Преобразуем 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 = { - ...bodyWithoutMeta, - messages, + ...allowedData, updatedAt: new Date(), }; @@ -445,6 +681,7 @@ app.put("/api/sessions/:storyId/:sessionId", requireAuth, async (req, res) => { } // Логируем новые токены (только для новых сообщений) + const messages = allowedData.messages || []; const newMessages = messages.slice(oldMessageCount); if (newMessages.length > 0) { const tokenUsage = db.collection("token_usage"); @@ -539,8 +776,9 @@ app.get("/api/characters/:id", requireAuth, async (req, res) => { 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 = { - ...req.body, + ...allowedData, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), @@ -558,6 +796,8 @@ app.post("/api/characters", requireAuth, async (req, res) => { 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), @@ -565,7 +805,7 @@ app.put("/api/characters/:id", requireAuth, async (req, res) => { }, { $set: { - ...req.body, + ...allowedData, updatedAt: new Date(), }, }, @@ -644,8 +884,9 @@ app.get("/api/npc/:id", requireAuth, async (req, res) => { 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 = { - ...req.body, + ...allowedData, userId: req.session.userId, createdAt: new Date(), updatedAt: new Date(), @@ -663,6 +904,8 @@ app.post("/api/npc", requireAuth, async (req, res) => { 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), @@ -670,7 +913,7 @@ app.put("/api/npc/:id", requireAuth, async (req, res) => { }, { $set: { - ...req.body, + ...allowedData, updatedAt: new Date(), }, }, @@ -930,9 +1173,233 @@ app.get("/api/generate-image/status/:uuid", requireAuth, async (req, res) => { } }); +// ============ 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) { + 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, + }), + }); + + 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) { + 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, + 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}`); }); }); diff --git a/src/components/game/CharacterPanel.tsx b/src/components/game/CharacterPanel.tsx new file mode 100644 index 0000000..0774d2f --- /dev/null +++ b/src/components/game/CharacterPanel.tsx @@ -0,0 +1,19 @@ +import type { Character } from "../../types"; + +interface CharacterPanelProps { + character: Character | null; +} + +export function CharacterPanel({ character }: CharacterPanelProps) { + if (!character?.avatarUrl) return null; + + return ( +
+ {character.name} +
+ {character.name} + {character.role} +
+
+ ); +} diff --git a/src/components/game/ChatInput.tsx b/src/components/game/ChatInput.tsx new file mode 100644 index 0000000..46f9630 --- /dev/null +++ b/src/components/game/ChatInput.tsx @@ -0,0 +1,88 @@ +import React, { useRef, useEffect } from "react"; + +interface ChatInputProps { + value: string; + onChange: (value: string) => void; + onSend: () => void; + onStop: () => void; + isLoading: boolean; + disabled?: boolean; + placeholder?: string; +} + +export function ChatInput({ + value, + onChange, + onSend, + onStop, + isLoading, + disabled = false, + placeholder = "Что ты хочешь сделать?...", +}: ChatInputProps) { + const inputRef = useRef(null); + + // Focus input after loading completes + useEffect(() => { + if (!isLoading) { + inputRef.current?.focus(); + } + }, [isLoading]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSend(); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + // Auto-resize + e.target.style.height = "auto"; + e.target.style.height = Math.min(e.target.scrollHeight, 150) + "px"; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSend(); + }; + + // Reset textarea height when value is cleared + useEffect(() => { + if (!value && inputRef.current) { + inputRef.current.style.height = "auto"; + } + }, [value]); + + return ( +
+