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);