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

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