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:
@@ -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
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user