413 lines
16 KiB
JavaScript
413 lines
16 KiB
JavaScript
const http = require("http");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const crypto = require("crypto");
|
||
|
||
const PORT = process.env.PORT || 3000;
|
||
const DATA = path.join(__dirname, "data.json");
|
||
const SECRET_KEY = crypto.randomBytes(32).toString("hex");
|
||
|
||
function readData() {
|
||
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { users: [], chats: [], messages: [], groups: [], channels: [], sessions: [] }; }
|
||
}
|
||
|
||
function writeData(data) {
|
||
fs.writeFileSync(DATA, JSON.stringify(data, null, 2));
|
||
}
|
||
|
||
function parseBody(req) {
|
||
return new Promise((resolve) => {
|
||
let s = "";
|
||
req.on("data", (c) => (s += c));
|
||
req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } });
|
||
});
|
||
}
|
||
|
||
function json(res, data, status = 200) {
|
||
res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
||
res.end(JSON.stringify(data));
|
||
}
|
||
|
||
function notFound(res) {
|
||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||
res.end("Not Found");
|
||
}
|
||
|
||
// Генерация токена сессии
|
||
function generateToken(userId) {
|
||
return crypto.createHmac("sha256", SECRET_KEY).update(userId + Date.now()).digest("hex");
|
||
}
|
||
|
||
// Проверка токена
|
||
function verifyToken(token) {
|
||
const data = readData();
|
||
const session = data.sessions.find(s => s.token === token && new Date(s.expires) > new Date());
|
||
if (!session) return null;
|
||
return data.users.find(u => u.id === session.userId);
|
||
}
|
||
|
||
// Хеширование пароля
|
||
function hashPassword(password) {
|
||
return crypto.createHash("sha256").update(password + "vibechat_salt").digest("hex");
|
||
}
|
||
|
||
// E2E шифрование (упрощённое AES-GCM)
|
||
function encryptMessage(text, key) {
|
||
const iv = crypto.randomBytes(16);
|
||
const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(key.slice(0, 32).padEnd(32, "0")), iv);
|
||
let encrypted = cipher.update(text, "utf8", "hex");
|
||
encrypted += cipher.final("hex");
|
||
const authTag = cipher.getAuthTag().toString("hex");
|
||
return { iv: iv.toString("hex"), encrypted, authTag };
|
||
}
|
||
|
||
function decryptMessage(encryptedData, key) {
|
||
try {
|
||
const decipher = crypto.createDecipheriv("aes-256-gcm", Buffer.from(key.slice(0, 32).padEnd(32, "0")), Buffer.from(encryptedData.iv, "hex"));
|
||
decipher.setAuthTag(Buffer.from(encryptedData.authTag, "hex"));
|
||
let decrypted = decipher.update(encryptedData.encrypted, "hex", "utf8");
|
||
decrypted += decipher.final("utf8");
|
||
return decrypted;
|
||
} catch (e) {
|
||
return "[Ошибка расшифровки]";
|
||
}
|
||
}
|
||
|
||
const routes = {
|
||
// ========== AUTH ==========
|
||
"POST /api/auth/register": async (req, res) => {
|
||
const { username, email, password } = await parseBody(req);
|
||
if (!username || !email || !password) return json(res, { ok: false, error: "Все поля обязательны" }, 400);
|
||
|
||
const data = readData();
|
||
if (data.users.find(u => u.email === email)) return json(res, { ok: false, error: "Email уже зарегистрирован" }, 400);
|
||
if (data.users.find(u => u.username === username)) return json(res, { ok: false, error: "Имя пользователя занято" }, 400);
|
||
|
||
const user = {
|
||
id: Date.now(),
|
||
username,
|
||
email,
|
||
passwordHash: hashPassword(password),
|
||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}`,
|
||
bio: "",
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
|
||
data.users.push(user);
|
||
writeData(data);
|
||
|
||
const token = generateToken(user.id);
|
||
data.sessions.push({ token, userId: user.id, expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() });
|
||
writeData(data);
|
||
|
||
json(res, { ok: true, token, user: { id: user.id, username: user.username, email: user.email, avatar: user.avatar } });
|
||
},
|
||
|
||
"POST /api/auth/login": async (req, res) => {
|
||
const { email, password } = await parseBody(req);
|
||
if (!email || !password) return json(res, { ok: false, error: "Email и пароль обязательны" }, 400);
|
||
|
||
const data = readData();
|
||
const user = data.users.find(u => u.email === email && u.passwordHash === hashPassword(password));
|
||
if (!user) return json(res, { ok: false, error: "Неверный email или пароль" }, 401);
|
||
|
||
const token = generateToken(user.id);
|
||
data.sessions.push({ token, userId: user.id, expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() });
|
||
writeData(data);
|
||
|
||
json(res, { ok: true, token, user: { id: user.id, username: user.username, email: user.email, avatar: user.avatar } });
|
||
},
|
||
|
||
"POST /api/auth/logout": async (req, res) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Токен не предоставлен" }, 401);
|
||
|
||
const data = readData();
|
||
data.sessions = data.sessions.filter(s => s.token !== token);
|
||
writeData(data);
|
||
json(res, { ok: true });
|
||
},
|
||
|
||
"GET /api/auth/me": async (req, res) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
|
||
const user = verifyToken(token);
|
||
if (!user) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
json(res, { ok: true, user: { id: user.id, username: user.username, email: user.email, avatar: user.avatar, bio: user.bio } });
|
||
},
|
||
|
||
// ========== USERS ==========
|
||
"GET /api/users": async (req, res) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const data = readData();
|
||
const users = data.users.filter(u => u.id !== currentUser.id).map(u => ({
|
||
id: u.id, username: u.username, avatar: u.avatar
|
||
}));
|
||
json(res, { ok: true, users });
|
||
},
|
||
|
||
"PUT /api/users/me": async (req, res) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const { username, bio, avatar } = await parseBody(req);
|
||
const data = readData();
|
||
const idx = data.users.findIndex(u => u.id === currentUser.id);
|
||
if (idx === -1) return json(res, { ok: false, error: "Пользователь не найден" }, 404);
|
||
|
||
if (username) data.users[idx].username = username;
|
||
if (bio !== undefined) data.users[idx].bio = bio;
|
||
if (avatar) data.users[idx].avatar = avatar;
|
||
writeData(data);
|
||
|
||
json(res, { ok: true, user: data.users[idx] });
|
||
},
|
||
|
||
// ========== CHATS ==========
|
||
"GET /api/chats": async (req, res) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const data = readData();
|
||
const userChats = data.chats.filter(c => c.participants.includes(currentUser.id));
|
||
|
||
const chatsWithDetails = userChats.map(chat => {
|
||
const otherUserId = chat.type === "private" ? chat.participants.find(p => p !== currentUser.id) : null;
|
||
const otherUser = otherUserId ? data.users.find(u => u.id === otherUserId) : null;
|
||
const lastMessage = data.messages.filter(m => m.chatId === chat.id).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0];
|
||
|
||
return {
|
||
...chat,
|
||
name: chat.type === "private" ? otherUser?.username : chat.name,
|
||
avatar: chat.type === "private" ? otherUser?.avatar : chat.avatar,
|
||
lastMessage: lastMessage ? { text: lastMessage.text, createdAt: lastMessage.createdAt } : null
|
||
};
|
||
});
|
||
|
||
json(res, { ok: true, chats: chatsWithDetails });
|
||
},
|
||
|
||
"POST /api/chats": async (req, res) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const { type, participantId, name } = await parseBody(req);
|
||
const data = readData();
|
||
|
||
if (type === "private") {
|
||
const existingChat = data.chats.find(c => c.type === "private" && c.participants.includes(currentUser.id) && c.participants.includes(participantId));
|
||
if (existingChat) return json(res, { ok: true, chat: existingChat });
|
||
|
||
const chat = {
|
||
id: Date.now(),
|
||
type: "private",
|
||
participants: [currentUser.id, participantId],
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
data.chats.push(chat);
|
||
writeData(data);
|
||
json(res, { ok: true, chat });
|
||
} else if (type === "group") {
|
||
const chat = {
|
||
id: Date.now(),
|
||
type: "group",
|
||
name: name || "Группа",
|
||
participants: [currentUser.id],
|
||
avatar: `https://api.dicebear.com/7.x/identicon/svg?seed=${Date.now()}`,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
data.chats.push(chat);
|
||
writeData(data);
|
||
json(res, { ok: true, chat });
|
||
} else if (type === "channel") {
|
||
const channel = {
|
||
id: Date.now(),
|
||
type: "channel",
|
||
name: name || "Канал",
|
||
ownerId: currentUser.id,
|
||
subscribers: [currentUser.id],
|
||
avatar: `https://api.dicebear.com/7.x/identicon/svg?seed=${Date.now()}`,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
data.channels.push(channel);
|
||
data.chats.push({ id: channel.id, type: "channel", name: channel.name, ownerId: channel.id, subscribers: [currentUser.id], avatar: channel.avatar, createdAt: channel.createdAt });
|
||
writeData(data);
|
||
json(res, { ok: true, chat: channel });
|
||
}
|
||
},
|
||
|
||
"GET /api/chats/:id/messages": async (req, res, params) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const chatId = parseInt(params.id);
|
||
const data = readData();
|
||
const chat = data.chats.find(c => c.id === chatId);
|
||
if (!chat || !chat.participants.includes(currentUser.id)) return json(res, { ok: false, error: "Чат не найден" }, 404);
|
||
|
||
const messages = data.messages.filter(m => m.chatId === chatId).sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||
|
||
// E2E расшифровка
|
||
const userKey = hashPassword(currentUser.email + currentUser.id);
|
||
const decryptedMessages = messages.map(m => ({
|
||
...m,
|
||
text: decryptMessage(m.encrypted, userKey)
|
||
}));
|
||
|
||
json(res, { ok: true, messages: decryptedMessages });
|
||
},
|
||
|
||
"POST /api/chats/:id/messages": async (req, res, params) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const chatId = parseInt(params.id);
|
||
const { text, type = "text" } = await parseBody(req);
|
||
const data = readData();
|
||
|
||
const chat = data.chats.find(c => c.id === chatId);
|
||
if (!chat || !chat.participants.includes(currentUser.id)) return json(res, { ok: false, error: "Чат не найден" }, 404);
|
||
|
||
// E2E шифрование
|
||
const userKey = hashPassword(currentUser.email + currentUser.id);
|
||
const encrypted = encryptMessage(text, userKey);
|
||
|
||
const message = {
|
||
id: Date.now(),
|
||
chatId,
|
||
senderId: currentUser.id,
|
||
senderName: currentUser.username,
|
||
senderAvatar: currentUser.avatar,
|
||
text,
|
||
encrypted,
|
||
type,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
|
||
data.messages.push(message);
|
||
writeData(data);
|
||
json(res, { ok: true, message: { ...message, text } });
|
||
},
|
||
|
||
// ========== GROUPS ==========
|
||
"POST /api/groups/:id/members": async (req, res, params) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const groupId = parseInt(params.id);
|
||
const { userId } = await parseBody(req);
|
||
const data = readData();
|
||
|
||
const group = data.chats.find(c => c.id === groupId && c.type === "group");
|
||
if (!group || !group.participants.includes(currentUser.id)) return json(res, { ok: false, error: "Группа не найдена" }, 404);
|
||
|
||
if (!group.participants.includes(userId)) {
|
||
group.participants.push(userId);
|
||
writeData(data);
|
||
}
|
||
json(res, { ok: true, group });
|
||
},
|
||
|
||
// ========== CHANNELS ==========
|
||
"POST /api/channels/:id/subscribe": async (req, res, params) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) return json(res, { ok: false, error: "Не авторизован" }, 401);
|
||
const currentUser = verifyToken(token);
|
||
if (!currentUser) return json(res, { ok: false, error: "Сессия истекла" }, 401);
|
||
|
||
const channelId = parseInt(params.id);
|
||
const data = readData();
|
||
|
||
const channel = data.channels.find(c => c.id === channelId);
|
||
if (!channel) return json(res, { ok: false, error: "Канал не найден" }, 404);
|
||
|
||
if (!channel.subscribers.includes(currentUser.id)) {
|
||
channel.subscribers.push(currentUser.id);
|
||
const chatIdx = data.chats.findIndex(c => c.id === channelId);
|
||
if (chatIdx !== -1 && !data.chats[chatIdx].subscribers.includes(currentUser.id)) {
|
||
data.chats[chatIdx].subscribers.push(currentUser.id);
|
||
}
|
||
writeData(data);
|
||
}
|
||
json(res, { ok: true, channel });
|
||
}
|
||
};
|
||
|
||
const mimeTypes = {
|
||
".html": "text/html; charset=utf-8",
|
||
".css": "text/css; charset=utf-8",
|
||
".js": "application/javascript; charset=utf-8",
|
||
".json": "application/json; charset=utf-8"
|
||
};
|
||
|
||
http.createServer(async (req, res) => {
|
||
const url = req.url.split("?")[0];
|
||
const method = req.method;
|
||
|
||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||
|
||
if (method === "OPTIONS") {
|
||
res.writeHead(204);
|
||
return res.end();
|
||
}
|
||
|
||
// API ROUTING
|
||
for (const [route, handler] of Object.entries(routes)) {
|
||
const [m, p] = route.split(" ");
|
||
if (method !== m) continue;
|
||
|
||
const routeParts = p.split("/");
|
||
const urlParts = url.split("/");
|
||
|
||
if (routeParts.length !== urlParts.length) continue;
|
||
|
||
const params = {};
|
||
let match = true;
|
||
for (let i = 0; i < routeParts.length; i++) {
|
||
if (routeParts[i].startsWith(":")) {
|
||
params[routeParts[i].slice(1)] = urlParts[i];
|
||
} else if (routeParts[i] !== urlParts[i]) {
|
||
match = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (match) {
|
||
return handler(req, res, params);
|
||
}
|
||
}
|
||
|
||
// STATIC FILES
|
||
let file = url === "/" ? "/index.html" : url;
|
||
const full = path.join(__dirname, file);
|
||
|
||
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||
const ext = path.extname(full);
|
||
const type = mimeTypes[ext] || "application/octet-stream";
|
||
res.writeHead(200, { "Content-Type": type });
|
||
return res.end(fs.readFileSync(full));
|
||
}
|
||
|
||
notFound(res);
|
||
}).listen(PORT, () => console.log("VibeChat server running on port " + PORT));
|