const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const mysql = require('mysql2/promise'); const bcrypt = require('bcryptjs'); const cors = require('cors'); const path = require('path'); const multer = require('multer'); const fs = require('fs-extra'); require('dotenv').config(); const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: "*", methods: ["GET", "POST"] }, maxHttpBufferSize: 1e8 // 100MB }); app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // Multer Storage Configuration const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadDir = path.join(__dirname, 'uploads'); fs.ensureDirSync(uploadDir); cb(null, uploadDir); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, uniqueSuffix + path.extname(file.originalname)); } }); const upload = multer({ storage: storage }); // DB Pool Connection const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0 }); // Create Table if not exists const initDB = async () => { try { // Create table await pool.execute(` CREATE TABLE IF NOT EXISTS chat_messages ( id INT AUTO_INCREMENT PRIMARY KEY, user_code VARCHAR(50), consultant_code VARCHAR(50), room_id VARCHAR(100), content TEXT, msg_type VARCHAR(10) DEFAULT 'text', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); // Ensure msg_type column exists (for existing tables) const [columns] = await pool.execute("SHOW COLUMNS FROM chat_messages LIKE 'msg_type'"); if (columns.length === 0) { await pool.execute("ALTER TABLE chat_messages ADD COLUMN msg_type VARCHAR(10) DEFAULT 'text' AFTER content"); console.log('DB schema updated: added msg_type column'); } console.log('Database initialized'); } catch (err) { console.error('DB Init Error:', err); } }; initDB(); // Image Upload API app.post('/api/upload', upload.single('image'), (req, res) => { try { if (!req.file) { console.log('Upload failed: No file uploaded'); return res.status(400).json({ success: false, message: 'No file uploaded' }); } const imageUrl = `/uploads/${req.file.filename}`; console.log('File uploaded successfully:', imageUrl); res.json({ success: true, imageUrl }); } catch (err) { console.error('Upload API Error:', err); res.status(500).json({ success: false, message: 'Upload internal error' }); } }); // Basic Routes app.get('/chat', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/admin/chat', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/register', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'register.html')); }); // 루트로 접속 시 기본적으로 /chat으로 리다이렉트 app.get('/', (req, res) => { res.redirect('/chat'); }); // Login API (Supports both 'user' and 'clientinfo' tables) app.post('/api/login', async (req, res) => { const { username, password } = req.body; try { // 1. 상담원(user) 테이블 먼저 조회 let [rows] = await pool.execute('SELECT id, passwd, role FROM user WHERE id = ?', [username]); let isConsultant = true; if (rows.length === 0) { // 2. 상담원이 없으면 일반 사용자(clientinfo) 테이블 조회 [rows] = await pool.execute('SELECT id, passwd, role FROM clientinfo WHERE id = ?', [username]); isConsultant = false; } if (rows.length === 0) { return res.status(401).json({ success: false, message: '사용자 계정을 찾을 수 없습니다.' }); } const user = rows[0]; const isValid = await bcrypt.compare(password, user.passwd); if (!isValid) { return res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' }); } res.json({ success: true, user: { id: user.id, username: user.id, role: user.role || (isConsultant ? 'master' : 'user') } }); } catch (error) { console.error('Login API error:', error); res.status(500).json({ success: false, message: '서버 내부 오류' }); } }); // User Registration API (clientinfo table) app.post('/api/register', async (req, res) => { const { username, password } = req.body; try { // 중복 체크 const [existing] = await pool.execute('SELECT id FROM clientinfo WHERE id = ?', [username]); if (existing.length > 0) { return res.status(400).json({ success: false, message: '이미 존재하는 아이디입니다.' }); } const hashedPassword = await bcrypt.hash(password, 10); await pool.execute( 'INSERT INTO clientinfo (id, passwd, role, created_at) VALUES (?, ?, ?, NOW())', [username, hashedPassword, 'user'] ); res.json({ success: true, message: '회원가입이 완료되었습니다.' }); } catch (error) { console.error('Registration API error:', error); res.status(500).json({ success: false, message: '회원가입 중 오류가 발생했습니다.' }); } }); const isConsultantRole = (role) => { if (!role) return false; // 상담원 관련 키워드 목록 (manager/manage 오타 및 콤마 구분 대응) const consultantKeywords = ['manage', 'manager', 'cloudflare', 'security', 'director', 'master', 'admin', 'consultant']; // role이 "manager,cloudflare..." 처럼 여러 개일 경우를 대비해 체크 const roles = String(role).toLowerCase(); return consultantKeywords.some(keyword => roles.includes(keyword)); }; // 룸 목록 관리를 위한 객체 (room_id -> { user_id, last_message, ... }) const activeRooms = new Map(); const emitRoomList = () => { const roomList = Array.from(activeRooms.entries()).map(([room, data]) => ({ room, user_id: data.user_id, username: data.user_id.startsWith('anon_') ? `익명(${data.user_id.substr(-5)})` : data.user_id })); io.to('consultants_group').emit('update_room_list', roomList); }; // Socket.io logic io.on('connection', (socket) => { console.log('A user connected:', socket.id); socket.on('join_room', (data) => { const { room, role, user_id } = data; console.log('[DEBUG] Join Room Request:', data); // 상담원의 경우 룸 전환 시 정리가 필요함 if (isConsultantRole(role)) { // 다른 모든 룸에서 퇴장 순회 (본인 소켓 ID 방 제외) socket.rooms.forEach(r => { if (r !== socket.id) socket.leave(r); }); if (room === 'consultants_group') { socket.join('consultants_group'); console.log(`[DEBUG] Consultant ${user_id} joined consultants_group`); // 입장 즉시 최신 룸 목록 전송 emitRoomList(); } else { // 특정 유저 룸 조인 socket.join(room); console.log(`[DEBUG] Consultant ${user_id} moved to 1:1 room: ${room}`); } } else { // 일반 유저나 익명은 본인 방 가입 socket.join(room); console.log(`[DEBUG] User ${user_id} joined room: ${room}`); // 일반 유저의 룸을 활성 목록에 추가 if (room.startsWith('room_')) { activeRooms.set(room, { user_id: user_id, socketId: socket.id }); emitRoomList(); } } }); socket.on('send_message', async (data) => { const { room, sender, message, user_code, consultant_code, msg_type, role } = data; console.log(`[DEBUG] Message from ${sender} (role: ${role}) targeting room: ${room}`); try { await pool.execute( 'INSERT INTO chat_messages (user_code, consultant_code, room_id, content, msg_type, created_at) VALUES (?, ?, ?, ?, ?, NOW())', [user_code || 'anonymous', consultant_code || null, room, message, msg_type || 'text'] ); } catch (err) { console.error('Error saving message:', err); } // 해당 룸 사용자에게 메시지 전송 io.to(room).emit('receive_message', data); // 상담원 그룹(전체 모니터링)에도 메시지 복사 전송 // 유저의 메시지뿐만 아니라 상담원의 모든 답장도 다른 상담원 및 발신자 본인이 볼 수 있게 함 if (room !== 'consultants_group') { io.to('consultants_group').emit('receive_message', data); } }); socket.on('disconnect', () => { console.log('User disconnected:', socket.id); // 해당 소켓이 소유했던 룸 정보 제거 for (let [room, data] of activeRooms.entries()) { if (data.socketId === socket.id) { activeRooms.delete(room); emitRoomList(); break; } } }); }); const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); });