Original
This commit is contained in:
40
apps/server/src/config.ts
Normal file
40
apps/server/src/config.ts
Normal 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
153
apps/server/src/db.ts
Normal 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
154
apps/server/src/encrypt.ts
Normal 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
188
apps/server/src/index.ts
Normal 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);
|
||||
26
apps/server/src/middleware/auth.ts
Normal file
26
apps/server/src/middleware/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
169
apps/server/src/routes/auth.ts
Normal file
169
apps/server/src/routes/auth.ts
Normal 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;
|
||||
608
apps/server/src/routes/chats.ts
Normal file
608
apps/server/src/routes/chats.ts
Normal 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;
|
||||
258
apps/server/src/routes/friends.ts
Normal file
258
apps/server/src/routes/friends.ts
Normal 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;
|
||||
221
apps/server/src/routes/messages.ts
Normal file
221
apps/server/src/routes/messages.ts
Normal 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;
|
||||
244
apps/server/src/routes/stories.ts
Normal file
244
apps/server/src/routes/stories.ts
Normal 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;
|
||||
233
apps/server/src/routes/users.ts
Normal file
233
apps/server/src/routes/users.ts
Normal 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
181
apps/server/src/shared.ts
Normal 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;
|
||||
1119
apps/server/src/socket/index.ts
Normal file
1119
apps/server/src/socket/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user