This commit is contained in:
Халимов Рустам
2026-03-10 21:11:59 +03:00
parent a3c0d35263
commit b4b4b8e0d3
80 changed files with 23310 additions and 0 deletions

40
apps/server/src/config.ts Normal file
View File

@@ -0,0 +1,40 @@
import dotenv from 'dotenv';
import path from 'path';
import { initEncryption } from './encrypt';
dotenv.config({ path: path.join(__dirname, '../.env') });
if (!process.env.JWT_SECRET) {
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET не задан в .env — нельзя запускать в production без секрета!');
}
console.error(' ⚠ JWT_SECRET не задан в .env — используется dev-значение. Укажите безопасный секрет в продакшене!');
}
// Initialise message encryption (AES-256-GCM)
if (process.env.ENCRYPTION_KEY) {
initEncryption(process.env.ENCRYPTION_KEY);
console.log(' 🔒 Шифрование сообщений включено (AES-256-GCM)');
} else {
console.warn(' ⚠ ENCRYPTION_KEY не задан — сообщения хранятся без шифрования. Для продакшена задайте 64-символьный hex-ключ.');
}
export const config = {
port: Number(process.env.PORT) || 3001,
jwtSecret: process.env.JWT_SECRET || 'vortex-dev-fallback-not-for-production',
corsOrigins: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(s => s.trim())
: ['http://localhost:5173', 'http://localhost:3000'],
uploadsDir: 'uploads',
/** Minimum password length */
minPasswordLength: 8,
/** Maximum registrations allowed from the same IP (permanent, DB-level) */
maxRegistrationsPerIp: Number(process.env.MAX_REGISTRATIONS_PER_IP) || 2,
/** TURN server URL for WebRTC calls (e.g. turn:your-domain.com:3478) */
turnUrl: process.env.TURN_URL || '',
/** Shared secret for TURN server (coturn static-auth-secret) */
turnSecret: process.env.TURN_SECRET || '',
/** STUN server URLs */
stunUrls: (process.env.STUN_URLS || 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302')
.split(',').map(s => s.trim()).filter(Boolean),
};

153
apps/server/src/db.ts Normal file
View File

@@ -0,0 +1,153 @@
import { PrismaClient } from '@prisma/client';
import { encryptText, decryptText, isEncryptionEnabled } from './encrypt';
const basePrisma = new PrismaClient();
// ─── Prisma extension: transparent message encryption ───────────────
// Encrypts `content` and `quote` before writing to DB,
// decrypts them after reading. This way the DB never stores plaintext.
export const prisma = basePrisma.$extends({
query: {
message: {
async create({ args, query }) {
if (args.data.content && typeof args.data.content === 'string') {
args.data.content = encryptText(args.data.content);
}
if (args.data.quote && typeof args.data.quote === 'string') {
args.data.quote = encryptText(args.data.quote);
}
const result = await query(args);
decryptMessageFields(result as Record<string, unknown>);
return result;
},
async update({ args, query }) {
if (args.data.content && typeof args.data.content === 'string') {
args.data.content = encryptText(args.data.content);
}
if (args.data.quote && typeof args.data.quote === 'string') {
args.data.quote = encryptText(args.data.quote);
}
const result = await query(args);
decryptMessageFields(result as Record<string, unknown>);
return result;
},
async upsert({ args, query }) {
if (args.create.content && typeof args.create.content === 'string') {
args.create.content = encryptText(args.create.content);
}
if (args.create.quote && typeof args.create.quote === 'string') {
args.create.quote = encryptText(args.create.quote);
}
if (args.update.content && typeof args.update.content === 'string') {
(args.update as Record<string, unknown>).content = encryptText(args.update.content as string);
}
if (args.update.quote && typeof args.update.quote === 'string') {
(args.update as Record<string, unknown>).quote = encryptText(args.update.quote as string);
}
const result = await query(args);
decryptMessageFields(result as Record<string, unknown>);
return result;
},
async findUnique({ args, query }) {
const result = await query(args);
if (result) decryptMessageFields(result as Record<string, unknown>);
return result;
},
async findFirst({ args, query }) {
const result = await query(args);
if (result) decryptMessageFields(result as Record<string, unknown>);
return result;
},
async findMany({ args, query }) {
const results = await query(args);
for (const item of results) {
decryptMessageFields(item as Record<string, unknown>);
}
return results;
},
},
// Also decrypt messages nested inside Chat queries
chat: {
async findMany({ args, query }) {
const results = await query(args);
for (const chat of results) {
decryptChatMessages(chat as Record<string, unknown>);
}
return results;
},
async findFirst({ args, query }) {
const result = await query(args);
if (result) decryptChatMessages(result as Record<string, unknown>);
return result;
},
async findUnique({ args, query }) {
const result = await query(args);
if (result) decryptChatMessages(result as Record<string, unknown>);
return result;
},
async create({ args, query }) {
const result = await query(args);
decryptChatMessages(result as Record<string, unknown>);
return result;
},
},
// Decrypt message inside PinnedMessage queries
pinnedMessage: {
async findFirst({ args, query }) {
const result = await query(args);
if (result) decryptNested(result as Record<string, unknown>);
return result;
},
async findMany({ args, query }) {
const results = await query(args);
for (const item of results) decryptNested(item as Record<string, unknown>);
return results;
},
},
},
});
/** Decrypt content/quote on a message-shaped object. */
function decryptMessageFields(obj: Record<string, unknown> | null): void {
if (!obj || typeof obj !== 'object' || !isEncryptionEnabled()) return;
if (typeof obj.content === 'string') {
obj.content = decryptText(obj.content);
}
if (typeof obj.quote === 'string') {
obj.quote = decryptText(obj.quote);
}
// Nested replyTo
if (obj.replyTo && typeof obj.replyTo === 'object') {
decryptMessageFields(obj.replyTo as Record<string, unknown>);
}
}
/** Decrypt messages nested inside a chat object. */
function decryptChatMessages(chat: Record<string, unknown>): void {
if (!chat || !isEncryptionEnabled()) return;
if (Array.isArray(chat.messages)) {
for (const msg of chat.messages) {
decryptMessageFields(msg as Record<string, unknown>);
}
}
// pinnedMessages[].message
if (Array.isArray(chat.pinnedMessages)) {
for (const pm of chat.pinnedMessages) {
const pmo = pm as Record<string, unknown>;
if (pmo.message && typeof pmo.message === 'object') {
decryptMessageFields(pmo.message as Record<string, unknown>);
}
}
}
}
/** Decrypt nested message field on any object (e.g. PinnedMessage.message). */
function decryptNested(obj: Record<string, unknown>): void {
if (!obj || !isEncryptionEnabled()) return;
if (obj.message && typeof obj.message === 'object') {
decryptMessageFields(obj.message as Record<string, unknown>);
}
}

154
apps/server/src/encrypt.ts Normal file
View File

@@ -0,0 +1,154 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
/**
* AES-256-GCM encryption for message content and files.
*
* Text format: "enc:v1:<iv_hex>:<authTag_hex>:<ciphertext_hex>"
* File format: [12 bytes IV][16 bytes AuthTag][...ciphertext...]
*
* The "enc:v1:" prefix allows detecting encrypted vs plain-text content
* for backward compatibility with existing unencrypted messages.
*/
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96 bits — recommended for GCM
const AUTH_TAG_LENGTH = 16;
const FILE_HEADER_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH; // 28 bytes
const PREFIX = 'enc:v1:';
let encryptionKey: Buffer | null = null;
/** Initialise encryption with a 64-char hex key (32 bytes). */
export function initEncryption(hexKey: string): void {
if (!hexKey || hexKey.length !== 64) {
throw new Error(
'ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' +
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
);
}
encryptionKey = Buffer.from(hexKey, 'hex');
}
/** Returns true if encryption is enabled (key configured). */
export function isEncryptionEnabled(): boolean {
return encryptionKey !== null;
}
/** Encrypt a plain-text string. Returns the encrypted string or the original if encryption is disabled. */
export function encryptText(plaintext: string): string {
if (!encryptionKey || !plaintext) return plaintext;
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${PREFIX}${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}
/** Decrypt an encrypted string. Returns the plain text, or the original string if it's not encrypted. */
export function decryptText(ciphertext: string): string {
if (!ciphertext || !ciphertext.startsWith(PREFIX)) {
// Not encrypted (legacy data or null) — return as-is
return ciphertext;
}
if (!encryptionKey) {
console.error('Cannot decrypt: ENCRYPTION_KEY not configured');
return '[зашифровано]';
}
try {
const payload = ciphertext.slice(PREFIX.length);
const [ivHex, tagHex, dataHex] = payload.split(':');
if (!ivHex || !tagHex || !dataHex) return '[повреждённые данные]';
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(tagHex, 'hex');
const encrypted = Buffer.from(dataHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
} catch (e) {
console.error('Decryption failed:', e);
return '[ошибка расшифровки]';
}
}
// ─── File encryption ─────────────────────────────────────────────────
/**
* Encrypt a file in-place on disk.
* Replaces the original file with: [IV 12B][AuthTag 16B][ciphertext...]
*/
export function encryptFileInPlace(filePath: string): void {
if (!encryptionKey) return;
const plainData = fs.readFileSync(filePath);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(plainData), cipher.final()]);
const authTag = cipher.getAuthTag();
// Write: IV + AuthTag + Ciphertext
const output = Buffer.concat([iv, authTag, encrypted]);
fs.writeFileSync(filePath, output);
}
/**
* Check if a file appears to be encrypted (has valid header size).
* This is a heuristic — not 100% reliable on tiny files, but good enough.
*/
export function isFileEncrypted(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
// Encrypted files must be at least 28 bytes (header).
// We also check if decryption with the key succeeds on the first chunk.
if (stat.size < FILE_HEADER_LENGTH) return false;
// If encryption is disabled, treat all files as plain
if (!encryptionKey) return false;
return true; // Assume encrypted if key is configured and file is large enough
} catch {
return false;
}
}
/**
* Decrypt a file and return the plain-text Buffer.
* Returns null if decryption fails (file may be unencrypted).
*/
export function decryptFileToBuffer(filePath: string): Buffer | null {
if (!encryptionKey) return null;
try {
const data = fs.readFileSync(filePath);
if (data.length < FILE_HEADER_LENGTH) return null;
const iv = data.subarray(0, IV_LENGTH);
const authTag = data.subarray(IV_LENGTH, FILE_HEADER_LENGTH);
const ciphertext = data.subarray(FILE_HEADER_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted;
} catch {
// Decryption failed — file is likely not encrypted (legacy)
return null;
}
}
/**
* Resolve a URL path like '/uploads/avatars/abc.jpg' to an absolute file path.
*/
export function resolveUploadPath(urlPath: string, uploadsRoot: string): string | null {
if (!urlPath) return null;
const filename = urlPath.replace(/^\/uploads\//, '');
const filePath = path.resolve(uploadsRoot, filename);
// Path containment check
if (!filePath.startsWith(uploadsRoot)) return null;
return filePath;
}

188
apps/server/src/index.ts Normal file
View File

@@ -0,0 +1,188 @@
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import mime from 'mime-types';
import { config } from './config';
import { prisma } from './db';
import authRoutes from './routes/auth';
import userRoutes from './routes/users';
import chatRoutes from './routes/chats';
import messageRoutes from './routes/messages';
import storyRoutes from './routes/stories';
import friendRoutes from './routes/friends';
import { setupSocket } from './socket';
import { authenticateToken, AuthRequest } from './middleware/auth';
import { decryptFileToBuffer, isEncryptionEnabled } from './encrypt';
import { UPLOADS_ROOT } from './shared';
const app = express();
const server = createServer(app);
const io = new Server(server, {
cors: {
origin: config.corsOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
},
});
// Trust first proxy (Nginx) so req.ip returns real client IP from X-Forwarded-For
app.set('trust proxy', 1);
app.use(cors({ origin: config.corsOrigins }));
app.use(express.json({ limit: '10mb' }));
// Serve uploads — decrypts encrypted files on the fly
app.use('/uploads', (req, res, next) => {
// Security headers
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('Cache-Control', 'private, max-age=86400');
// Resolve file path safely
const urlPath = decodeURIComponent(req.path);
if (urlPath.includes('..')) {
res.status(400).end();
return;
}
const filePath = path.resolve(UPLOADS_ROOT, urlPath.replace(/^\//, ''));
if (!filePath.startsWith(UPLOADS_ROOT) || !fs.existsSync(filePath)) {
res.status(404).end();
return;
}
// Set Content-Type from extension
const contentType = mime.lookup(filePath) || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
// If encryption is enabled, try to decrypt
if (isEncryptionEnabled()) {
const decrypted = decryptFileToBuffer(filePath);
if (decrypted) {
res.setHeader('Content-Length', decrypted.length);
res.end(decrypted);
return;
}
// Decryption failed — file is likely unencrypted (legacy), fall through to static
}
// Serve unencrypted file as-is
next();
}, express.static(UPLOADS_ROOT));
// Rate limiting for auth endpoints (prevent brute-force)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // max 20 attempts per window
message: { error: 'Слишком много попыток, попробуйте позже' },
standardHeaders: true,
legacyHeaders: false,
});
// General API rate limiter (100 req/min per IP)
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
message: { error: 'Слишком много запросов, попробуйте позже' },
standardHeaders: true,
legacyHeaders: false,
});
// API маршруты — auth/me uses general limiter (called on every page load)
app.use('/api/auth/me', apiLimiter, authRoutes);
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/users', apiLimiter, authenticateToken, userRoutes);
app.use('/api/chats', apiLimiter, authenticateToken, chatRoutes);
app.use('/api/messages', apiLimiter, authenticateToken, messageRoutes);
app.use('/api/stories', apiLimiter, authenticateToken, storyRoutes);
app.use('/api/friends', apiLimiter, authenticateToken, friendRoutes);
// Проверка здоровья
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', name: 'Vortex Server' });
});
// ICE серверы для WebRTC звонков
app.get('/api/ice-servers', authenticateToken, (_req: AuthRequest, res) => {
const iceServers: Array<{ urls: string | string[]; username?: string; credential?: string }> = [];
// STUN серверы
if (config.stunUrls.length > 0) {
iceServers.push({ urls: config.stunUrls });
}
// TURN сервер с временными credentials (coturn --use-auth-secret)
if (config.turnUrl && config.turnSecret) {
const ttl = 24 * 3600; // 24 часа
const timestamp = Math.floor(Date.now() / 1000) + ttl;
const username = `${timestamp}:vortex`;
const credential = crypto
.createHmac('sha1', config.turnSecret)
.update(username)
.digest('base64');
iceServers.push({
urls: config.turnUrl,
username,
credential,
});
}
res.json({ iceServers });
});
// Socket.io
setupSocket(io);
// При старте сервера сбросить всех в offline
prisma.user.updateMany({ data: { isOnline: false, lastSeen: new Date() } })
.then(() => console.log(' ✔ Все пользователи сброшены в offline'))
.catch((e: unknown) => console.error('Ошибка сброса онлайн-статусов:', e));
// Cleanup expired stories (every 10 minutes)
import { deleteUploadedFile } from './shared';
async function cleanupExpiredStories() {
try {
const expired = await prisma.story.findMany({
where: { expiresAt: { lte: new Date() } },
select: { id: true, mediaUrl: true },
});
if (expired.length === 0) return;
for (const story of expired) {
if (story.mediaUrl) deleteUploadedFile(story.mediaUrl);
}
const ids = expired.map(s => s.id);
// Cascade handles StoryView deletion via schema onDelete: Cascade
await prisma.story.deleteMany({ where: { id: { in: ids } } });
console.log(` 🗑 Удалено ${expired.length} истёкших историй`);
} catch (e) {
console.error('Story cleanup error:', e);
}
}
cleanupExpiredStories();
setInterval(cleanupExpiredStories, 10 * 60 * 1000);
server.listen(config.port, () => {
console.log(`\n ⚡ Vortex Server запущен на порту ${config.port}\n`);
});
// Graceful shutdown
const shutdown = async () => {
console.log('\n Завершение работы...');
await prisma.$disconnect();
server.close(() => {
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

View File

@@ -0,0 +1,26 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
export interface AuthRequest extends Request {
userId?: string;
}
export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Требуется авторизация' });
return;
}
try {
const decoded = jwt.verify(token, config.jwtSecret) as { userId: string };
req.userId = decoded.userId;
next();
} catch {
res.status(403).json({ error: 'Недействительный токен' });
return;
}
}

View File

@@ -0,0 +1,169 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../db';
import { config } from '../config';
import { USER_SELECT } from '../shared';
import { authenticateToken, AuthRequest } from '../middleware/auth';
import rateLimit from 'express-rate-limit';
const router = Router();
// ─── Strict registration rate limiter: 3 registrations per IP per hour ───
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
message: { error: 'Слишком много регистраций с этого IP. Попробуйте через час.' },
standardHeaders: true,
legacyHeaders: false,
validate: false,
keyGenerator: (req) => req.ip || req.socket.remoteAddress || 'unknown',
});
// In-memory cooldown: track last registration timestamp per IP (prevents rapid-fire even within rate limit)
const registrationCooldowns = new Map<string, number>();
const REGISTRATION_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between registrations from same IP
// Регистрация
router.post('/register', registerLimiter, async (req, res) => {
try {
const { username, displayName, password, bio } = req.body;
// ── IP cooldown check ──
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
const lastReg = registrationCooldowns.get(clientIp);
if (lastReg && Date.now() - lastReg < REGISTRATION_COOLDOWN_MS) {
const waitMinutes = Math.ceil((REGISTRATION_COOLDOWN_MS - (Date.now() - lastReg)) / 60000);
res.status(429).json({ error: `Подождите ${waitMinutes} мин. перед созданием нового аккаунта` });
return;
}
// ── Permanent IP limit (DB-level) ──
const accountsFromIp = await prisma.user.count({ where: { registrationIp: clientIp } });
if (accountsFromIp >= config.maxRegistrationsPerIp) {
res.status(403).json({ error: `Максимум ${config.maxRegistrationsPerIp} аккаунта с одного IP. Лимит исчерпан.` });
return;
}
if (!username || !password) {
res.status(400).json({ error: 'Username и пароль обязательны' });
return;
}
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
res.status(400).json({ error: 'Username: 3-20 символов, только латиница, цифры, _' });
return;
}
if (password.length < config.minPasswordLength) {
res.status(400).json({ error: `Пароль должен быть не менее ${config.minPasswordLength} символов` });
return;
}
// Password must contain at least one letter and one digit
if (!/[a-zA-Zа-яА-Я]/.test(password) || !/\d/.test(password)) {
res.status(400).json({ error: 'Пароль должен содержать буквы и цифры' });
return;
}
// Validate optional fields
if (displayName !== undefined && (typeof displayName !== 'string' || displayName.length > 50)) {
res.status(400).json({ error: 'Имя должно быть не длиннее 50 символов' });
return;
}
if (bio !== undefined && (typeof bio !== 'string' || bio.length > 500)) {
res.status(400).json({ error: 'Био должно быть не длиннее 500 символов' });
return;
}
const existing = await prisma.user.findUnique({ where: { username: username.toLowerCase() } });
if (existing) {
res.status(400).json({ error: 'Этот username уже занят' });
return;
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username: username.toLowerCase(),
displayName: (displayName || username).slice(0, 50),
password: hashedPassword,
bio: bio ? bio.slice(0, 500) : null,
registrationIp: clientIp,
},
select: USER_SELECT,
});
const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '30d' });
// Track registration for cooldown
registrationCooldowns.set(clientIp, Date.now());
res.json({ token, user: { ...user, isOnline: true } });
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Вход
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username и пароль обязательны' });
return;
}
const user = await prisma.user.findUnique({
where: { username: username.toLowerCase() },
select: { ...USER_SELECT, password: true },
});
if (!user) {
res.status(400).json({ error: 'Неверный username или пароль' });
return;
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
res.status(400).json({ error: 'Неверный username или пароль' });
return;
}
await prisma.user.update({
where: { id: user.id },
data: { isOnline: true, lastSeen: new Date() },
});
const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '30d' });
const { password: _, ...userWithoutPassword } = user;
res.json({ token, user: { ...userWithoutPassword, isOnline: true } });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Текущий пользователь — uses authenticateToken middleware instead of duplicating JWT parsing
router.get('/me', authenticateToken, async (req: AuthRequest, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: USER_SELECT,
});
if (!user) {
res.status(404).json({ error: 'Пользователь не найден' });
return;
}
res.json({ user });
} catch {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
export default router;

View File

@@ -0,0 +1,608 @@
import { Router } from 'express';
import { Prisma } from '@prisma/client';
import { prisma } from '../db';
import { AuthRequest } from '../middleware/auth';
import { USER_SELECT, SENDER_SELECT, uploadGroupAvatar, deleteUploadedFile, encryptUploadedFile } from '../shared';
const router = Router();
// Compact user select for chat member lists (no bio/birthday)
const CHAT_USER_SELECT = {
id: true,
username: true,
displayName: true,
avatar: true,
isOnline: true,
lastSeen: true,
};
// Получить все чаты пользователя
router.get('/', async (req: AuthRequest, res) => {
try {
const chats = await prisma.chat.findMany({
where: {
members: { some: { userId: req.userId } },
},
include: {
members: {
include: { user: { select: CHAT_USER_SELECT } },
},
messages: {
where: {
isDeleted: false,
OR: [
{ scheduledAt: null },
{ senderId: req.userId! },
],
},
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
pinnedMessages: {
orderBy: { pinnedAt: 'desc' },
take: 1,
include: {
message: {
include: {
sender: { select: SENDER_SELECT },
media: true,
},
},
},
},
},
});
// Batch unread counts in a single query to avoid N+1
const chatIds = chats.map(c => c.id);
let unreadCounts: Array<{ chatId: string; count: bigint }> = [];
if (chatIds.length > 0) {
unreadCounts = await prisma.$queryRaw<Array<{ chatId: string; count: bigint }>>(
Prisma.sql`SELECT m."chatId", COUNT(m.id) as count FROM "Message" m
LEFT JOIN "ReadReceipt" rr ON rr."messageId" = m.id AND rr."userId" = ${req.userId}
WHERE m."chatId" IN (${Prisma.join(chatIds)})
AND m."senderId" != ${req.userId} AND m."isDeleted" = false AND rr.id IS NULL
AND m."scheduledAt" IS NULL
GROUP BY m."chatId"`
).catch(() => [] as Array<{ chatId: string; count: bigint }>);
}
const unreadMap = new Map(unreadCounts.map(r => [r.chatId, Number(r.count)]));
// Filter last message by clearedAt per user
const chatsFiltered = chats.map((chat) => {
const member = chat.members.find((m) => m.userId === req.userId);
const clearedAt = member?.clearedAt;
if (clearedAt && chat.messages.length > 0) {
const filtered = chat.messages.filter((msg) => new Date(msg.createdAt) > new Date(clearedAt));
return { ...chat, messages: filtered };
}
return chat;
});
const sortedChats = chatsFiltered.sort((a, b) => {
const aPinned = a.members.find((m) => m.userId === req.userId)?.isPinned || false;
const bPinned = b.members.find((m) => m.userId === req.userId)?.isPinned || false;
if (aPinned && !bPinned) return -1;
if (!aPinned && bPinned) return 1;
const aDate = a.messages[0]?.createdAt || a.createdAt;
const bDate = b.messages[0]?.createdAt || b.createdAt;
return new Date(bDate).getTime() - new Date(aDate).getTime();
});
const chatsWithUnread = sortedChats.map((chat) => ({
...chat,
unreadCount: unreadMap.get(chat.id) || 0,
}));
res.json(chatsWithUnread);
} catch (error) {
console.error('Get chats error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Создать личный чат
router.post('/personal', async (req: AuthRequest, res) => {
try {
const { userId } = req.body;
if (!userId) {
res.status(400).json({ error: 'ID пользователя обязателен' });
return;
}
const existingChat = await prisma.chat.findFirst({
where: {
type: 'personal',
AND: [
{ members: { some: { userId: req.userId } } },
{ members: { some: { userId } } },
],
},
include: {
members: { include: { user: { select: CHAT_USER_SELECT } } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
},
});
if (existingChat) {
res.json({ ...existingChat, unreadCount: 0 });
return;
}
const chat = await prisma.chat.create({
data: {
type: 'personal',
members: {
create: [{ userId: req.userId! }, { userId }],
},
},
include: {
members: { include: { user: { select: CHAT_USER_SELECT } } },
messages: true,
},
});
res.json({ ...chat, unreadCount: 0 });
} catch (error) {
console.error('Create chat error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Создать или получить чат "Избранное" (saved messages)
router.post('/favorites', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
// Check if favorites chat already exists
const existing = await prisma.chat.findFirst({
where: {
type: 'favorites',
members: { some: { userId } },
},
include: {
members: { include: { user: { select: CHAT_USER_SELECT } } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
},
});
if (existing) {
res.json({ ...existing, unreadCount: 0 });
return;
}
const chat = await prisma.chat.create({
data: {
type: 'favorites',
name: null,
members: {
create: [{ userId, role: 'admin' }],
},
},
include: {
members: { include: { user: { select: CHAT_USER_SELECT } } },
messages: true,
},
});
res.json({ ...chat, unreadCount: 0 });
} catch (error) {
console.error('Create favorites chat error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Создать групповой чат
router.post('/group', async (req: AuthRequest, res) => {
try {
const { name, memberIds } = req.body;
if (!name || !memberIds || !Array.isArray(memberIds)) {
res.status(400).json({ error: 'Название и участники обязательны' });
return;
}
// Validate group name length
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 100) {
res.status(400).json({ error: 'Название группы должно быть от 1 до 100 символов' });
return;
}
// Limit max members
if (memberIds.length > 256) {
res.status(400).json({ error: 'Максимум 256 участников в группе' });
return;
}
const allMemberIds = [...new Set([req.userId!, ...memberIds])];
const chat = await prisma.chat.create({
data: {
type: 'group',
name,
members: {
create: allMemberIds.map((uid) => ({
userId: uid,
role: uid === req.userId ? 'admin' : 'member',
})),
},
},
include: {
members: { include: { user: { select: CHAT_USER_SELECT } } },
messages: true,
},
});
res.json({ ...chat, unreadCount: 0 });
} catch (error) {
console.error('Create group error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Получить чат по ID
router.get('/:id', async (req: AuthRequest, res) => {
try {
const chat = await prisma.chat.findFirst({
where: {
id: String(req.params.id),
members: { some: { userId: req.userId } },
},
include: {
members: { include: { user: { select: CHAT_USER_SELECT } } },
},
});
if (!chat) {
res.status(404).json({ error: 'Чат не найден' });
return;
}
res.json(chat);
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Обновить группу (только админ)
router.put('/:id', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
const { name } = req.body;
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId: req.userId! } },
});
if (!member || member.role !== 'admin') {
res.status(403).json({ error: 'Только администратор может редактировать группу' });
return;
}
const chat = await prisma.chat.update({
where: { id: chatId },
data: { name },
include: {
members: { include: { user: { select: USER_SELECT } } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
},
});
res.json(chat);
} catch (error) {
console.error('Update group error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Загрузить аватар группы (только админ)
router.post('/:id/avatar', uploadGroupAvatar.single('avatar'), encryptUploadedFile, async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId: req.userId! } },
});
if (!member || member.role !== 'admin') {
res.status(403).json({ error: 'Только администратор может менять аватар группы' });
return;
}
if (!req.file) {
res.status(400).json({ error: 'Файл не загружен' });
return;
}
// Delete old avatar file
const currentChat = await prisma.chat.findUnique({ where: { id: chatId }, select: { avatar: true } });
if (currentChat?.avatar) deleteUploadedFile(currentChat.avatar);
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const chat = await prisma.chat.update({
where: { id: chatId },
data: { avatar: avatarUrl },
include: {
members: { include: { user: { select: USER_SELECT } } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
},
});
res.json(chat);
} catch (error) {
console.error('Upload group avatar error:', error);
res.status(500).json({ error: 'Ошибка загрузки аватара' });
}
});
// Удалить аватар группы (только админ)
router.delete('/:id/avatar', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId: req.userId! } },
});
if (!member || member.role !== 'admin') {
res.status(403).json({ error: 'Только администратор может менять аватар группы' });
return;
}
// Delete file from disk
const currentChat = await prisma.chat.findUnique({ where: { id: chatId }, select: { avatar: true } });
if (currentChat?.avatar) deleteUploadedFile(currentChat.avatar);
const chat = await prisma.chat.update({
where: { id: chatId },
data: { avatar: null },
include: {
members: { include: { user: { select: USER_SELECT } } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
},
});
res.json(chat);
} catch (error) {
res.status(500).json({ error: 'Ошибка удаления аватара' });
}
});
// Добавить участников в группу (только админ)
router.post('/:id/members', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
const { userIds } = req.body;
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
res.status(400).json({ error: 'Необходимо указать пользователей' });
return;
}
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId: req.userId! } },
});
if (!member || member.role !== 'admin') {
res.status(403).json({ error: 'Только администратор может добавлять участников' });
return;
}
const chat = await prisma.chat.findUnique({ where: { id: chatId } });
if (!chat || chat.type !== 'group') {
res.status(400).json({ error: 'Чат не является группой' });
return;
}
for (const uid of userIds) {
await prisma.chatMember.upsert({
where: { chatId_userId: { chatId, userId: uid } },
create: { chatId, userId: uid, role: 'member' },
update: {},
});
}
const updatedChat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
members: { include: { user: { select: USER_SELECT } } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
},
});
res.json(updatedChat);
} catch (error) {
console.error('Add members error:', error);
res.status(500).json({ error: 'Ошибка добавления участников' });
}
});
// Удалить участника из группы (только админ)
router.delete('/:id/members/:userId', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
const targetUserId = String(req.params.userId);
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId: req.userId! } },
});
if (!member || member.role !== 'admin') {
res.status(403).json({ error: 'Только администратор может удалять участников' });
return;
}
if (targetUserId === req.userId) {
res.status(400).json({ error: 'Нельзя удалить себя из группы' });
return;
}
await prisma.chatMember.delete({
where: { chatId_userId: { chatId, userId: targetUserId } },
});
const updatedChat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
members: { include: { user: { select: USER_SELECT } } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
include: {
sender: { select: { id: true, username: true, displayName: true } },
readBy: { select: { userId: true } },
},
},
},
});
res.json(updatedChat);
} catch (error) {
console.error('Remove member error:', error);
res.status(500).json({ error: 'Ошибка удаления участника' });
}
});
// Очистить чат для себя
router.post('/:id/clear', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
await prisma.chatMember.update({
where: { chatId_userId: { chatId, userId: req.userId! } },
data: { clearedAt: new Date() },
});
res.json({ success: true });
} catch (error) {
console.error('Clear chat error:', error);
res.status(500).json({ error: 'Ошибка очистки чата' });
}
});
// Удалить чат (для текущего пользователя — выйти из чата)
router.delete('/:id', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
const userId = req.userId!;
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: { members: true },
});
if (!chat) {
res.status(404).json({ error: 'Чат не найден' });
return;
}
// Membership check
const isMember = chat.members.some(m => m.userId === userId);
if (!isMember) {
res.status(403).json({ error: 'Нет доступа к этому чату' });
return;
}
if (chat.type === 'personal') {
// For personal chats, just remove the member (soft leave) instead of destroying for both
await prisma.chatMember.delete({
where: { chatId_userId: { chatId, userId } },
});
// If both members have left, clean up the chat
const remaining = await prisma.chatMember.count({ where: { chatId } });
if (remaining === 0) {
await prisma.chat.delete({ where: { id: chatId } });
}
} else if (chat.members.length <= 1) {
// Last member — delete the group entirely
await prisma.chat.delete({ where: { id: chatId } });
} else {
// For groups, just remove the member
await prisma.chatMember.delete({
where: { chatId_userId: { chatId, userId } },
});
}
res.json({ success: true });
} catch (error) {
console.error('Delete chat error:', error);
res.status(500).json({ error: 'Ошибка удаления чата' });
}
});
// Закрепить / открепить чат
router.post('/:id/pin', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.id);
const userId = req.userId!;
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId } },
});
if (!member) {
res.status(404).json({ error: 'Чат не найден' });
return;
}
await prisma.chatMember.update({
where: { chatId_userId: { chatId, userId } },
data: { isPinned: !member.isPinned },
});
res.json({ isPinned: !member.isPinned });
} catch (error) {
console.error('Pin chat error:', error);
res.status(500).json({ error: 'Ошибка закрепления чата' });
}
});
export default router;

