Полный рефакторинг сервера и клиента, голосовые сообщения, аудио/видео, 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:
Комаров Данил Анатольевич 6
2026-05-29 05:23:22 +03:00
parent 83ef0d5ab3
commit 63bf73d2c4
18 changed files with 760 additions and 485 deletions
+21
View File
@@ -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 };
+78
View File
@@ -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 };
+7
View File
@@ -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
View File
@@ -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);
}
});
}