Полный рефакторинг сервера и клиента, голосовые сообщения, аудио/видео, Docker
Сервер: - Вынесена конфигурация в server/config.js (порт, размер файла из env) - Вынесена аутентификация в server/auth.js (загрузка users.json, authenticate()) - Вынесено управление соединениями в server/clients.js (createJsonSender, broadcast, validateFile) - server/server.js стал точкой входа — минимальный код Клиент: - Вынесены хуки: hooks/useWebSocket.js (WebSocket + auth), hooks/useChatMessages.js (сообщения + уведомления) - Вынесены утилиты: utils/blob.js (base64 → Blob URL), utils/linkify.jsx (URL → ссылки), utils/notify.js (звук + favicon) Новые функции: - VoiceRecorder — запись голоса через MediaRecorder, отправка как файл - Аудио/видео плеер в Message (audio/*, video/* с controls) - URL linkification — http/https ссылки автоматически кликабельны - Звуковое уведомление (Web Audio API) при сообщении на неактивной вкладке - Красная точка на favicon при непрочитанных сообщениях Инфраструктура: - docker-entrypoint.sh: авто-перезапуск Node.js сервера при падении - Обновлён README.md: новая структура проекта, список функций, примеры - Кастомный тонкий скроллбар в Message.css
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const usersPath = path.join(__dirname, config.usersFile);
|
||||
let users;
|
||||
|
||||
try {
|
||||
users = JSON.parse(fs.readFileSync(usersPath, 'utf-8'));
|
||||
console.log(`Loaded ${users.length} user(s) from ${config.usersFile}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load ${config.usersFile}:`, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function authenticate(login, password) {
|
||||
const user = users.find(u => u.login === login && u.password === password);
|
||||
return user || null;
|
||||
}
|
||||
|
||||
module.exports = { authenticate };
|
||||
@@ -0,0 +1,78 @@
|
||||
const config = require('./config');
|
||||
|
||||
function createJsonSender(ws) {
|
||||
return (data) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createClientHandlers(ws, sendJson) {
|
||||
let authenticated = false;
|
||||
let login = null;
|
||||
|
||||
function getLogin() { return login; }
|
||||
|
||||
function handleAuth(userLogin) {
|
||||
authenticated = true;
|
||||
login = userLogin;
|
||||
console.log(`Authenticated: ${login}`);
|
||||
sendJson({ type: 'auth_result', success: true });
|
||||
return { type: 'system', text: `${login} joined the chat` };
|
||||
}
|
||||
|
||||
function handleAuthFail(reason) {
|
||||
sendJson({ type: 'auth_result', success: false, reason });
|
||||
}
|
||||
|
||||
function wrapHandler(handler) {
|
||||
return (raw) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
sendJson({ type: 'error', message: 'Invalid JSON' });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
if (parsed.type === 'auth') {
|
||||
return handler({ type: 'auth', login: parsed.login, password: parsed.password });
|
||||
}
|
||||
sendJson({ type: 'error', message: 'Authenticate first' });
|
||||
return null;
|
||||
}
|
||||
|
||||
return handler(parsed);
|
||||
};
|
||||
}
|
||||
|
||||
return { getLogin, handleAuth, handleAuthFail, wrapHandler };
|
||||
}
|
||||
|
||||
function formatSizeEstimate(base64Length) {
|
||||
return Math.ceil((base64Length * 3) / 4);
|
||||
}
|
||||
|
||||
function validateFile(parsed) {
|
||||
if (!parsed.filename || !parsed.mime || !parsed.data) {
|
||||
return 'Invalid file message';
|
||||
}
|
||||
const size = formatSizeEstimate(parsed.data.length);
|
||||
if (size > config.maxFileSize) {
|
||||
return `File too large (max ${config.maxFileSize / 1024 / 1024} MB)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function broadcast(server, message, excludeWs) {
|
||||
const json = JSON.stringify(message);
|
||||
server.clients.forEach(client => {
|
||||
if (client !== excludeWs && client.readyState === 1) {
|
||||
client.send(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createJsonSender, createClientHandlers, validateFile, broadcast };
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
port: parseInt(process.env.PORT, 10) || 8080,
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 10 * 1024 * 1024,
|
||||
usersFile: process.env.USERS_FILE || 'users.json',
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
+50
-111
@@ -1,129 +1,68 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const config = require('./config');
|
||||
const { authenticate } = require('./auth');
|
||||
const { createJsonSender, createClientHandlers, validateFile, broadcast } = require('./clients');
|
||||
|
||||
const PORT = parseInt(process.env.PORT, 10) || 8080;
|
||||
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE, 10) || 10 * 1024 * 1024;
|
||||
const server = new WebSocketServer({ port: config.port });
|
||||
console.log(`WebSocket server running on ws://0.0.0.0:${config.port}`);
|
||||
|
||||
const usersPath = path.join(__dirname, process.env.USERS_FILE || 'users.json');
|
||||
let validUsers;
|
||||
try {
|
||||
validUsers = JSON.parse(fs.readFileSync(usersPath, 'utf-8'));
|
||||
console.log(`Loaded ${validUsers.length} users from users.json`);
|
||||
} catch (err) {
|
||||
console.error('Failed to load users.json:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function authenticate(login, password) {
|
||||
return validUsers.find(u => u.login === login && u.password === password) || null;
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ port: PORT });
|
||||
console.log(`WebSocket server running on ws://localhost:${PORT}`);
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
server.on('connection', (ws, req) => {
|
||||
const clientIp = req.socket.remoteAddress;
|
||||
console.log(`New connection from ${clientIp}`);
|
||||
console.log(`Connection from ${clientIp}`);
|
||||
|
||||
let authenticated = false;
|
||||
let userLogin = null;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(data.toString());
|
||||
} catch (e) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
if (parsed.type === 'auth') {
|
||||
const send = createJsonSender(ws);
|
||||
const client = createClientHandlers(ws, send);
|
||||
const handle = client.wrapHandler((parsed) => {
|
||||
switch (parsed.type) {
|
||||
case 'auth': {
|
||||
const user = authenticate(parsed.login, parsed.password);
|
||||
if (user) {
|
||||
authenticated = true;
|
||||
userLogin = parsed.login;
|
||||
console.log(`User authenticated: ${userLogin}`);
|
||||
ws.send(JSON.stringify({ type: 'auth_result', success: true }));
|
||||
broadcast({
|
||||
type: 'system',
|
||||
text: `${userLogin} joined the chat`
|
||||
}, null);
|
||||
} else {
|
||||
console.log(`Auth failed for login: ${parsed.login}`);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth_result',
|
||||
success: false,
|
||||
reason: 'Invalid login or password'
|
||||
}));
|
||||
return client.handleAuth(user.login);
|
||||
}
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Authenticate first' }));
|
||||
client.handleAuthFail('Invalid login or password');
|
||||
return null;
|
||||
}
|
||||
return;
|
||||
case 'text': {
|
||||
if (!parsed.text || typeof parsed.text !== 'string') {
|
||||
send({ type: 'error', message: 'Invalid text message' });
|
||||
return null;
|
||||
}
|
||||
console.log(`Text from ${client.getLogin()}: ${parsed.text.substring(0, 50)}`);
|
||||
return { type: 'text', from: client.getLogin(), timestamp: Date.now(), text: parsed.text };
|
||||
}
|
||||
case 'file': {
|
||||
const error = validateFile(parsed);
|
||||
if (error) {
|
||||
send({ type: 'error', message: error });
|
||||
return null;
|
||||
}
|
||||
console.log(`File from ${client.getLogin()}: ${parsed.filename} (${parsed.mime})`);
|
||||
return {
|
||||
type: 'file', from: client.getLogin(), timestamp: Date.now(),
|
||||
filename: parsed.filename, mime: parsed.mime, data: parsed.data,
|
||||
};
|
||||
}
|
||||
default:
|
||||
send({ type: 'error', message: 'Unknown message type' });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.type === 'text') {
|
||||
if (!parsed.text || typeof parsed.text !== 'string') {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid text message' }));
|
||||
return;
|
||||
}
|
||||
console.log(`Text from ${userLogin}: ${parsed.text.substring(0, 50)}`);
|
||||
broadcast({
|
||||
type: 'text',
|
||||
from: userLogin,
|
||||
timestamp: Date.now(),
|
||||
text: parsed.text
|
||||
}, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'file') {
|
||||
if (!parsed.filename || !parsed.mime || !parsed.data) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid file message' }));
|
||||
return;
|
||||
}
|
||||
const size = Math.ceil((parsed.data.length * 3) / 4);
|
||||
if (size > MAX_FILE_SIZE) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'File too large (max 10 MB)' }));
|
||||
return;
|
||||
}
|
||||
console.log(`File from ${userLogin}: ${parsed.filename} (${parsed.mime}, ~${size} bytes)`);
|
||||
broadcast({
|
||||
type: 'file',
|
||||
from: userLogin,
|
||||
timestamp: Date.now(),
|
||||
filename: parsed.filename,
|
||||
mime: parsed.mime,
|
||||
data: parsed.data
|
||||
}, null);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' }));
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`Connection closed${userLogin ? ` for ${userLogin}` : ''} (code: ${code})`);
|
||||
if (userLogin) {
|
||||
broadcast({
|
||||
type: 'system',
|
||||
text: `${userLogin} left the chat`
|
||||
}, null);
|
||||
ws.on('message', (raw) => {
|
||||
const msg = handle(raw);
|
||||
if (msg) {
|
||||
broadcast(server, msg, null);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code) => {
|
||||
console.log(`Disconnected: ${client.getLogin() || 'unauthenticated'} (code: ${code})`);
|
||||
if (client.getLogin()) {
|
||||
broadcast(server, { type: 'system', text: `${client.getLogin()} left the chat` }, null);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`WebSocket error${userLogin ? ` for ${userLogin}` : ''}:`, err.message);
|
||||
console.error(`Socket error (${client.getLogin() || 'unknown'}):`, err.message);
|
||||
});
|
||||
});
|
||||
|
||||
function broadcast(message, excludeWs) {
|
||||
const json = JSON.stringify(message);
|
||||
wss.clients.forEach(client => {
|
||||
if (client !== excludeWs && client.readyState === 1) {
|
||||
client.send(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user