189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
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);
|