/** * 관리자 라우터 * 통계 대시보드, 사용자 관리, SES 설정 등 */ const express = require("express"); const router = express.Router(); const jwt = require("jsonwebtoken"); const bcrypt = require("bcryptjs"); const { Op, fn, col } = require("sequelize"); const User = require("../models/User"); const Inbox = require("../models/Inbox"); const Sent = require("../models/Sent"); const SystemConfig = require("../models/SystemConfig"); const SmtpLog = require("../models/SmtpLog"); const SentLog = require("../models/SentLog"); const Spam = require("../models/Spam"); const { sendEmail } = require("../services/emailService"); const rspamd = require("../services/rspamdService"); // 공통 헬퍼 함수 import const { calculateStorageSize, getPeriodStartDate, } = require("../utils/helpers"); const JWT_SECRET = process.env.JWT_SECRET || "super_secret_jwt_key_changed_in_production"; // ============================================================================ // 미들웨어 // ============================================================================ /** * 관리자 권한 검증 미들웨어 * JWT 토큰 검증 후 DB에서 실시간 권한 확인 * (권한이 박탈된 경우 즉시 차단) */ const verifyAdmin = async (req, res, next) => { const token = req.headers.authorization?.split(" ")[1]; if (!token) return res.status(401).json({ error: "토큰이 없습니다" }); try { const decoded = jwt.verify(token, JWT_SECRET); // DB에서 최신 사용자 정보 조회 const user = await User.findByPk(decoded.id); if (!user) { return res.status(403).json({ error: "사용자를 찾을 수 없습니다" }); } // 실시간 관리자 권한 확인 if (!user.isAdmin) { return res.status(403).json({ error: "관리자 권한이 필요합니다" }); } req.user = { id: user.id, email: user.email, isAdmin: user.isAdmin, }; next(); } catch (err) { return res.status(403).json({ error: "유효하지 않은 토큰" }); } }; // 최고 관리자 계정 (삭제 불가, 비밀번호만 수정 가능) const SUPER_ADMIN_EMAIL = "admin@caadiq.co.kr"; // 모든 라우트에 관리자 검증 적용 router.use(verifyAdmin); // ============================================================================ // API 라우트 - 통계 대시보드 // ============================================================================ /** * 대시보드 통계 조회 * GET /api/admin/stats */ router.get("/stats", async (req, res) => { try { const userCount = await User.count(); const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); // 오늘 및 총 발송/수신 메일 수 const [ sentToday, receivedToday, spamToday, totalSpam, totalSent, totalReceived, ] = await Promise.all([ SentLog.count({ where: { sentAt: { [Op.gte]: startOfDay }, success: true }, }), SmtpLog.count({ where: { connectedAt: { [Op.gte]: startOfDay }, success: true }, }), SmtpLog.count({ where: { connectedAt: { [Op.gte]: startOfDay }, isSpam: true }, }), Spam.count(), Sent.count(), // 보낸편지함 실제 메일 수 Inbox.count(), // 받은편지함 실제 메일 수 ]); // 스토리지 사용량 계산 const [inboxItems, sentItems] = await Promise.all([ Inbox.findAll({ attributes: ["attachments", "subject", "date", "text", "html"], }), Sent.findAll({ attributes: ["attachments", "subject", "date", "text", "html"], }), ]); const totalSize = calculateStorageSize(inboxItems) + calculateStorageSize(sentItems); const storageUsed = (totalSize / (1024 * 1024)).toFixed(2); // 스토리지 쿼터 설정 (기본값 50GB) const quotaConfig = await SystemConfig.findOne({ where: { key: "user_storage_quota" }, }); const storageLimit = quotaConfig ? parseInt(quotaConfig.value) : 51200; // 최근 활동 로그 생성 const logs = []; // 최근 수신 메일 (5개) const recentInbox = [...inboxItems] .sort((a, b) => new Date(b.date) - new Date(a.date)) .slice(0, 5); recentInbox.forEach((item) => { logs.push({ type: "INBOX", date: new Date(item.date), message: `메일 수신: ${item.subject}`, detail: item.subject, }); }); // 최근 발송 메일 (5개) const recentSent = [...sentItems] .sort((a, b) => new Date(b.date) - new Date(a.date)) .slice(0, 5); recentSent.forEach((item) => { logs.push({ type: "SENT", date: new Date(item.date), message: `메일 발송: ${item.subject}`, detail: item.subject, }); }); // 최근 가입 사용자 (5개) const recentUsers = await User.findAll({ limit: 5, order: [["createdAt", "DESC"]], attributes: ["email", "createdAt"], }); recentUsers.forEach((user) => { if (user.createdAt) { logs.push({ type: "USER", date: new Date(user.createdAt), message: `사용자 가입: ${user.email}`, detail: user.email, }); } }); // 로그 병합 후 최신순 정렬 logs.sort((a, b) => b.date - a.date); const recentLogs = logs.slice(0, 5); // 차트 데이터 (최근 7일) - sent_logs/smtp_logs 기반 const chartData = []; for (let i = 6; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); const dateStr = d.toISOString().split("T")[0]; const dayStart = new Date(d.setHours(0, 0, 0, 0)); const dayEnd = new Date(d.setHours(23, 59, 59, 999)); const [sent, received] = await Promise.all([ SentLog.count({ where: { sentAt: { [Op.between]: [dayStart, dayEnd] }, success: true, }, }), SmtpLog.count({ where: { connectedAt: { [Op.between]: [dayStart, dayEnd] }, success: true, }, }), ]); chartData.push({ name: dateStr.slice(5), sent, received }); } // 시스템 상태 체크 let dbStatus = "Connected"; try { await User.findOne(); } catch { dbStatus = "Disconnected"; } // rspamd 상태 체크 const rspamdStatus = (await rspamd.checkHealth()) ? "Running" : "Stopped"; // 기간 파라미터 처리 (1d, 7d, 30d, all) const period = req.query.period || "all"; const periodStart = getPeriodStartDate(period); // 유저별 메일 통계 (Top Users) - sent_logs/smtp_logs 기반 const allUsers = await User.findAll({ attributes: ["email"] }); const topUsersData = await Promise.all( allUsers.map(async (user) => { const sentWhere = { from: user.email, success: true }; const receivedWhere = { rcptTo: { [Op.like]: `%${user.email}%` }, success: true, }; if (periodStart) { sentWhere.sentAt = { [Op.gte]: periodStart }; receivedWhere.connectedAt = { [Op.gte]: periodStart }; } const [sentCount, receivedCount] = await Promise.all([ SentLog.count({ where: sentWhere }), SmtpLog.count({ where: receivedWhere }), ]); return { email: user.email, sent: sentCount, received: receivedCount, total: sentCount + receivedCount, }; }) ); const topUsers = topUsersData.sort((a, b) => b.total - a.total).slice(0, 5); // SMTP 접속 IP 통계 (Remote IPs) - 국가 정보 포함 const smtpLogWhere = periodStart ? { connectedAt: { [Op.gte]: periodStart } } : {}; const remoteIpsData = await SmtpLog.findAll({ attributes: [ "remoteAddress", "country", "countryName", "hostname", [fn("COUNT", col("id")), "count"], ], where: smtpLogWhere, group: ["remoteAddress", "country", "countryName", "hostname"], order: [[fn("COUNT", col("id")), "DESC"]], limit: 10, raw: true, }); const remoteIps = remoteIpsData.map((item) => ({ ip: item.remoteAddress, country: item.country, countryName: item.countryName, hostname: item.hostname, count: parseInt(item.count), })); res.json({ userCount, sentToday, receivedToday, spamToday, totalSpam, totalSent, totalReceived, storageUsed: storageUsed + " MB", storageLimit, chartData, recentLogs, topUsers, remoteIps, rspamdStatus, dbStatus, }); } catch (error) { console.error("통계 조회 오류:", error); res.status(500).json({ error: "통계 조회 실패" }); } }); /** * 접속 IP 목록 페이징 조회 (개별 로그) * GET /api/admin/remote-ips?page=1&limit=20&period=all */ router.get("/remote-ips", async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = Math.min(parseInt(req.query.limit) || 10, 10); // 기본 10개, 최대 10개 const period = req.query.period || "all"; const maxItems = 50; // 최대 50개까지만 표시 const offset = (page - 1) * limit; // 기간 필터 const periodStart = getPeriodStartDate(period); const smtpLogWhere = periodStart ? { connectedAt: { [Op.gte]: periodStart } } : {}; // 전체 개수 조회 const totalCount = await SmtpLog.count({ where: smtpLogWhere }); // 페이징된 개별 로그 조회 const remoteIpsData = await SmtpLog.findAll({ attributes: [ "remoteAddress", "country", "countryName", "hostname", "mailFrom", "rcptTo", "connectedAt", ], where: smtpLogWhere, order: [["connectedAt", "DESC"]], limit, offset, raw: true, }); const remoteIps = remoteIpsData.map((item) => ({ ip: item.remoteAddress, country: item.country, countryName: item.countryName, hostname: item.hostname, mailFrom: item.mailFrom, rcptTo: item.rcptTo, connectedAt: item.connectedAt, })); // 최대 50개로 제한된 전체 개수 const effectiveTotal = Math.min(totalCount, maxItems); res.json({ data: remoteIps, pagination: { page, limit, total: effectiveTotal, hasMore: offset + remoteIps.length < effectiveTotal, }, }); } catch (error) { console.error("접속 IP 조회 오류:", error); res.status(500).json({ error: "접속 IP 조회 실패" }); } }); /** * 최근 활동 로그 페이징 조회 * GET /api/admin/recent-logs?page=1&limit=20 */ router.get("/recent-logs", async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = Math.min(parseInt(req.query.limit) || 10, 10); // 기본 10개, 최대 10개 const maxItems = 50; // 최대 50개까지만 표시 const offset = (page - 1) * limit; const logs = []; // 최근 수신 메일 const recentInbox = await Inbox.findAll({ order: [["date", "DESC"]], attributes: ["from", "subject", "date"], }); recentInbox.forEach((item) => { logs.push({ type: "INBOX", date: new Date(item.date), message: `메일 수신: ${item.subject}`, detail: item.from, }); }); // 최근 발송 메일 const recentSent = await Sent.findAll({ order: [["date", "DESC"]], attributes: ["to", "subject", "date"], }); recentSent.forEach((item) => { logs.push({ type: "SENT", date: new Date(item.date), message: `메일 발송: ${item.subject}`, detail: item.to, }); }); // 최근 가입 사용자 const recentUsers = await User.findAll({ order: [["createdAt", "DESC"]], attributes: ["email", "createdAt"], }); recentUsers.forEach((user) => { if (user.createdAt) { logs.push({ type: "USER", date: new Date(user.createdAt), message: `사용자 가입: ${user.email}`, detail: user.email, }); } }); // 날짜순 정렬 후 페이징 (최대 50개) logs.sort((a, b) => b.date - a.date); const effectiveTotal = Math.min(logs.length, maxItems); const paginatedLogs = logs.slice( offset, Math.min(offset + limit, effectiveTotal) ); res.json({ data: paginatedLogs, pagination: { page, limit, total: effectiveTotal, hasMore: offset + paginatedLogs.length < effectiveTotal, }, }); } catch (error) { console.error("최근 활동 조회 오류:", error); res.status(500).json({ error: "최근 활동 조회 실패" }); } }); // ============================================================================ // API 라우트 - 사용자 관리 // ============================================================================ /** * 사용자 목록 조회 * GET /api/admin/users */ router.get("/users", async (req, res) => { try { const users = await User.findAll({ attributes: { exclude: ["password"] } }); res.json(users); } catch (error) { console.error("사용자 목록 조회 오류:", error); res.status(500).json({ error: "사용자 목록 조회 실패" }); } }); /** * 사용자 생성 * POST /api/admin/users */ router.post("/users", async (req, res) => { try { const { email, password, name, isAdmin } = req.body; // 중복 이메일 체크 const existing = await User.findOne({ where: { email } }); if (existing) return res.status(400).json({ error: "이미 사용 중인 이메일입니다." }); const hashedPassword = await bcrypt.hash(password, 10); const user = await User.create({ email, password: hashedPassword, name, isAdmin, }); res.json({ success: true, user: { id: user.id, email: user.email } }); } catch (error) { console.error("사용자 생성 오류:", error); res.status(500).json({ error: "사용자 생성 실패" }); } }); /** * 사용자 수정 * PUT /api/admin/users/:id */ router.put("/users/:id", async (req, res) => { try { const { password, name, isAdmin } = req.body; const user = await User.findByPk(req.params.id); if (!user) { return res.status(404).json({ error: "사용자를 찾을 수 없습니다" }); } // 최고 관리자는 비밀번호만 수정 가능 if (user.email === SUPER_ADMIN_EMAIL) { if (password) { user.password = await bcrypt.hash(password, 10); await user.save(); } return res.json({ success: true, user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin, }, }); } if (name !== undefined) user.name = name; if (isAdmin !== undefined) user.isAdmin = isAdmin; if (password) { user.password = await bcrypt.hash(password, 10); } await user.save(); res.json({ success: true, user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin, }, }); } catch (error) { console.error("사용자 수정 오류:", error); res.status(500).json({ error: "사용자 수정 실패" }); } }); /** * 사용자 삭제 * DELETE /api/admin/users/:id */ router.delete("/users/:id", async (req, res) => { try { const user = await User.findByPk(req.params.id); // 최고 관리자 삭제 금지 if (user && user.email === SUPER_ADMIN_EMAIL) { return res .status(403) .json({ error: "최고 관리자 계정은 삭제할 수 없습니다" }); } await User.destroy({ where: { id: req.params.id } }); res.json({ success: true }); } catch (error) { console.error("사용자 삭제 오류:", error); res.status(500).json({ error: "사용자 삭제 실패" }); } }); // ============================================================================ // API 라우트 - Resend 설정 // ============================================================================ /** * Resend 설정 조회 * GET /api/admin/config/email */ router.get("/config/email", async (req, res) => { try { const configs = await SystemConfig.findAll({ where: { key: { [Op.in]: [ "resend_api_key", "mail_from", "user_storage_quota", "session_expire_hours", ], }, }, }); const configMap = {}; configs.forEach((c) => (configMap[c.key] = c.value)); res.json(configMap); } catch (error) { console.error("이메일 설정 조회 오류:", error); res.status(500).json({ error: "설정 조회 실패" }); } }); /** * Resend 설정 저장 * POST /api/admin/config/email */ router.post("/config/email", async (req, res) => { try { const { resend_api_key, mail_from, user_storage_quota, session_expire_hours, } = req.body; // 각 설정값 upsert (있으면 업데이트, 없으면 생성) if (resend_api_key && !resend_api_key.includes("*")) await SystemConfig.upsert({ key: "resend_api_key", value: resend_api_key, }); if (mail_from) await SystemConfig.upsert({ key: "mail_from", value: mail_from }); if (user_storage_quota) await SystemConfig.upsert({ key: "user_storage_quota", value: user_storage_quota, }); if (session_expire_hours) await SystemConfig.upsert({ key: "session_expire_hours", value: session_expire_hours, }); res.json({ success: true }); } catch (error) { console.error("이메일 설정 저장 오류:", error); res.status(500).json({ error: "설정 저장 실패" }); } }); /** * Resend 연결 테스트 (테스트 이메일 발송) * POST /api/admin/config/email/test */ router.post("/config/email/test", async (req, res) => { try { const { to, resend_api_key, mail_from } = req.body; if (!resend_api_key) { return res.status(400).json({ error: "Resend API 키가 필요합니다" }); } // 입력된 값으로 테스트하기 위해 직접 Resend 클라이언트 생성 const { Resend } = require("resend"); const resend = new Resend(resend_api_key); const result = await resend.emails.send({ from: mail_from || to, to: [to], subject: "Resend 연결 테스트", html: "
Resend 이메일 설정이 정상적으로 작동합니다.
", text: "연결 성공! Resend 이메일 설정이 정상적으로 작동합니다.", }); if (result.error) { throw new Error(result.error.message); } res.json({ success: true }); } catch (error) { console.error("Resend 테스트 오류:", error); res.status(500).json({ error: error.message }); } }); // ============================================================================ // API 라우트 - Gemini 설정 // ============================================================================ const geminiService = require("../services/geminiService"); /** * Gemini 설정 조회 * GET /api/admin/config/gemini */ router.get("/config/gemini", async (req, res) => { try { const config = await geminiService.getGeminiConfig(); res.json(config); } catch (error) { console.error("Gemini 설정 조회 오류:", error); res.status(500).json({ error: "Gemini 설정 조회 실패" }); } }); /** * Gemini 설정 저장 * POST /api/admin/config/gemini */ router.post("/config/gemini", async (req, res) => { try { const { gemini_api_key, gemini_model } = req.body; await geminiService.saveGeminiConfig({ gemini_api_key, gemini_model }); res.json({ success: true }); } catch (error) { console.error("Gemini 설정 저장 오류:", error); res.status(500).json({ error: "Gemini 설정 저장 실패" }); } }); /** * Gemini 번역 API (일반 사용자도 사용 가능) * POST /api/admin/translate */ router.post("/translate", async (req, res) => { try { const { text, targetLang } = req.body; if (!text) { return res.status(400).json({ error: "번역할 텍스트가 필요합니다" }); } const config = await geminiService.getGeminiConfig(); if (!config.gemini_api_key) { return res .status(400) .json({ error: "Gemini API 키가 설정되지 않았습니다" }); } const translatedText = await geminiService.translateText( text, targetLang || "ko", config.gemini_api_key, config.gemini_model ); res.json({ translatedText }); } catch (error) { console.error("번역 오류:", error); res.status(500).json({ error: error.message || "번역 실패" }); } }); module.exports = router;