View File

@@ -0,0 +1,258 @@
import { Router } from 'express';
import { prisma } from '../db';
import { AuthRequest } from '../middleware/auth';
import { USER_SELECT } from '../shared';
const router = Router();
// ─── Get accepted friends list ───────────────────────────────────────
router.get('/', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const friendships = await prisma.friendship.findMany({
where: {
status: 'accepted',
OR: [{ userId }, { friendId: userId }],
},
include: {
user: { select: USER_SELECT },
friend: { select: USER_SELECT },
},
});
const friends = friendships.map(f => ({
...(f.userId === userId ? f.friend : f.user),
friendshipId: f.id,
}));
res.json(friends);
} catch (error) {
console.error('Get friends error:', error);
res.status(500).json({ error: 'Ошибка получения друзей' });
}
});
// ─── Get incoming friend requests ────────────────────────────────────
router.get('/requests', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const requests = await prisma.friendship.findMany({
where: { friendId: userId, status: 'pending' },
include: {
user: { select: USER_SELECT },
},
orderBy: { createdAt: 'desc' },
});
res.json(requests.map(r => ({ id: r.id, user: r.user, createdAt: r.createdAt })));
} catch (error) {
console.error('Get friend requests error:', error);
res.status(500).json({ error: 'Ошибка получения заявок' });
}
});
// ─── Get outgoing friend requests ────────────────────────────────────
router.get('/outgoing', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const requests = await prisma.friendship.findMany({
where: { userId, status: 'pending' },
include: {
friend: { select: USER_SELECT },
},
orderBy: { createdAt: 'desc' },
});
res.json(requests.map(r => ({ id: r.id, user: r.friend, createdAt: r.createdAt })));
} catch (error) {
console.error('Get outgoing requests error:', error);
res.status(500).json({ error: 'Ошибка получения заявок' });
}
});
// ─── Get friendship status with a user ───────────────────────────────
router.get('/status/:userId', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const targetId = String(req.params.userId);
if (userId === targetId) {
res.json({ status: 'self' });
return;
}
const friendship = await prisma.friendship.findFirst({
where: {
OR: [
{ userId, friendId: targetId },
{ userId: targetId, friendId: userId },
],
},
});
if (!friendship) {
res.json({ status: 'none', friendshipId: null });
return;
}
// Determine who sent the request to show correct action
const direction = friendship.userId === userId ? 'outgoing' : 'incoming';
res.json({ status: friendship.status, friendshipId: friendship.id, direction });
} catch (error) {
console.error('Get friend status error:', error);
res.status(500).json({ error: 'Ошибка получения статуса' });
}
});
// ─── Send friend request ─────────────────────────────────────────────
router.post('/request', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const { friendId } = req.body;
if (!friendId || typeof friendId !== 'string') {
res.status(400).json({ error: 'ID пользователя обязателен' });
return;
}
if (userId === friendId) {
res.status(400).json({ error: 'Нельзя добавить себя в друзья' });
return;
}
// Check if target user exists
const targetUser = await prisma.user.findUnique({ where: { id: friendId } });
if (!targetUser) {
res.status(404).json({ error: 'Пользователь не найден' });
return;
}
// Check for existing friendship in either direction
const existing = await prisma.friendship.findFirst({
where: {
OR: [
{ userId, friendId },
{ userId: friendId, friendId: userId },
],
},
});
if (existing) {
if (existing.status === 'accepted') {
res.status(400).json({ error: 'Уже в друзьях' });
return;
}
if (existing.status === 'pending') {
// If they already sent us a request, auto-accept
if (existing.userId === friendId) {
const updated = await prisma.friendship.update({
where: { id: existing.id },
data: { status: 'accepted' },
include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } },
});
res.json({ status: 'accepted', friendship: updated });
return;
}
res.status(400).json({ error: 'Заявка уже отправлена' });
return;
}
if (existing.status === 'declined') {
// Allow re-sending if previously declined — keep existing direction to avoid @@unique conflict
const updated = await prisma.friendship.update({
where: { id: existing.id },
data: { status: 'pending' },
});
res.json({ status: 'pending', friendship: updated });
return;
}
}
const friendship = await prisma.friendship.create({
data: { userId, friendId },
include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } },
});
res.json({ status: 'pending', friendship });
} catch (error) {
console.error('Send friend request error:', error);
res.status(500).json({ error: 'Ошибка отправки заявки' });
}
});
// ─── Accept friend request ───────────────────────────────────────────
router.post('/:id/accept', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const friendshipId = String(req.params.id);
const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } });
if (!friendship || friendship.friendId !== userId || friendship.status !== 'pending') {
res.status(404).json({ error: 'Заявка не найдена' });
return;
}
const updated = await prisma.friendship.update({
where: { id: friendshipId },
data: { status: 'accepted' },
include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } },
});
res.json(updated);
} catch (error) {
console.error('Accept friend request error:', error);
res.status(500).json({ error: 'Ошибка принятия заявки' });
}
});
// ─── Decline friend request ──────────────────────────────────────────
router.post('/:id/decline', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const friendshipId = String(req.params.id);
const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } });
if (!friendship || friendship.friendId !== userId || friendship.status !== 'pending') {
res.status(404).json({ error: 'Заявка не найдена' });
return;
}
await prisma.friendship.update({
where: { id: friendshipId },
data: { status: 'declined' },
});
res.json({ success: true });
} catch (error) {
console.error('Decline friend request error:', error);
res.status(500).json({ error: 'Ошибка отклонения заявки' });
}
});
// ─── Remove friend ───────────────────────────────────────────────────
router.delete('/:id', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const friendshipId = String(req.params.id);
const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } });
if (!friendship || (friendship.userId !== userId && friendship.friendId !== userId)) {
res.status(404).json({ error: 'Дружба не найдена' });
return;
}
await prisma.friendship.delete({ where: { id: friendshipId } });
res.json({ success: true });
} catch (error) {
console.error('Remove friend error:', error);
res.status(500).json({ error: 'Ошибка удаления друга' });
}
});
export default router;

