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
This commit is contained in:
Alexej Wolff
2026-05-05 23:41:52 +02:00
parent bbefa114f8
commit 68c2b129fa
23 changed files with 1817 additions and 553 deletions
+7
View File
@@ -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
+484 -17
View File
@@ -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}`);
});
});