541 lines
15 KiB
JavaScript
541 lines
15 KiB
JavaScript
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 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
|
||
app.use(express.json());
|
||
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: false, // true в production с HTTPS
|
||
httpOnly: true,
|
||
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 ============
|
||
|
||
// Начало авторизации 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 newStory = {
|
||
...req.body,
|
||
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 result = await stories.updateOne(
|
||
{
|
||
_id: new ObjectId(req.params.id),
|
||
userId: req.session.userId,
|
||
},
|
||
{
|
||
$set: {
|
||
...req.body,
|
||
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 session = await sessions.findOne({
|
||
storyId: req.params.storyId,
|
||
userId: req.session.userId,
|
||
});
|
||
|
||
res.json(session);
|
||
} 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");
|
||
|
||
// Преобразуем timestamp строки обратно в Date
|
||
const messages = (req.body.messages || []).map((msg) => ({
|
||
...msg,
|
||
timestamp: new Date(msg.timestamp),
|
||
}));
|
||
|
||
// Убираем _id и createdAt из body чтобы не было конфликтов
|
||
const { createdAt, _id, id, ...bodyWithoutMeta } = req.body;
|
||
|
||
const sessionData = {
|
||
...bodyWithoutMeta,
|
||
messages,
|
||
storyId: req.params.storyId,
|
||
userId: req.session.userId,
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
await sessions.updateOne(
|
||
{
|
||
storyId: req.params.storyId,
|
||
userId: req.session.userId,
|
||
},
|
||
{
|
||
$set: sessionData,
|
||
$setOnInsert: { createdAt: new Date() },
|
||
},
|
||
{ upsert: true },
|
||
);
|
||
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error("Save session error:", error);
|
||
res.status(500).json({ error: "Failed to save 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 newCharacter = {
|
||
...req.body,
|
||
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 result = await characters.updateOne(
|
||
{
|
||
_id: new ObjectId(req.params.id),
|
||
userId: req.session.userId,
|
||
},
|
||
{
|
||
$set: {
|
||
...req.body,
|
||
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" });
|
||
}
|
||
});
|
||
|
||
// ============ ADMIN STATS ============
|
||
|
||
// Получить статистику по всем историям и токенам
|
||
app.get("/api/admin/stats", requireAuth, async (req, res) => {
|
||
try {
|
||
const stories = db.collection("stories");
|
||
const gameSessions = db.collection("game_sessions");
|
||
|
||
// Получаем все истории пользователя
|
||
const userStories = await stories
|
||
.find({ userId: req.session.userId })
|
||
.toArray();
|
||
|
||
// Получаем все сессии пользователя
|
||
const userSessions = await gameSessions
|
||
.find({ storyId: { $in: userStories.map((s) => s._id.toString()) } })
|
||
.toArray();
|
||
|
||
// Считаем статистику для каждой истории
|
||
const storyStats = userStories.map((story) => {
|
||
const session = userSessions.find(
|
||
(s) => s.storyId === story._id.toString(),
|
||
);
|
||
const messages = session?.messages || [];
|
||
const messageCount = messages.length;
|
||
|
||
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
|
||
const totalChars = messages.reduce(
|
||
(sum, msg) => sum + (msg.content?.length || 0),
|
||
0,
|
||
);
|
||
const tokens = Math.round(totalChars / 3);
|
||
|
||
return {
|
||
id: story._id.toString(),
|
||
title: story.title,
|
||
messageCount,
|
||
tokens,
|
||
lastPlayed: session?.updatedAt || null,
|
||
};
|
||
});
|
||
|
||
// Сортируем по токенам (больше сверху)
|
||
storyStats.sort((a, b) => b.tokens - a.tokens);
|
||
|
||
// Общая статистика
|
||
const totalTokens = storyStats.reduce((sum, s) => sum + s.tokens, 0);
|
||
|
||
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" });
|
||
}
|
||
});
|
||
|
||
// Запуск сервера
|
||
connectDB().then(() => {
|
||
app.listen(PORT, () => {
|
||
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
||
});
|
||
});
|