View File

@@ -0,0 +1,221 @@
import { Router } from 'express';
import { prisma } from '../db';
import { Prisma } from '@prisma/client';
import { AuthRequest } from '../middleware/auth';
import { SENDER_SELECT, MESSAGE_INCLUDE, uploadFile, deleteUploadedFile, encryptUploadedFile } from '../shared';
const router = Router();
// Получить сообщения чата
router.get('/chat/:chatId', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.chatId);
const { cursor, limit = '50' } = req.query;
const take = Math.min(Math.max(1, parseInt(limit as string) || 50), 200);
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId: req.userId! } },
});
if (!member) {
res.status(403).json({ error: 'Нет доступа к этому чату' });
return;
}
const createdAtFilter: Record<string, Date> = {};
if (cursor) createdAtFilter.lt = new Date(cursor as string);
if (member.clearedAt) createdAtFilter.gt = member.clearedAt;
const messages = await prisma.message.findMany({
where: {
chatId,
isDeleted: false,
hiddenBy: { none: { userId: req.userId! } },
// Scheduled messages: only visible to the sender until delivered
OR: [
{ scheduledAt: null },
{ senderId: req.userId! },
],
...(Object.keys(createdAtFilter).length > 0 ? { createdAt: createdAtFilter } : {}),
},
include: MESSAGE_INCLUDE,
orderBy: { createdAt: 'desc' },
take,
});
res.json(messages.reverse());
} catch (error) {
console.error('Get messages error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Загрузка файла
router.post('/upload', uploadFile.single('file'), encryptUploadedFile, async (req: AuthRequest, res) => {
try {
if (!req.file) {
res.status(400).json({ error: 'Файл не загружен' });
return;
}
const fileUrl = `/uploads/${req.file.filename}`;
// multer decodes multipart filenames as latin1 — re-decode as UTF-8
const originalName = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
res.json({
url: fileUrl,
filename: originalName,
size: req.file.size,
mimetype: req.file.mimetype,
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Ошибка загрузки' });
}
});
// Редактировать сообщение
router.put('/:id', async (req: AuthRequest, res) => {
try {
const { content } = req.body;
const id = String(req.params.id);
if (!content || typeof content !== 'string' || content.length > 10000) {
res.status(400).json({ error: 'Содержимое обязательно и не должно превышать 10000 символов' });
return;
}
const message = await prisma.message.findUnique({ where: { id } });
if (!message || message.senderId !== req.userId) {
res.status(403).json({ error: 'Нет прав для редактирования' });
return;
}
const updated = await prisma.message.update({
where: { id },
data: { content, isEdited: true },
include: MESSAGE_INCLUDE,
});
res.json(updated);
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Удалить сообщение
router.delete('/:id', async (req: AuthRequest, res) => {
try {
const id = String(req.params.id);
const message = await prisma.message.findUnique({
where: { id },
include: { media: true },
});
if (!message || message.senderId !== req.userId) {
res.status(403).json({ error: 'Нет прав для удаления' });
return;
}
// Delete media files from disk
if (message.media && message.media.length > 0) {
for (const m of message.media) {
if (m.url) deleteUploadedFile(m.url);
}
await prisma.media.deleteMany({ where: { messageId: id } });
}
await prisma.message.update({
where: { id },
data: { isDeleted: true, content: null },
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Получить общие медиа/файлы/ссылки чата
router.get('/chat/:chatId/shared', async (req: AuthRequest, res) => {
try {
const chatId = String(req.params.chatId);
const { type } = req.query; // 'media' | 'files' | 'links'
// Check membership
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId, userId: req.userId! } },
});
if (!member) {
res.status(403).json({ error: 'Нет доступа' });
return;
}
const baseWhere: Prisma.MessageWhereInput = {
chatId,
isDeleted: false,
hiddenBy: { none: { userId: req.userId! } },
...(member.clearedAt ? { createdAt: { gt: member.clearedAt } } : {}),
};
if (type === 'media') {
// Images and videos
const messages = await prisma.message.findMany({
where: {
...baseWhere,
media: { some: { type: { in: ['image', 'video'] } } },
},
include: {
media: { where: { type: { in: ['image', 'video'] } } },
sender: { select: SENDER_SELECT },
},
orderBy: { createdAt: 'desc' },
take: 100,
});
res.json(messages);
} else if (type === 'files') {
// Files (documents, archives, audio, etc.)
const messages = await prisma.message.findMany({
where: {
...baseWhere,
media: { some: { type: { notIn: ['image', 'video'] } } },
},
include: {
media: { where: { type: { notIn: ['image', 'video'] } } },
sender: { select: SENDER_SELECT },
},
orderBy: { createdAt: 'desc' },
take: 100,
});
res.json(messages);
} else if (type === 'links') {
// Messages containing URLs
const messages = await prisma.message.findMany({
where: {
...baseWhere,
content: { contains: 'http' },
},
include: {
sender: { select: SENDER_SELECT },
},
orderBy: { createdAt: 'desc' },
take: 100,
});
// Filter to only messages with actual URLs
const withLinks = messages
.filter((m) => m.content && /https?:\/\/[^\s]+/i.test(m.content))
.map((m) => {
const links = m.content!.match(/https?:\/\/[^\s]+/gi) || [];
return { ...m, links };
});
res.json(withLinks);
} else {
res.status(400).json({ error: 'Invalid type. Use: media, files, or links' });
}
} catch (error) {
console.error('Shared media error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
export default router;

View File

@@ -0,0 +1,244 @@
import { Router } from 'express';
import { prisma } from '../db';
import { AuthRequest } from '../middleware/auth';
import { deleteUploadedFile } from '../shared';
const router = Router();
// Get all active stories (grouped by user)
router.get('/', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const now = new Date();
// Get accepted friends
const friendships = await prisma.friendship.findMany({
where: {
status: 'accepted',
OR: [{ userId }, { friendId: userId }],
},
select: { userId: true, friendId: true },
});
const friendIds = friendships.map(f =>
f.userId === userId ? f.friendId : f.userId,
);
// Include own userId to see own stories
friendIds.push(userId);
const stories = await prisma.story.findMany({
where: {
userId: { in: friendIds },
expiresAt: { gt: now },
},
include: {
user: {
select: { id: true, username: true, displayName: true, avatar: true },
},
views: {
select: { userId: true },
},
},
orderBy: { createdAt: 'asc' },
});
// Group by user
interface StoryItem {
id: string;
type: string;
mediaUrl: string | null;
content: string | null;
bgColor: string | null;
createdAt: Date;
expiresAt: Date;
viewCount: number;
viewed: boolean;
}
interface StoryGroupResult {
user: typeof stories[number]['user'];
stories: StoryItem[];
hasUnviewed: boolean;
}
const grouped: Record<string, StoryGroupResult> = {};
for (const story of stories) {
if (!grouped[story.userId]) {
grouped[story.userId] = {
user: story.user,
stories: [],
hasUnviewed: false,
};
}
const viewed = story.views.some(v => v.userId === userId);
grouped[story.userId].stories.push({
id: story.id,
type: story.type,
mediaUrl: story.mediaUrl,
content: story.content,
bgColor: story.bgColor,
createdAt: story.createdAt,
expiresAt: story.expiresAt,
viewCount: story.views.length,
viewed,
});
if (!viewed && story.userId !== userId) {
grouped[story.userId].hasUnviewed = true;
}
}
// Own stories first, then unviewed, then viewed
const result = Object.values(grouped).sort((a, b) => {
if (a.user.id === userId) return -1;
if (b.user.id === userId) return 1;
if (a.hasUnviewed && !b.hasUnviewed) return -1;
if (!a.hasUnviewed && b.hasUnviewed) return 1;
return 0;
});
res.json(result);
} catch (error) {
console.error('Get stories error:', error);
res.status(500).json({ error: 'Ошибка получения историй' });
}
});
// Create a story
router.post('/', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const { type, mediaUrl, content, bgColor } = req.body;
// Validate mediaUrl to prevent path traversal
if (mediaUrl) {
if (typeof mediaUrl !== 'string' || !mediaUrl.startsWith('/uploads/') || mediaUrl.includes('..')) {
res.status(400).json({ error: 'Недопустимый URL медиафайла' });
return;
}
}
const story = await prisma.story.create({
data: {
userId,
type: type || 'text',
mediaUrl,
content,
bgColor: bgColor || '#6366f1',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
},
include: {
user: {
select: { id: true, username: true, displayName: true, avatar: true },
},
views: true,
},
});
res.json(story);
} catch (error) {
console.error('Create story error:', error);
res.status(500).json({ error: 'Ошибка создания истории' });
}
});
// View a story
router.post('/:storyId/view', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const storyId = req.params.storyId as string;
// Verify story exists and viewer is the owner or a friend
const story = await prisma.story.findUnique({ where: { id: storyId }, select: { userId: true } });
if (!story) {
res.status(404).json({ error: 'История не найдена' });
return;
}
if (story.userId !== userId) {
const friendship = await prisma.friendship.findFirst({
where: {
status: 'accepted',
OR: [
{ userId, friendId: story.userId },
{ userId: story.userId, friendId: userId },
],
},
});
if (!friendship) {
res.status(403).json({ error: 'Нет доступа' });
return;
}
}
await prisma.storyView.upsert({
where: { storyId_userId: { storyId, userId } },
create: { storyId, userId },
update: {},
});
res.json({ ok: true });
} catch (error) {
console.error('View story error:', error);
res.status(500).json({ error: 'Ошибка просмотра истории' });
}
});
// Get story viewers
router.get('/:storyId/viewers', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const storyId = req.params.storyId as string;
const story = await prisma.story.findUnique({ where: { id: storyId }, select: { userId: true } });
if (!story || story.userId !== userId) {
res.status(403).json({ error: 'Только автор может просматривать аудиторию' });
return;
}
const views = await prisma.storyView.findMany({
where: {
storyId,
user: { hideStoryViews: false },
},
include: {
user: {
select: { id: true, username: true, displayName: true, avatar: true },
},
},
orderBy: { viewedAt: 'desc' },
});
res.json(views.map(v => ({
userId: v.userId,
username: v.user.username,
displayName: v.user.displayName,
avatar: v.user.avatar,
viewedAt: v.viewedAt,
})));
} catch (error) {
console.error('Get story viewers error:', error);
res.status(500).json({ error: 'Ошибка получения просмотров' });
}
});
// Delete own story
router.delete('/:storyId', async (req: AuthRequest, res) => {
try {
const userId = req.userId!;
const storyId = req.params.storyId as string;
const story = await prisma.story.findUnique({ where: { id: storyId } });
if (!story || story.userId !== userId) {
res.status(403).json({ error: 'Нет прав' });
return;
}
// Delete media file if present
if (story.mediaUrl) deleteUploadedFile(story.mediaUrl);
await prisma.story.delete({ where: { id: storyId } });
res.json({ ok: true });
} catch (error) {
console.error('Delete story error:', error);
res.status(500).json({ error: 'Ошибка удаления истории' });
}
});
export default router;

View File

@@ -0,0 +1,233 @@
import { Router } from 'express';
import { prisma } from '../db';
import { AuthRequest } from '../middleware/auth';
import { USER_SELECT, SENDER_SELECT, uploadUserAvatar, deleteUploadedFile, encryptUploadedFile } from '../shared';
const router = Router();
// Поиск пользователей
router.get('/search', async (req: AuthRequest, res) => {
try {
const { q } = req.query;
if (!q || typeof q !== 'string' || q.trim().length < 3) {
res.json([]);
return;
}
const users = await prisma.user.findMany({
where: {
OR: [
{ username: { contains: q } },
{ displayName: { contains: q } },
],
NOT: { id: req.userId },
},
select: USER_SELECT,
take: 20,
});
res.json(users);
} catch (error) {
console.error('Search users error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Профиль пользователя
router.get('/:id', async (req: AuthRequest, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: String(req.params.id) },
select: USER_SELECT,
});
if (!user) {
res.status(404).json({ error: 'Пользователь не найден' });
return;
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Загрузить аватар
router.post('/avatar', uploadUserAvatar.single('avatar'), encryptUploadedFile, async (req: AuthRequest, res) => {
try {
if (!req.file) {
res.status(400).json({ error: 'Файл не загружен' });
return;
}
// Delete old avatar file if exists
const currentUser = await prisma.user.findUnique({ where: { id: req.userId }, select: { avatar: true } });
if (currentUser?.avatar) deleteUploadedFile(currentUser.avatar);
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const user = await prisma.user.update({
where: { id: req.userId },
data: { avatar: avatarUrl },
select: USER_SELECT,
});
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Ошибка загрузки аватара' });
}
});
// Удалить аватар
router.delete('/avatar', async (req: AuthRequest, res) => {
try {
// Delete file from disk
const currentUser = await prisma.user.findUnique({ where: { id: req.userId }, select: { avatar: true } });
if (currentUser?.avatar) deleteUploadedFile(currentUser.avatar);
const user = await prisma.user.update({
where: { id: req.userId },
data: { avatar: null },
select: USER_SELECT,
});
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Ошибка удаления аватара' });
}
});
// Обновить профиль (username НЕ меняется!)
router.put('/profile', async (req: AuthRequest, res) => {
try {
const { displayName, bio, birthday } = req.body;
// Validate field lengths
if (displayName !== undefined && (typeof displayName !== 'string' || displayName.length === 0 || displayName.length > 50)) {
res.status(400).json({ error: 'Имя должно быть от 1 до 50 символов' });
return;
}
if (bio !== undefined && bio !== null && (typeof bio !== 'string' || bio.length > 500)) {
res.status(400).json({ error: 'Био должно быть не длиннее 500 символов' });
return;
}
if (birthday !== undefined && birthday !== null) {
if (typeof birthday !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(birthday) || isNaN(Date.parse(birthday))) {
res.status(400).json({ error: 'Некорректный формат даты рождения (YYYY-MM-DD)' });
return;
}
}
const updateData: Record<string, string | null> = {};
if (displayName !== undefined) updateData.displayName = displayName;
if (bio !== undefined) updateData.bio = bio;
if (birthday !== undefined) updateData.birthday = birthday;
const user = await prisma.user.update({
where: { id: req.userId },
data: updateData,
select: USER_SELECT,
});
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Поиск сообщений
router.get('/messages/search', async (req: AuthRequest, res) => {
try {
const { q, chatId } = req.query;
if (!q || typeof q !== 'string') {
res.json([]);
return;
}
const where: Record<string, unknown> = {
content: { contains: q },
isDeleted: false,
};
if (chatId) {
where.chatId = chatId;
const member = await prisma.chatMember.findUnique({
where: { chatId_userId: { chatId: chatId as string, userId: req.userId! } },
});
if (member?.clearedAt) {
where.createdAt = { gt: member.clearedAt };
}
} else {
where.chat = {
members: { some: { userId: req.userId } },
};
}
const messages = await prisma.message.findMany({
where,
include: {
sender: { select: SENDER_SELECT },
chat: {
select: {
id: true,
name: true,
type: true,
members: {
include: {
user: { select: { id: true, username: true, displayName: true } },
},
},
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// For global search (no chatId filter), filter out messages before clearedAt per chat
let filtered = messages;
if (!chatId) {
const memberships = await prisma.chatMember.findMany({
where: { userId: req.userId! },
select: { chatId: true, clearedAt: true },
});
const clearedMap = new Map<string, Date>();
for (const m of memberships) {
if (m.clearedAt) clearedMap.set(m.chatId, m.clearedAt);
}
if (clearedMap.size > 0) {
filtered = messages.filter((msg) => {
const cleared = clearedMap.get(msg.chatId);
if (!cleared) return true;
return new Date(msg.createdAt) > new Date(cleared);
});
}
}
res.json(filtered);
} catch (error) {
console.error('Search messages error:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Обновить настройки приватности
router.put('/settings', async (req: AuthRequest, res) => {
try {
const { hideStoryViews } = req.body;
const updateData: Record<string, boolean> = {};
if (typeof hideStoryViews === 'boolean') updateData.hideStoryViews = hideStoryViews;
const user = await prisma.user.update({
where: { id: req.userId },
data: updateData,
select: USER_SELECT,
});
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Ошибка сохранения настроек' });
}
});
export default router;

181
apps/server/src/shared.ts Normal file
View File

@@ -0,0 +1,181 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { Request, Response, NextFunction } from 'express';
import { encryptFileInPlace, isEncryptionEnabled } from './encrypt';
// ─── Prisma select objects ────────────────────────────────────────────
/** Standard user fields to include in API responses (excludes password) */
export const USER_SELECT = {
id: true,
username: true,
displayName: true,
avatar: true,
bio: true,
birthday: true,
isOnline: true,
lastSeen: true,
createdAt: true,
hideStoryViews: true,
} as const;
/** Compact user fields for message sender / forwarded-from */
export const SENDER_SELECT = {
id: true,
username: true,
displayName: true,
avatar: true,
} as const;
/** Full message include for API responses */
export const MESSAGE_INCLUDE = {
sender: { select: SENDER_SELECT },
forwardedFrom: { select: SENDER_SELECT },
replyTo: {
include: { sender: { select: { id: true, username: true, displayName: true } } },
},
media: true,
reactions: {
include: { user: { select: { id: true, username: true, displayName: true } } },
},
readBy: { select: { userId: true } },
} as const;
// ─── File system helpers ──────────────────────────────────────────────
const uploadsRoot = path.join(__dirname, '../uploads');
/** Ensure a directory exists (recursive). */
export function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
/** Safely delete a file from the uploads directory given its URL path (e.g. '/uploads/avatars/abc.jpg'). */
export function deleteUploadedFile(urlPath: string): void {
if (!urlPath) return;
try {
const filename = urlPath.replace(/^\/uploads\//, '');
const filePath = path.resolve(uploadsRoot, filename);
// Path containment check — prevent directory traversal
if (!filePath.startsWith(uploadsRoot)) {
console.error('Path traversal attempt blocked:', urlPath);
return;
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (e) {
console.error('Failed to delete file:', urlPath, e);
}
}
// ─── Multer configurations ───────────────────────────────────────────
const avatarsDir = path.join(uploadsRoot, 'avatars');
ensureDir(avatarsDir);
ensureDir(uploadsRoot);
/** Allowed image extensions for avatars. */
const ALLOWED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']);
function createAvatarStorage(prefix = '') {
return multer.diskStorage({
destination: (_req, _file, cb) => cb(null, avatarsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${prefix}${uuidv4()}${ext}`);
},
});
}
/** Multer middleware for user avatar uploads (max 5MB, images only). */
export const uploadUserAvatar = multer({
storage: createAvatarStorage(''),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true);
else cb(new Error('Только изображения (jpg, png, gif, webp, avif)'));
},
});
/** Multer middleware for group avatar uploads (max 5MB, images only). */
export const uploadGroupAvatar = multer({
storage: createAvatarStorage('group-'),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true);
else cb(new Error('Только изображения (jpg, png, gif, webp, avif)'));
},
});
/** Blocked file extensions that could be served as executable content. */
const BLOCKED_EXTENSIONS = new Set([
'.html', '.htm', '.svg', '.xml', '.xhtml',
'.php', '.jsp', '.asp', '.aspx', '.cgi',
'.exe', '.bat', '.cmd', '.com', '.msi', '.scr', '.pif',
'.sh', '.bash', '.ps1', '.psm1', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh',
'.dll', '.sys', '.drv',
'.hta', '.cpl', '.inf', '.reg',
]);
/** Multer middleware for general file uploads (max 50MB). */
export const uploadFile = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsRoot),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uuidv4()}${ext}`);
},
}),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (BLOCKED_EXTENSIONS.has(ext)) {
cb(new Error('Этот тип файла не разрешён'));
} else {
cb(null, true);
}
},
});
// ─── Post-upload file encryption middleware ───────────────────────────
/**
* Express middleware that encrypts an uploaded file in-place after multer
* has written it to disk. Use after any multer middleware.
*/
export function encryptUploadedFile(req: Request, _res: Response, next: NextFunction): void {
if (!isEncryptionEnabled()) return next();
try {
// Single file upload (req.file)
if (req.file) {
encryptFileInPlace(req.file.path);
}
// Multiple files (req.files) — handle both array and field-keyed forms
if (req.files) {
const files = Array.isArray(req.files)
? req.files
: Object.values(req.files).flat();
for (const file of files) {
encryptFileInPlace(file.path);
}
}
} catch (e) {
console.error('File encryption error:', e);
// Don't block the request — file is already saved, just unencrypted
}
next();
}
/** Absolute path to the uploads root directory. */
export const UPLOADS_ROOT = uploadsRoot;

File diff suppressed because it is too large Load Diff