mailbox/backend/routes/admin.js

747 lines
21 KiB
JavaScript
Raw Normal View History

2025-12-16 08:18:15 +09:00
/**
* 관리자 라우터
* 통계 대시보드, 사용자 관리, 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: "<h1>연결 성공!</h1><p>Resend 이메일 설정이 정상적으로 작동합니다.</p>",
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;