메일서버 관리 화면 코드 공개 (index.php)
더 편하게 읽는 방법 (초보자 추천)
아래 코드는 3,100줄이 넘어서 이 페이지에서 쭉 훑어보기엔 불편할 수 있습니다. 구간마다 목차로 정리되고, 우측 목차에서 원하는 곳을 클릭하면 바로 그 자리로 이동하는 정리된 버전을 따로 만들어뒀습니다. 코드를 직접 뜯어보고 싶으시다면 이쪽을 먼저 열어보시길 추천합니다.
이 글은 앞서 공개한 메일서버 관리 API 코드(mailapi.py)와
짝을 이루는 웹 화면(프론트엔드) 코드, index.php를 공개하는 글입니다.
API 서버(mailapi.py)가 실제 작업(도메인 추가, 계정 삭제, 파일 저장 등)을 처리한다면, 이
화면은 그 API 서버에 “이렇게 해줘"라고 요청을 보내고 돌아온 결과를 사람이 보는 HTML로
그려주는 역할만 합니다. 프레임워크 없이 순수 PHP로 만들었고, 로그인 화면과 CSRF 방어,
탭마다 나뉜 기능(도메인·계정·별칭·백업·파일 탐색기·접속 IP 차단 등)이 한 파일 안에 들어
있습니다.
코딩을 잘 몰라도 흐름을 따라올 수 있도록 함수·구간마다 한글 주석을 달았습니다. 다만 실제
운영 서버의 로그인 정보와 직결되는 기본값(비밀번호·API 키)은 ***로 가려두었으니, 그대로
복붙해서 쓰기보다는 참고용으로 봐주세요.
이 글의 주석 방침: 로그인 처리(
handle_ui_login)와 저장 요청의 CSRF 검사, 파일 탐색기의 허용(file_allow) 동작처럼 보안과 직결되는 부분은 줄마다 설명을 달았습니다. 나머지 함수·구간은 구간 앞에 요약 설명을 달아뒀습니다.
전체 코드는 아래와 같습니다.
<?php
declare(strict_types=1);
// =====================================================================
// 이 코드는 무엇인가? (초보자를 위한 개요)
// =====================================================================
// 자체 메일서버(postfix + dovecot + MariaDB)를 관리하는 화면(프론트엔드)입니다.
// 이 파일 혼자서는 아무 일도 못 하고, 뒷단(백엔드) API 서버(mailapi.py)에
// "도메인 추가해줘", "계정 목록 줘" 같은 요청을 보내고, 돌아온 결과를
// 사람이 보기 좋은 HTML 화면으로 그려주는 역할만 합니다.
//
// - 프레임워크(Laravel, Symfony 등) 없이 순수 PHP로만 만들었습니다.
// - 로그인 화면이 있고(세션 기반), 저장하는 동작(POST)마다 CSRF 토큰을 검사합니다.
// - 탭이 여러 개(대시보드, 도메인, 계정, 별칭, 백업, 파일 탐색기, 접속 IP 등)이고,
// 주소의 ?tab=값으로 어떤 탭인지 정합니다.
// - 화면 하나(이 파일)가 "저장 동작 처리 → 화면에 쓸 데이터 조회 → HTML 그리기"
// 순서로 위에서 아래로 실행됩니다.
//
// ※ 이 글은 학습/공유 목적으로 비밀번호·API 키의 기본값을 ***로 가려서 공개합니다.
// 실제 운영 시에는 이 코드를 그대로 복붙하지 말고, 본인 환경에 맞게 검토 후 사용하세요.
// =====================================================================
session_start();
ini_set('display_errors', '0');
error_reporting(E_ALL);
// --- 설정값 (환경변수로 오버라이드 가능) ---
// 뒷단 API 서버 주소와, 그 서버에 요청할 때 같이 보내는 인증 키.
$API_BASE = getenv('MAILADMIN_UI_API_BASE') ?: 'http://127.0.0.1:18080';
$API_KEY = getenv('MAILADMIN_UI_API_KEY') ?: '***'; // 서버에서 직접 정하는 값 (공개용으로 가림)
// API 서버의 기능별 주소(경로) 모음. 기능을 추가할 때 여기 한 줄만 더하면 된다.
$ENDPOINTS = [
'health' => '/health',
'domain_add' => '/domain/add',
'domain_delete' => '/domain/delete',
'domain_set_tempmail' => '/domain/set-tempmail',
'domain_list' => '/domain/list',
'account_add' => '/account/add',
'account_delete' => '/account/delete',
'account_list' => '/account/list',
'account_password' => '/account/password',
'account_search' => '/account/search',
'account_set_active' => '/account/set-active',
'account_test_login' => '/account/test-login',
'account_quota' => '/account/quota',
'audit_log' => '/audit/log',
'dkim_list' => '/dkim/list',
'dkim_public' => '/dkim/public',
'dkim_generate' => '/dkim/generate',
'backup_list' => '/backup/list',
'backup_create' => '/backup/create',
'backup_restore' => '/backup/restore',
'alias_list' => '/alias/list',
'alias_add' => '/alias/add',
'alias_delete' => '/alias/delete',
'alias_set_active' => '/alias/set-active',
'system_status' => '/system/status',
'fail2ban_status' => '/system/fail2ban',
'cert_status' => '/system/certs',
'tempmail_stats' => '/tempmail/stats',
'security_checks' => '/security/checks',
'log_sources' => '/logs/sources',
'log_tail' => '/logs/tail',
'fail2ban_unban' => '/system/fail2ban/unban',
'file_roots' => '/file/roots',
'file_list' => '/file/list',
'file_read' => '/file/read',
'file_write' => '/file/write',
'file_perm' => '/file/perm',
'file_allowed' => '/file/allowed',
'file_allow' => '/file/allow',
'file_allow_remove' => '/file/allow/remove',
'connections' => '/system/connections',
'fail2ban_ban' => '/system/fail2ban/ban',
];
$USE_UI_LOGIN = true;
// --- 이 화면 자체의 로그인 계정 (API 키와는 별개) ---
$UI_USERNAME = getenv('MAILADMIN_UI_USER') ?: 'admin';
$UI_PASSWORD = getenv('MAILADMIN_UI_PASS') ?: '***'; // 서버에서 직접 정하는 값 (공개용으로 가림)
// =====================================================================
// 공통 유틸리티 함수
// =====================================================================
// h() : 화면에 값을 찍을 때 항상 거치는 함수. <, >, &, " 같은 글자를 무해한
// 형태로 바꿔서, 사용자가 입력한 값에 HTML 코드를 몰래 넣어도(XSS) 그대로
// 글자로만 보이게 만든다.
function h(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function highlight_log_line(string $line): string
{
$escaped = h($line);
$patterns = [
'/\b(ban(?:ned)?|block(?:ed)?|attack|intrusion|unauthorized|suspicious|deny|denied|refused)\b/i' => 'log-hl-security',
'/\b(warn(?:ing)?)\b/i' => 'log-hl-warn',
'/\b(error|err|fail(?:ed)?|critical|fatal|exception|panic)\b/i' => 'log-hl-error',
];
foreach ($patterns as $pattern => $class) {
$escaped = preg_replace_callback(
$pattern,
function ($m) use ($class) {
return '<span class="' . $class . '">' . $m[0] . '</span>';
},
$escaped
);
}
return $escaped;
}
function mode_from_post(array $p): string
{
$digit = function (string $who) use ($p): int {
return (isset($p["pm_{$who}r"]) ? 4 : 0) + (isset($p["pm_{$who}w"]) ? 2 : 0) + (isset($p["pm_{$who}x"]) ? 1 : 0);
};
return $digit('u') . $digit('g') . $digit('o');
}
function file_name_style(string $name): string
{
// 압축파일은 빨간색, .bak 백업 파일은 밝은 연두색, 그 밖에는 기본색
$low = strtolower($name);
if (preg_match('/\.(zip|tar|gz|tgz|bz2|tbz2|xz|txz|zst|7z|rar|z|lz4)$/', $low)) {
return ' style="color:#ef4444;"';
}
if (strpos($low, '.bak') !== false) {
return ' style="color:#a3e635;"';
}
return '';
}
function is_post(): bool
{
return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
}
// csrf_token() / verify_csrf() : 다른 사이트가 내 브라우저를 시켜 몰래 요청을
// 보내는 것(CSRF 공격)을 막는다. 세션마다 무작위 토큰을 하나 만들어 화면에
// 숨겨서 넣어두고, 저장(POST) 요청이 오면 그 토큰이 같이 왔는지 확인한다.
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(): bool
{
$token = $_POST['csrf_token'] ?? '';
return is_string($token) && hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
function set_flash(string $type, string $message): void
{
$_SESSION['flash'] = [
'type' => $type,
'message' => $message,
];
}
function get_flash(): ?array
{
if (!isset($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
function normalize_domain(string $domain): string
{
return strtolower(trim($domain));
}
function normalize_email(string $email): string
{
return strtolower(trim($email));
}
function valid_domain(string $domain): bool
{
return (bool) preg_match(
'/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i',
$domain
);
}
function valid_email_addr(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
function normalize_active_input(mixed $value): int
{
if ($value === '1' || $value === 1 || $value === true || $value === 'true') {
return 1;
}
return 0;
}
function pretty_json(mixed $value): string
{
return json_encode(
$value,
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) ?: '';
}
function bytes_to_human(int $n): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$value = (float) $n;
foreach ($units as $unit) {
if ($value < 1024.0 || $unit === end($units)) {
return sprintf('%.2f %s', $value, $unit);
}
$value /= 1024.0;
}
return $n . ' B';
}
// api_request() / api_get_json() / api_post_json() : 뒷단 API 서버에 실제로
// 요청을 보내는 함수. 모든 요청에 API 키를 헤더로 실어 보내고, 응답이
// JSON이 아니거나 API 서버가 응답을 안 하면 오류로 처리해서 화면이
// 깨지지 않게 한다.
function api_request(string $method, string $url, string $apiKey, ?array $payload = null): array
{
$ch = curl_init();
if ($ch === false) {
return ['ok' => false, 'http_code' => 0, 'json' => null, 'raw' => '', 'error' => 'curl_init_failed'];
}
$headers = [
'Accept: application/json',
'X-API-Key: ' . $apiKey,
];
$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 60,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
];
if ($payload !== null) {
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
curl_close($ch);
return ['ok' => false, 'http_code' => 0, 'json' => null, 'raw' => '', 'error' => 'json_encode_failed'];
}
$options[CURLOPT_POSTFIELDS] = $json;
$options[CURLOPT_HTTPHEADER] = [
'Content-Type: application/json; charset=utf-8',
'Accept: application/json',
'X-API-Key: ' . $apiKey,
];
}
curl_setopt_array($ch, $options);
$raw = curl_exec($ch);
$error = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false) {
return ['ok' => false, 'http_code' => $httpCode, 'json' => null, 'raw' => '', 'error' => $error ?: 'curl_exec_failed'];
}
$decoded = json_decode($raw, true);
return [
'ok' => ($httpCode >= 200 && $httpCode < 300),
'http_code' => $httpCode,
'json' => is_array($decoded) ? $decoded : null,
'raw' => $raw,
'error' => null,
];
}
function api_get_json(string $url, string $apiKey): array
{
return api_request('GET', $url, $apiKey, null);
}
function api_post_json(string $url, string $apiKey, array $payload): array
{
return api_request('POST', $url, $apiKey, $payload);
}
// =====================================================================
// 로그인 (이 화면 자체의 보호 장치)
// =====================================================================
function is_logged_in(bool $useLogin): bool
{
if (!$useLogin) {
return true;
}
return !empty($_SESSION['ui_logged_in']);
}
// handle_ui_login(): 로그인 화면에서 넘어온 아이디·비밀번호를 확인하는 함수 (전체 라인 주석)
function handle_ui_login(bool $useLogin, string $username, string $password): void
{
if (!$useLogin || !is_post()) {
return; // 로그인을 안 쓰거나, 화면 보기(GET) 요청이면 할 일 없음
}
$action = (string)($_POST['action'] ?? ''); // 로그인 폼이 보낸 action 값 확인
if ($action === 'login') {
$u = (string)($_POST['ui_username'] ?? ''); // 입력한 아이디
$p = (string)($_POST['ui_password'] ?? ''); // 입력한 비밀번호
// hash_equals: 글자를 앞에서부터 하나씩 비교하다 다르면 바로 멈추는 일반 비교(==)와 달리,
// 항상 같은 시간이 걸리게 비교한다. 그래야 "걸린 시간"만 보고 비밀번호를 한 글자씩
// 알아내는 공격(타이밍 공격)을 막을 수 있다.
if (hash_equals($username, $u) && hash_equals($password, $p)) {
session_regenerate_id(true); // 로그인 성공 시 세션 ID를 새로 발급 (세션 고정 공격 방지)
$_SESSION['ui_logged_in'] = true; // 로그인 상태를 세션에 표시
set_flash('success', '로그인 성공');
} else {
$clientIp = $_SERVER['REMOTE_ADDR'] ?? '-'; // 실패한 접속의 IP
$logLine = sprintf(
"[%s] MAILADMIN_UI_LOGIN_FAIL ip=%s user=%s\n", // 시각·IP·시도한 아이디를 한 줄로
date('Y-m-d H:i:s'),
$clientIp,
$u
);
@file_put_contents('/var/log/mailadmin-ui-auth.log', $logLine, FILE_APPEND | LOCK_EX); // 실패 기록 (무차별 대입 탐지용)
set_flash('error', '로그인 실패');
}
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
if ($action === 'logout') {
unset($_SESSION['ui_logged_in']);
session_regenerate_id(true);
set_flash('success', '로그아웃 완료');
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
}
handle_ui_login($USE_UI_LOGIN, $UI_USERNAME, $UI_PASSWORD);
// =====================================================================
// 저장 동작(POST) 처리
// =====================================================================
// 로그인·로그아웃이 아닌 모든 POST 요청은 먼저 CSRF 토큰부터 검사한다.
// 여기를 통과해야만 아래 switch 문의 각 기능(도메인 추가, 계정 삭제,
// 파일 저장, 차단 등)으로 넘어간다.
if (is_post() && is_logged_in($USE_UI_LOGIN)) {
$action = (string)($_POST['action'] ?? '');
if (!in_array($action, ['login', 'logout'], true)) {
if (!verify_csrf()) {
set_flash('error', 'CSRF token 검증 실패');
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
}
// 이 PHP 파일은 입력값 형식만 가볍게 확인하고 API 서버로 그대로 넘긴다.
// 진짜 보안 검사(경로가 시스템 영역인지, 비밀번호가 맞는지, IP가 지금
// 접속 중인지 등)는 전부 API 서버(mailapi.py) 쪽에서 다시 한다 —
// 화면 쪽 검사만 믿지 않는다는 원칙.
try {
switch ($action) {
case 'domain_add': // 도메인 추가
$domain = normalize_domain((string)($_POST['domain'] ?? ''));
if ($domain === '' || !valid_domain($domain)) {
throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
}
$result = api_post_json($API_BASE . $ENDPOINTS['domain_add'], $API_KEY, ['domain' => $domain]);
break;
case 'domain_delete':
$domain = normalize_domain((string)($_POST['domain'] ?? ''));
if ($domain === '' || !valid_domain($domain)) {
throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
}
$result = api_post_json($API_BASE . $ENDPOINTS['domain_delete'], $API_KEY, ['domain' => $domain]);
break;
case 'domain_set_tempmail':
$domain = normalize_domain((string)($_POST['domain'] ?? ''));
$enabled = normalize_active_input($_POST['enabled'] ?? '0');
if ($domain === '' || !valid_domain($domain)) {
throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['domain_set_tempmail'],
$API_KEY,
['domain' => $domain, 'enabled' => $enabled]
);
break;
case 'account_add':
$email = normalize_email((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
$active = normalize_active_input($_POST['active'] ?? '1');
if ($email === '' || !valid_email_addr($email)) {
throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
}
if ($password === '') {
throw new RuntimeException('비밀번호를 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['account_add'],
$API_KEY,
['email' => $email, 'password' => $password, 'active' => $active]
);
break;
case 'account_delete':
$email = normalize_email((string)($_POST['email'] ?? ''));
if ($email === '' || !valid_email_addr($email)) {
throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
}
$result = api_post_json($API_BASE . $ENDPOINTS['account_delete'], $API_KEY, ['email' => $email]);
break;
case 'account_password':
$email = normalize_email((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if ($email === '' || !valid_email_addr($email)) {
throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
}
if ($password === '') {
throw new RuntimeException('새 비밀번호를 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['account_password'],
$API_KEY,
['email' => $email, 'password' => $password]
);
break;
case 'account_set_active':
$email = normalize_email((string)($_POST['email'] ?? ''));
$active = normalize_active_input($_POST['active'] ?? '0');
if ($email === '' || !valid_email_addr($email)) {
throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['account_set_active'],
$API_KEY,
['email' => $email, 'active' => $active]
);
break;
case 'account_test_login':
$email = normalize_email((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if ($email === '' || !valid_email_addr($email)) {
throw new RuntimeException('유효한 이메일 주소를 입력해야 합니다.');
}
if ($password === '') {
throw new RuntimeException('테스트 비밀번호를 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['account_test_login'],
$API_KEY,
['email' => $email, 'password' => $password]
);
break;
case 'dkim_generate':
$domain = normalize_domain((string)($_POST['domain'] ?? ''));
$selector = trim((string)($_POST['selector'] ?? 'default'));
$bits = (int)($_POST['bits'] ?? 2048);
$force = normalize_active_input($_POST['force'] ?? '0');
if ($domain === '' || !valid_domain($domain)) {
throw new RuntimeException('유효한 도메인을 입력해야 합니다.');
}
if ($selector === '') {
throw new RuntimeException('selector를 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['dkim_generate'],
$API_KEY,
['domain' => $domain, 'selector' => $selector, 'bits' => $bits, 'force' => $force]
);
break;
case 'backup_create':
$label = trim((string)($_POST['label'] ?? ''));
$result = api_post_json(
$API_BASE . $ENDPOINTS['backup_create'],
$API_KEY,
['label' => $label]
);
break;
case 'backup_restore':
$file = trim((string)($_POST['file'] ?? ''));
if ($file === '') {
throw new RuntimeException('복구할 백업 파일을 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['backup_restore'],
$API_KEY,
['file' => $file]
);
break;
case 'alias_add':
$source = normalize_email((string)($_POST['source'] ?? ''));
$destination = normalize_email((string)($_POST['destination'] ?? ''));
$active = normalize_active_input($_POST['active'] ?? '1');
if ($source === '' || !valid_email_addr($source)) {
throw new RuntimeException('유효한 원본 이메일(source)을 입력해야 합니다.');
}
if ($destination === '' || !valid_email_addr($destination)) {
throw new RuntimeException('유효한 목적지 이메일(destination)을 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['alias_add'],
$API_KEY,
['source' => $source, 'destination' => $destination, 'active' => $active]
);
break;
case 'alias_delete':
$aliasId = (int)($_POST['id'] ?? 0);
if ($aliasId <= 0) {
throw new RuntimeException('삭제할 별칭 id가 필요합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['alias_delete'],
$API_KEY,
['id' => $aliasId]
);
break;
case 'alias_set_active':
$aliasId = (int)($_POST['id'] ?? 0);
$active = normalize_active_input($_POST['active'] ?? '0');
if ($aliasId <= 0) {
throw new RuntimeException('별칭 id가 필요합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['alias_set_active'],
$API_KEY,
['id' => $aliasId, 'active' => $active]
);
break;
case 'fail2ban_unban': // 차단 해제 (IP 형식만 확인하고 API 로 전달)
$unbanIp = trim((string)($_POST['unban_ip'] ?? ''));
$unbanJail = trim((string)($_POST['unban_jail'] ?? ''));
if ($unbanIp === '' || filter_var($unbanIp, FILTER_VALIDATE_IP) === false) {
throw new RuntimeException('유효한 IP 주소를 입력해야 합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['fail2ban_unban'],
$API_KEY,
['ip' => $unbanIp, 'jail' => $unbanJail]
);
break;
case 'file_write': // 파일 저장 (허용된 폴더인지는 API 서버가 다시 검사)
$fwPath = (string)($_POST['path'] ?? '');
$fwContent = (string)($_POST['content'] ?? '');
if ($fwPath === '') {
throw new RuntimeException('저장할 파일 경로가 필요합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['file_write'],
$API_KEY,
['path' => $fwPath, 'content' => $fwContent]
);
break;
case 'fail2ban_ban': // 접속 중인 IP를 골라 jail 로 차단 보내기
$banIp = trim((string)($_POST['ban_ip'] ?? ''));
$banJail = trim((string)($_POST['ban_jail'] ?? ''));
if ($banIp === '' || filter_var($banIp, FILTER_VALIDATE_IP) === false) {
throw new RuntimeException('표에서 IP를 먼저 눌러 고르세요.');
}
if ($banJail === '') {
throw new RuntimeException('보낼 jail을 고르세요.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['fail2ban_ban'],
$API_KEY,
['ip' => $banIp, 'jail' => $banJail]
);
break;
// file_allow : 폴더를 "허용 목록"에 추가하는, 파일 탐색기에서 가장 민감한
// 동작이다(전체 라인 주석). 비밀번호가 맞는지, 그 경로가 시스템 영역인지는
// 여기서 판단하지 않고 API 서버로 넘겨서 다시 검사받는다 — 화면(PHP)
// 쪽 검사만으로는 절대 통과시키지 않는다는 원칙을 여기서도 지킨다.
case 'file_allow':
$faPath = (string)($_POST['path'] ?? ''); // 허용할 폴더 경로
$faPassword = (string)($_POST['password'] ?? ''); // 허용용 비밀번호 (여기선 검증 안 함)
if ($faPath === '') {
throw new RuntimeException('허용할 폴더 경로가 필요합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['file_allow'], // 실제 비밀번호 검증·경로 검증은 이 API 호출 안에서 일어난다
$API_KEY,
['path' => $faPath, 'password' => $faPassword]
);
break;
case 'file_allow_remove': // 허용 해제 (비밀번호 불필요 — 더 좁아지는 방향이라)
$farPath = (string)($_POST['path'] ?? '');
if ($farPath === '') {
throw new RuntimeException('해제할 폴더 경로가 필요합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['file_allow_remove'],
$API_KEY,
['path' => $farPath]
);
break;
case 'file_perm': // 권한·소유 변경 (체크박스 → 숫자 변환은 mode_from_post 가 함)
$fpPath = (string)($_POST['path'] ?? '');
if ($fpPath === '') {
throw new RuntimeException('바꿀 파일 경로가 필요합니다.');
}
$result = api_post_json(
$API_BASE . $ENDPOINTS['file_perm'],
$API_KEY,
[
'path' => $fpPath,
'mode' => mode_from_post($_POST),
'owner' => trim((string)($_POST['owner'] ?? '')),
'group' => trim((string)($_POST['group'] ?? '')),
]
);
break;
default:
$result = null;
break;
}
if ($result !== null) {
if ($result['ok']) {
if ($action === 'fail2ban_unban' && is_array($result['json'])) {
$unbannedFrom = [];
foreach (($result['json']['results'] ?? []) as $r) {
if ((string)($r['detail'] ?? '') === '1') {
$unbannedFrom[] = (string)($r['jail'] ?? '');
}
}
$ipShown = (string)($result['json']['ip'] ?? $unbanIp);
if (!empty($unbannedFrom)) {
set_flash('success', "{$ipShown} 차단 해제 완료 (" . implode(', ', $unbannedFrom) . ')');
} else {
set_flash('success', "{$ipShown} — 차단되어 있던 jail이 없었습니다.");
}
} elseif ($action === 'file_perm' && is_array($result['json'])) {
set_flash('success', '✅ ' . (string)($result['json']['message'] ?? '변경 완료'));
} elseif (in_array($action, ['file_allow', 'file_allow_remove'], true) && is_array($result['json'])) {
set_flash('success', '✅ ' . (string)($result['json']['message'] ?? '완료'));
} elseif ($action === 'fail2ban_ban' && is_array($result['json'])) {
set_flash('success', '✅ ' . (string)($result['json']['message'] ?? '차단 완료'));
} else {
set_flash('success', pretty_json($result['json'] ?? ['status' => 'ok']));
}
} else {
if (in_array($action, ['file_perm', 'fail2ban_ban', 'file_allow', 'file_allow_remove'], true) && is_array($result['json']) && isset($result['json']['message'])) {
set_flash('error', '⚠️ ' . (string)$result['json']['message']);
} elseif ($result['error']) {
set_flash('error', "실패\n" . $result['error']);
} elseif (is_array($result['json'])) {
set_flash('error', "실패\n" . pretty_json($result['json']));
} else {
set_flash('error', "실패\nHTTP {$result['http_code']}\n{$result['raw']}");
}
}
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
} catch (Throwable $e) {
set_flash('error', $e->getMessage());
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
}
// =====================================================================
// 화면에 필요한 데이터 불러오기
// =====================================================================
// 여기서부터는 "그리기 전에 API 서버한테 물어봐야 할 것들"을 미리 다
// 조회해둔다. 탭마다 필요한 데이터가 달라서, 아래에서 지금 탭이 무엇인지
// 보고 그 탭에만 필요한 추가 조회를 한다(안 쓰는 탭의 데이터까지 매번
// 긁어오지 않기 위함).
$searchQ = trim((string)($_GET['q'] ?? ''));
$searchDomain = trim((string)($_GET['domain'] ?? ''));
$searchActive = trim((string)($_GET['active'] ?? ''));
$quotaDomain = trim((string)($_GET['quota_domain'] ?? ''));
$dkimViewDomain = trim((string)($_GET['dkim_domain'] ?? ''));
$dkimViewSelector = trim((string)($_GET['dkim_selector'] ?? 'default'));
$fmPath = trim((string)($_GET['fpath'] ?? '/'));
$fmFile = trim((string)($_GET['ffile'] ?? ''));
$fmPerm = trim((string)($_GET['fperm'] ?? ''));
$logKey = trim((string)($_GET['logkey'] ?? ''));
$logLinesParam = trim((string)($_GET['loglines'] ?? '100'));
$secLinesParam = trim((string)($_GET['seclines'] ?? '50'));
$activeTab = trim((string)($_GET['tab'] ?? 'dashboard'));
$healthData = null;
$domainsData = null;
$accountsData = null;
$allAccountsData = null;
$quotaData = null;
$allQuotaData = null;
$auditData = null;
$dkimListData = null;
$dkimPublicData = null;
$backupData = null;
$aliasesData = null;
$systemStatusData = null;
$fail2banData = null;
$certData = null;
$tempmailStatsData = null;
$logSourcesData = null;
$securityChecksData = null;
$logTailData = null;
$logTailErr = null;
$fmRootsData = null;
$fmListData = null;
$fmReadData = null;
$fmPermData = null;
$fmPermErr = null;
$fmAllowedData = null;
$fmAllow = trim((string)($_GET['fallow'] ?? ''));
$connData = null;
$connErr = null;
$healthErr = null;
$domainsErr = null;
$accountsErr = null;
$quotaErr = null;
$auditErr = null;
$dkimListErr = null;
$dkimPublicErr = null;
$backupErr = null;
$aliasesErr = null;
$systemStatusErr = null;
$fmListErr = null;
$fmReadErr = null;
if (is_logged_in($USE_UI_LOGIN)) {
$healthRes = api_get_json($API_BASE . $ENDPOINTS['health'], $API_KEY);
$healthData = $healthRes['ok'] ? $healthRes['json'] : null;
$healthErr = $healthRes['ok'] ? null : $healthRes;
$domainsRes = api_get_json($API_BASE . $ENDPOINTS['domain_list'], $API_KEY);
$domainsData = $domainsRes['ok'] ? $domainsRes['json'] : null;
$domainsErr = $domainsRes['ok'] ? null : $domainsRes;
$allAccountsRes = api_get_json($API_BASE . $ENDPOINTS['account_list'], $API_KEY);
$allAccountsData = $allAccountsRes['ok'] ? $allAccountsRes['json'] : null;
$accountUrl = $API_BASE . $ENDPOINTS['account_list'];
$query = [];
if ($searchQ !== '') {
$query[] = 'q=' . urlencode($searchQ);
}
if ($searchDomain !== '') {
$query[] = 'domain=' . urlencode($searchDomain);
}
if ($searchActive === '0' || $searchActive === '1') {
$query[] = 'active=' . urlencode($searchActive);
}
if (!empty($query)) {
$accountUrl = $API_BASE . $ENDPOINTS['account_search'] . '?' . implode('&', $query);
}
$accountsRes = api_get_json($accountUrl, $API_KEY);
$accountsData = $accountsRes['ok'] ? $accountsRes['json'] : null;
$accountsErr = $accountsRes['ok'] ? null : $accountsRes;
$allQuotaRes = api_get_json($API_BASE . $ENDPOINTS['account_quota'], $API_KEY);
$allQuotaData = $allQuotaRes['ok'] ? $allQuotaRes['json'] : null;
$quotaUrl = $API_BASE . $ENDPOINTS['account_quota'];
if ($quotaDomain !== '') {
$quotaUrl .= '?domain=' . urlencode($quotaDomain);
}
$quotaRes = api_get_json($quotaUrl, $API_KEY);
$quotaData = $quotaRes['ok'] ? $quotaRes['json'] : null;
$quotaErr = $quotaRes['ok'] ? null : $quotaRes;
$auditRes = api_get_json($API_BASE . $ENDPOINTS['audit_log'] . '?lines=50', $API_KEY);
$auditData = $auditRes['ok'] ? $auditRes['json'] : null;
$auditErr = $auditRes['ok'] ? null : $auditRes;
$dkimListRes = api_get_json($API_BASE . $ENDPOINTS['dkim_list'], $API_KEY);
$dkimListData = $dkimListRes['ok'] ? $dkimListRes['json'] : null;
$dkimListErr = $dkimListRes['ok'] ? null : $dkimListRes;
if ($dkimViewDomain !== '') {
$dkimPublicRes = api_get_json(
$API_BASE . $ENDPOINTS['dkim_public'] . '?domain=' . urlencode($dkimViewDomain) . '&selector=' . urlencode($dkimViewSelector),
$API_KEY
);
$dkimPublicData = $dkimPublicRes['ok'] ? $dkimPublicRes['json'] : null;
$dkimPublicErr = $dkimPublicRes['ok'] ? null : $dkimPublicRes;
}
$backupRes = api_get_json($API_BASE . $ENDPOINTS['backup_list'], $API_KEY);
$backupData = $backupRes['ok'] ? $backupRes['json'] : null;
$backupErr = $backupRes['ok'] ? null : $backupRes;
$aliasesRes = api_get_json($API_BASE . $ENDPOINTS['alias_list'], $API_KEY);
$aliasesData = $aliasesRes['ok'] ? $aliasesRes['json'] : null;
$aliasesErr = $aliasesRes['ok'] ? null : $aliasesRes;
$systemStatusRes = api_get_json($API_BASE . $ENDPOINTS['system_status'], $API_KEY);
$systemStatusData = $systemStatusRes['ok'] ? $systemStatusRes['json'] : null;
$systemStatusErr = $systemStatusRes['ok'] ? null : $systemStatusRes;
$fail2banRes = api_get_json($API_BASE . $ENDPOINTS['fail2ban_status'], $API_KEY);
$fail2banData = $fail2banRes['ok'] ? $fail2banRes['json'] : null;
$certRes = api_get_json($API_BASE . $ENDPOINTS['cert_status'], $API_KEY);
$certData = $certRes['ok'] ? $certRes['json'] : null;
$tempmailStatsRes = api_get_json($API_BASE . $ENDPOINTS['tempmail_stats'], $API_KEY);
$tempmailStatsData = $tempmailStatsRes['ok'] ? $tempmailStatsRes['json'] : null;
// 아래 4개 탭은 다른 탭에는 없는 자기만의 데이터가 더 필요해서 따로 조회한다.
if ($activeTab === 'svclogs') {
$logSourcesRes = api_get_json($API_BASE . $ENDPOINTS['log_sources'], $API_KEY);
$logSourcesData = $logSourcesRes['ok'] ? $logSourcesRes['json'] : null;
if ($logKey !== '') {
$logLinesQ = ctype_digit($logLinesParam) ? $logLinesParam : '100';
$logTailRes = api_get_json(
$API_BASE . $ENDPOINTS['log_tail'] . '?key=' . urlencode($logKey) . '&lines=' . urlencode($logLinesQ),
$API_KEY
);
$logTailData = $logTailRes['ok'] ? $logTailRes['json'] : null;
$logTailErr = $logTailRes['ok'] ? null : $logTailRes;
}
}
if ($activeTab === 'seccheck') {
$secLinesQ = ctype_digit($secLinesParam) ? $secLinesParam : '50';
$securityChecksRes = api_get_json(
$API_BASE . $ENDPOINTS['security_checks'] . '?lines=' . urlencode($secLinesQ),
$API_KEY
);
$securityChecksData = $securityChecksRes['ok'] ? $securityChecksRes['json'] : null;
}
if ($activeTab === 'connips') {
$connRes = api_get_json($API_BASE . $ENDPOINTS['connections'], $API_KEY);
$connData = $connRes['ok'] ? $connRes['json'] : null;
$connErr = $connRes['ok'] ? null : $connRes;
}
if ($activeTab === 'files') {
$fmAllowedRes = api_get_json($API_BASE . $ENDPOINTS['file_allowed'], $API_KEY);
$fmAllowedData = $fmAllowedRes['ok'] ? $fmAllowedRes['json'] : null;
$fmRootsRes = api_get_json($API_BASE . $ENDPOINTS['file_roots'], $API_KEY);
$fmRootsData = $fmRootsRes['ok'] ? $fmRootsRes['json'] : null;
if ($fmPerm !== '') {
$fmPermRes = api_get_json(
$API_BASE . $ENDPOINTS['file_perm'] . '?path=' . urlencode($fmPerm),
$API_KEY
);
$fmPermData = $fmPermRes['ok'] ? $fmPermRes['json'] : null;
$fmPermErr = $fmPermRes['ok'] ? null : $fmPermRes;
} elseif ($fmFile !== '') {
$fmReadRes = api_get_json(
$API_BASE . $ENDPOINTS['file_read'] . '?path=' . urlencode($fmFile),
$API_KEY
);
$fmReadData = $fmReadRes['ok'] ? $fmReadRes['json'] : null;
$fmReadErr = $fmReadRes['ok'] ? null : $fmReadRes;
} else {
$fmListRes = api_get_json(
$API_BASE . $ENDPOINTS['file_list'] . '?path=' . urlencode($fmPath),
$API_KEY
);
$fmListData = $fmListRes['ok'] ? $fmListRes['json'] : null;
$fmListErr = $fmListRes['ok'] ? null : $fmListRes;
}
}
}
// 위에서 받아온 원본 데이터를 화면(특히 대시보드 카드)에 바로 쓰기 좋은
// 숫자·요약값으로 미리 계산해둔다.
$statDomainCount = is_array($domainsData['domains'] ?? null) ? count($domainsData['domains']) : null;
$statAccountTotal = null;
$statAccountActive = null;
$statAccountInactive = null;
if (is_array($allAccountsData['accounts'] ?? null)) {
$statAccountTotal = count($allAccountsData['accounts']);
$statAccountActive = 0;
foreach ($allAccountsData['accounts'] as $row) {
if ((int)($row['active'] ?? 0) === 1) {
$statAccountActive++;
}
}
$statAccountInactive = $statAccountTotal - $statAccountActive;
}
$statQuotaTotalBytes = null;
$statQuotaTop = [];
if (is_array($allQuotaData['accounts'] ?? null)) {
$statQuotaTotalBytes = 0;
$rows = $allQuotaData['accounts'];
foreach ($rows as $row) {
$statQuotaTotalBytes += (int)($row['size_bytes'] ?? 0);
}
usort($rows, function ($a, $b) {
return (int)($b['size_bytes'] ?? 0) <=> (int)($a['size_bytes'] ?? 0);
});
$statQuotaTop = array_slice($rows, 0, 10);
}
$statDkimSelectorTotal = null;
if (is_array($dkimListData['domains'] ?? null)) {
$statDkimSelectorTotal = 0;
foreach ($dkimListData['domains'] as $row) {
$statDkimSelectorTotal += (int)($row['selector_count'] ?? 0);
}
}
$statBackupCount = is_array($backupData['backups'] ?? null) ? count($backupData['backups']) : null;
$statAliasCount = is_array($aliasesData['aliases'] ?? null) ? count($aliasesData['aliases']) : null;
$statRecentDomains = [];
if (is_array($domainsData['domains'] ?? null)) {
$tmp = $domainsData['domains'];
usort($tmp, function ($a, $b) { return (int)($b['id'] ?? 0) <=> (int)($a['id'] ?? 0); });
$statRecentDomains = array_slice($tmp, 0, 5);
}
$statAliasCountByDomain = [];
if (is_array($aliasesData['aliases'] ?? null)) {
foreach ($aliasesData['aliases'] as $row) {
$d = (string)($row['domain'] ?? '');
if ($d === '') {
continue;
}
$statAliasCountByDomain[$d] = ($statAliasCountByDomain[$d] ?? 0) + 1;
}
}
$statRecentAuditLines = [];
$statRecentErrors = [];
$statErrorCount = 0;
$oneHourAgo = time() - 3600;
if (is_array($auditData['lines'] ?? null)) {
$tail = array_slice($auditData['lines'], -6);
$tail = array_reverse($tail);
foreach ($tail as $line) {
$decoded = json_decode($line, true);
if (is_array($decoded)) {
$statRecentAuditLines[] = $decoded;
}
}
foreach (array_reverse($auditData['lines']) as $line) {
$decoded = json_decode($line, true);
if (!is_array($decoded)) {
continue;
}
$isError = (($decoded['result'] ?? '') === 'error' || (int)($decoded['status_code'] ?? 200) >= 400);
$ts = strtotime((string)($decoded['ts'] ?? ''));
$withinHour = ($ts !== false && $ts >= $oneHourAgo);
if ($isError && $withinHour) {
$statErrorCount++;
if (count($statRecentErrors) < 5) {
$statRecentErrors[] = $decoded;
}
}
}
}
$statBanCount = 0;
$statBannedJails = [];
if (is_array($fail2banData['jails'] ?? null)) {
foreach ($fail2banData['jails'] as $jailRow) {
$c = (int)($jailRow['currently_banned'] ?? 0);
if ($c > 0) {
$statBanCount += $c;
$statBannedJails[] = $jailRow;
}
}
}
$BACKUP_STALE_DAYS = 7;
$statBackupStale = false;
$statBackupLastAgeDays = null;
if (is_array($backupData['backups'] ?? null)) {
// 경고 계산에는 mailadmin-backup-*.tar.gz 백업 파일만 센다(폴더에 섞인 다른 파일은 무시)
$statBackupFiles = array_values(array_filter($backupData['backups'], function ($bRow) {
return (bool) preg_match('/^mailadmin-backup-.*\.tar\.gz$/', (string)($bRow['file'] ?? ''));
}));
if (empty($statBackupFiles)) {
$statBackupStale = true;
} else {
$latestTs = 0;
foreach ($statBackupFiles as $bRow) {
$t = strtotime((string)($bRow['mtime'] ?? ''));
if ($t !== false && $t > $latestTs) {
$latestTs = $t;
}
}
if ($latestTs > 0) {
$statBackupLastAgeDays = (int) floor((time() - $latestTs) / 86400);
$statBackupStale = $statBackupLastAgeDays >= $BACKUP_STALE_DAYS;
}
}
}
$CERT_WARN_DAYS = 14;
$statCertWarnings = [];
if (is_array($certData['certs'] ?? null)) {
foreach ($certData['certs'] as $certRow) {
if (isset($certRow['days_remaining']) && (int)$certRow['days_remaining'] <= $CERT_WARN_DAYS) {
$statCertWarnings[] = $certRow;
}
}
}
$TEMPMAIL_ABUSE_WARN_THRESHOLD = 10;
$statTempmailAbuseCount1h = is_array($tempmailStatsData) ? (int)($tempmailStatsData['abuse_count_1h'] ?? 0) : 0;
$statTempmailAbuseSpike = $statTempmailAbuseCount1h >= $TEMPMAIL_ABUSE_WARN_THRESHOLD;
$statNotifTotal = $statErrorCount + $statBanCount + ($statBackupStale ? 1 : 0) + count($statCertWarnings) + ($statTempmailAbuseSpike ? 1 : 0);
$flash = get_flash();
$csrf = csrf_token();
$loggedIn = is_logged_in($USE_UI_LOGIN);
$TABS = [
'dashboard' => ['label' => '대시보드', 'icon' => 'grid'],
'domains' => ['label' => '도메인', 'icon' => 'globe'],
'accounts' => ['label' => '계정', 'icon' => 'user'],
'aliases' => ['label' => '별칭 (Alias)', 'icon' => 'at-sign'],
'inactive' => ['label' => '비활성 계정', 'icon' => 'user-x'],
'security' => ['label' => '비밀번호 / 로그인 테스트', 'icon' => 'lock'],
'quota' => ['label' => 'Maildir 용량', 'icon' => 'inbox'],
'dkim' => ['label' => 'DKIM 관리', 'icon' => 'shield'],
'backup' => ['label' => '백업 / 복구', 'icon' => 'archive'],
'logs' => ['label' => '로그', 'icon' => 'list'],
'files' => ['label' => '파일 탐색기', 'icon' => 'folder'],
'svclogs' => ['label' => '서버 로그', 'icon' => 'file-text'],
'connips' => ['label' => '접속 IP', 'icon' => 'globe'],
'seccheck' => ['label' => '보안 점검', 'icon' => 'shield-alert'],
];
if (!array_key_exists($activeTab, $TABS)) {
$activeTab = 'dashboard';
}
?>
<!-- =====================================================================
화면 뼈대 (HTML/CSS)
여기서부터는 PHP 계산은 거의 끝나고, 모아둔 데이터를 HTML로 그리는
부분이다. <style> 안의 CSS는 어두운/밝은 테마, 탭, 표, 카드, 알림창
같은 공통 부품의 모양을 정한다.
===================================================================== -->
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mail Hosting Admin</title>
<style>
:root {
--bg: #f1f4f9;
--panel: #ffffff;
--ink: #101828;
--muted: #667085;
--line: #e4e8ef;
--accent: #4f46e5;
--accent-ink: #ffffff;
--ok: #059669;
--ok-bg: #ecfdf5;
--ok-line: #a7f3d0;
--bad: #dc2626;
--bad-bg: #fef2f2;
--bad-line: #fecaca;
--radius: 14px;
--input-bg: #ffffff;
--input-border: #cfd7e3;
--code-bg: #eef2f7;
--th-bg: #f9fafb;
--pill-bg: #f2f4f8;
--sidebar: #ffffff;
--sidebar-ink: #344054;
--sidebar-active: #eef2ff;
--sidebar-hover: #f2f4f8;
--sidebar-border: #e4e8ef;
--brand-ink: #101828;
--login-bg: #0b1220;
}
body.dark {
--sidebar: #0b1220;
--sidebar-ink: #cbd5e1;
--sidebar-active: #1d2942;
--sidebar-hover: #141d33;
--sidebar-border: transparent;
--brand-ink: #ffffff;
--bg: #0b1220;
--panel: #121a2b;
--ink: #e5e9f0;
--muted: #8a97ab;
--line: #263042;
--accent: #6366f1;
--ok-bg: #052e21;
--ok-line: #0f4a37;
--bad-bg: #3a1414;
--bad-line: #5c1f1f;
--input-bg: #0f1626;
--input-border: #2c384d;
--code-bg: #0f1626;
--th-bg: #0f1626;
--pill-bg: #0f1626;
}
body.dark input[type="text"],
body.dark input[type="email"],
body.dark input[type="password"],
body.dark input[type="number"],
body.dark select,
body.dark textarea {
background: var(--input-bg);
border-color: var(--input-border);
color: var(--ink);
}
body.dark th { background: var(--th-bg); color: #aab4c4; }
body.dark .mono, body.dark pre { background: var(--code-bg); }
body.dark .quick-btn { background: var(--code-bg); color: var(--ink); }
body.dark .conn-pill, body.dark .icon-btn, body.dark .user-pill, body.dark .user-dropdown { background: var(--pill-bg); border-color: var(--line); color: var(--ink); }
body.dark .icon-btn:hover { background: var(--code-bg); }
body.dark .btn-gray { background: var(--code-bg); color: var(--ink); }
body.dark .bar-track { background: var(--code-bg); }
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", Arial, sans-serif;
font-size: 14px;
}
a { color: inherit; }
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at 20% 20%, rgba(79,70,229,0.18), transparent 45%),
var(--login-bg);
}
.login-card {
width: 380px;
background: var(--panel);
border-radius: var(--radius);
padding: 32px;
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
}
.login-card h1 {
font-size: 20px;
margin: 0 0 4px;
}
.login-card .desc { color: var(--muted); margin-bottom: 20px; }
.app {
display: flex;
min-height: 100vh;
background: var(--bg);
}
.sidebar {
width: 232px;
flex: 0 0 232px;
background: var(--sidebar);
color: var(--sidebar-ink);
border-right: 1px solid var(--sidebar-border);
padding: 20px 14px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 20px;
color: var(--brand-ink);
font-weight: 700;
font-size: 16px;
}
.brand .logo {
width: 30px;
height: 30px;
border-radius: 9px;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.nav-group { margin-bottom: 16px; }
.nav-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
padding: 6px 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 9px;
text-decoration: none;
color: var(--sidebar-ink);
font-size: 13.5px;
margin-bottom: 2px;
cursor: pointer;
border: 0;
background: transparent;
width: 100%;
text-align: left;
}
.nav-item:hover { background: var(--sidebar-hover); color: var(--brand-ink); }
.nav-item.active { background: var(--sidebar-active); color: var(--brand-ink); font-weight: 600; }
.nav-item .dot {
width: 6px; height: 6px; border-radius: 50%; background: #475569; flex: 0 0 auto;
}
.nav-item.active .dot { background: var(--accent); }
.main {
flex: 1;
min-width: 0;
padding: 22px 26px 60px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
gap: 12px;
flex-wrap: wrap;
}
.topbar .page-title { font-size: 22px; font-weight: 700; }
.topbar .sub { color: var(--muted); font-size: 12.5px; margin-top: 3px; }
.topbar form button { }
button, .btn {
border: 0;
border-radius: 10px;
padding: 10px 14px;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
display: inline-block;
text-decoration: none;
}
.btn-blue { background: var(--accent); color: #fff; }
.btn-red { background: var(--bad); color: #fff; }
.btn-dark { background: #111827; color: #fff; }
.btn-gray { background: #eef1f6; color: #344054; }
.alert {
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 16px;
white-space: pre-wrap;
word-break: break-word;
font-size: 13.5px;
}
.success { background: var(--ok-bg); border: 1px solid var(--ok-line); color: #065f46; }
.error { background: var(--bad-bg); border: 1px solid var(--bad-line); color: #991b1b; }
.mono {
font-family: Consolas, Monaco, monospace;
font-size: 12.5px;
background: #eef2f7;
border-radius: 6px;
padding: 2px 6px;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.card {
background: var(--panel);
border-radius: var(--radius);
padding: 18px;
box-shadow: 0 1px 2px rgba(16,24,40,0.04), 0 1px 0 rgba(16,24,40,0.04);
border: 1px solid var(--line);
}
.card h2 { margin: 0 0 4px; font-size: 16px; }
.card .desc { font-size: 12.5px; color: var(--muted); margin-bottom: 14px; }
.section { margin-top: 18px; }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.grid-3 { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.stat-row { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-bottom: 18px; }
.stat-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 18px;
}
.stat-card .icon-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.icon-badge {
width: 40px; height: 40px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto;
}
.icon-badge svg { width: 20px; height: 20px; }
.badge-blue { background: #e0e7ff; color: #4f46e5; }
.badge-purple { background: #ede9fe; color: #7c3aed; }
.badge-sky { background: #e0f2fe; color: #0284c7; }
.badge-amber { background: #fef3c7; color: #d97706; }
.badge-green { background: #dcfce7; color: #16a34a; }
.topbar-left { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.conn-pill {
display: inline-flex; align-items: center; gap: 7px;
background: #f2f4f8; border: 1px solid var(--line);
border-radius: 999px; padding: 6px 12px; font-size: 12.5px; color: #344054;
}
.conn-pill .dot2 { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.icon-btn {
width: 36px; height: 36px; border-radius: 10px; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
background: #fff; border: 1px solid var(--line); color: #475467;
cursor: pointer;
}
.icon-btn:hover { background: #f2f4f8; }
.icon-btn svg { width: 34px; height: 34px; flex-shrink: 0; }
.icon-btn, .user-pill, .quick-btn, button { outline: none; }
.icon-btn:focus-visible, .user-pill:focus-visible, .quick-btn:focus-visible, button:focus-visible {
box-shadow: 0 0 0 2px var(--accent);
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.user-menu { position: relative; }
.user-pill {
display: inline-flex; align-items: center; gap: 8px;
background: #fff; border: 1px solid var(--line); border-radius: 10px;
padding: 7px 12px; font-size: 13.5px; font-weight: 600; cursor: pointer;
}
.user-pill .avatar {
width: 22px; height: 22px; border-radius: 50%; background: var(--accent); color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800;
}
.user-dropdown {
display: none;
position: absolute; right: 0; top: calc(100% + 6px);
background: #fff; border: 1px solid var(--line); border-radius: 12px;
box-shadow: 0 12px 32px rgba(16,24,40,0.14);
min-width: 160px; padding: 6px; z-index: 20;
}
.user-dropdown.open { display: block; }
.user-dropdown form button {
width: 100%; text-align: left; background: transparent; color: #b42318;
font-weight: 600; padding: 8px 10px; border-radius: 8px;
}
.user-dropdown form button:hover { background: #fef2f2; }
.quick-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.quick-btn {
display: flex; flex-direction: column; align-items: center; gap: 7px;
background: #f9fafb; border: 1px solid var(--line); border-radius: 12px;
padding: 14px 8px; cursor: pointer; font-size: 12px; font-weight: 700; color: #344054;
}
.quick-btn:hover { background: #f2f4f8; }
.quick-btn .icon-badge { width: 34px; height: 34px; margin-bottom: 0; }
.quick-btn .icon-badge svg { width: 16px; height: 16px; }
/* 서버 로그 카드 전용 - 한 줄에 10개, 기존 quick-btn보다 작게 */
.svclog-grid { grid-template-columns: repeat(10, 1fr); gap: 8px; }
.svclog-grid .quick-btn { padding: 8px 4px; font-size: 10.5px; gap: 4px; }
.svclog-grid .quick-btn .icon-badge { width: 24px; height: 24px; border-radius: 8px; }
.svclog-grid .quick-btn .icon-badge svg { width: 12px; height: 12px; }
.svclog-grid .quick-btn .small { font-size: 9.5px; }
@media (max-width: 1400px) {
.svclog-grid { grid-template-columns: repeat(5, 1fr); }
}
@media (max-width: 700px) {
.svclog-grid { grid-template-columns: repeat(2, 1fr); }
}
.log-mini-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 12.5px; }
.log-mini-row:last-child { border-bottom: 0; }
.log-mini-row .method { font-weight: 800; color: var(--accent); width: 42px; flex: 0 0 auto; }
.log-mini-row .path { flex: 1; font-family: Consolas, Monaco, monospace; color: #344054; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log-mini-row .ts { color: var(--muted); flex: 0 0 auto; font-size: 11.5px; }
.status-dot { display: inline-flex; align-items: center; gap: 6px; }
.status-dot .d { width: 7px; height: 7px; border-radius: 50%; }
.status-dot .d.on { background: var(--ok); }
.status-dot .d.off { background: var(--bad); }
.log-box { overflow: auto; background: var(--code-bg); padding: 12px; border-radius: 10px; }
.log-box-sm { max-height: 220px; }
.log-box-lg { max-height: 520px; }
.log-hl-error { background: #dc2626; color: #fff; padding: 1px 3px; border-radius: 3px; font-weight: 700; }
.log-hl-warn { background: #d97706; color: #fff; padding: 1px 3px; border-radius: 3px; font-weight: 700; }
.log-hl-security { background: #7c3aed; color: #fff; padding: 1px 3px; border-radius: 3px; font-weight: 700; }
@media (max-width: 700px) {
.quick-grid { grid-template-columns: repeat(2, 1fr); }
}
.stat-card .label { color: var(--muted); font-size: 12.5px; margin-bottom: 8px; }
.stat-card .value { font-size: 24px; font-weight: 800; }
.stat-card .foot { margin-top: 6px; font-size: 12px; color: var(--muted); }
.stat-card .foot b { color: var(--ok); }
.stat-card .foot .bad { color: var(--bad); }
label { display: block; font-size: 13px; font-weight: 700; margin-bottom: 6px; }
input[type="text"], input[type="email"], input[type="password"], input[type="number"], select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--input-border);
border-radius: 10px;
margin-bottom: 12px;
font-size: 13.5px;
outline: none;
background: var(--input-bg);
color: var(--ink);
}
input:focus, select:focus { border-color: var(--accent); }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th, td { border-bottom: 1px solid var(--line); text-align: left; padding: 9px 8px; vertical-align: top; }
th { background: var(--th-bg); font-weight: 700; font-size: 12.5px; color: #475467; text-transform: uppercase; letter-spacing: .02em; }
.status-ok { color: var(--ok); font-weight: 700; }
.status-bad { color: var(--bad); font-weight: 700; }
.small { font-size: 12px; color: var(--muted); }
pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
.inline-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 12.5px; }
.bar-row .name { width: 150px; flex: 0 0 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { flex: 1; height: 8px; border-radius: 6px; background: #eef1f6; overflow: hidden; }
.bar-fill { height: 100%; background: var(--accent); border-radius: 6px; }
.bar-row .val { width: 80px; text-align: right; color: var(--muted); flex: 0 0 auto; }
.donut-wrap { display: flex; align-items: center; gap: 20px; }
.donut-legend { font-size: 13px; }
.donut-legend .row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.donut-legend .swatch { width: 10px; height: 10px; border-radius: 3px; }
.tempmail-toggle {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 700;
border: 1px solid var(--line); background: #f2f4f8; color: #667085; cursor: pointer;
}
.tempmail-toggle.on { background: var(--ok-bg); border-color: var(--ok-line); color: #065f46; }
@media (max-width: 900px) {
.app { flex-direction: column; }
.sidebar { width: 100%; flex: none; height: auto; position: static; }
.main { padding: 16px; }
}
</style>
</head>
<body>
<!-- 로그인 화면: is_logged_in() 이 false 일 때만 이 블록이 그려지고,
그 아래 전체 대시보드(else 쪽)는 그려지지 않는다. -->
<?php if (!$loggedIn): ?>
<div class="login-shell">
<div class="login-card">
<h1>Mail Admin 로그인</h1>
<div class="desc">mailapi.py 전용 관리 UI</div>
<?php if ($flash): ?>
<div class="alert <?= h($flash['type']) ?>"><?= h($flash['message']) ?></div>
<?php endif; ?>
<form method="post" autocomplete="off">
<input type="hidden" name="action" value="login">
<label for="ui_username">UI 계정</label>
<input type="text" id="ui_username" name="ui_username" required>
<label for="ui_password">UI 비밀번호</label>
<input type="password" id="ui_password" name="ui_password" required>
<button type="submit" class="btn-blue" style="width:100%;">로그인</button>
</form>
</div>
</div>
<?php else: ?>
<div class="app">
<aside class="sidebar">
<div class="brand">
<span class="logo">M</span>
<span>Mail Admin<div style="font-size:10px; font-weight:400; color:var(--muted);">v1.0.0</div></span>
</div>
<nav>
<div class="nav-group">
<div class="nav-label">개요</div>
<button type="button" class="nav-item <?= $activeTab === 'dashboard' ? 'active' : '' ?>" data-tab="dashboard"><span class="dot"></span>대시보드</button>
</div>
<div class="nav-group">
<div class="nav-label">계정 관리</div>
<button type="button" class="nav-item <?= $activeTab === 'domains' ? 'active' : '' ?>" data-tab="domains"><span class="dot"></span>도메인</button>
<button type="button" class="nav-item <?= $activeTab === 'accounts' ? 'active' : '' ?>" data-tab="accounts"><span class="dot"></span>계정</button>
<button type="button" class="nav-item <?= $activeTab === 'aliases' ? 'active' : '' ?>" data-tab="aliases"><span class="dot"></span>별칭 (Alias)</button>
<button type="button" class="nav-item <?= $activeTab === 'inactive' ? 'active' : '' ?>" data-tab="inactive"><span class="dot"></span>비활성 계정</button>
</div>
<div class="nav-group">
<div class="nav-label">보안 관리</div>
<button type="button" class="nav-item <?= $activeTab === 'security' ? 'active' : '' ?>" data-tab="security"><span class="dot"></span>비밀번호 / 로그인 테스트</button>
</div>
<div class="nav-group">
<div class="nav-label">메일 관리</div>
<button type="button" class="nav-item <?= $activeTab === 'quota' ? 'active' : '' ?>" data-tab="quota"><span class="dot"></span>Maildir 용량</button>
<button type="button" class="nav-item <?= $activeTab === 'dkim' ? 'active' : '' ?>" data-tab="dkim"><span class="dot"></span>DKIM 관리</button>
</div>
<div class="nav-group">
<div class="nav-label">시스템</div>
<button type="button" class="nav-item <?= $activeTab === 'backup' ? 'active' : '' ?>" data-tab="backup"><span class="dot"></span>백업 / 복구</button>
<button type="button" class="nav-item <?= $activeTab === 'logs' ? 'active' : '' ?>" data-tab="logs"><span class="dot"></span>로그</button>
<button type="button" class="nav-item <?= $activeTab === 'files' ? 'active' : '' ?>" data-tab="files"><span class="dot"></span>파일 탐색기</button>
<button type="button" class="nav-item <?= $activeTab === 'svclogs' ? 'active' : '' ?>" data-tab="svclogs"><span class="dot"></span>서버 로그</button>
<button type="button" class="nav-item <?= $activeTab === 'connips' ? 'active' : '' ?>" data-tab="connips"><span class="dot"></span>접속 IP</button>
<button type="button" class="nav-item <?= $activeTab === 'seccheck' ? 'active' : '' ?>" data-tab="seccheck"><span class="dot"></span>보안 점검</button>
</div>
</nav>
</aside>
<main class="main">
<div class="topbar">
<div class="topbar-left">
<div>
<div class="page-title"><?= h($TABS[$activeTab]['label']) ?></div>
<div class="sub">Access: <span class="mono">SSH 터널 전용</span></div>
</div>
<div class="conn-pill">
<span class="dot2"></span>
터널 연결됨 · <span class="mono"><?= h(($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . ($_SERVER['SERVER_PORT'] ?? '8081')) ?></span> · <?= h((new DateTime('now', new DateTimeZone('Asia/Seoul')))->format('Y-m-d H:i')) ?>
</div>
</div>
<div class="topbar-right">
<div class="user-menu">
<button type="button" class="icon-btn" id="notifBtn" title="알림" style="position:relative; cursor:pointer;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<?php if ($statNotifTotal > 0): ?>
<span style="position:absolute; top:-4px; right:-4px; background:#dc2626; color:#fff; font-size:10px; font-weight:800; min-width:18px; height:16px; border-radius:8px; display:flex; align-items:center; justify-content:center; padding:0 3px;"><?= $statNotifTotal > 99 ? '99+' : h((string)$statNotifTotal) ?></span>
<?php endif; ?>
</button>
<div class="user-dropdown" id="notifDropdown" style="min-width:340px; max-height:420px; overflow:auto;">
<?php if ($statNotifTotal === 0): ?>
<div class="small" style="padding:8px 10px;">알림 없음 — 이상 없음</div>
<?php else: ?>
<?php if ($statErrorCount > 0): ?>
<div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">최근 1시간 내 API 에러 (<?= h((string)$statErrorCount) ?>건)</div>
<?php foreach ($statRecentErrors as $errRow): ?>
<div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
<div style="font-weight:700; color:#b42318;"><?= h((string)($errRow['action'] ?? '')) ?> · <?= h((string)($errRow['status_code'] ?? '')) ?></div>
<div class="small" style="margin-top:2px;"><?= h((string)($errRow['detail'] ?? '')) ?></div>
</div>
<?php endforeach; ?>
<div style="padding:4px 10px 8px;"><a href="#" onclick="switchTab('logs'); return false;" class="small">전체 로그 보기 ›</a></div>
<?php endif; ?>
<?php if ($statBanCount > 0): ?>
<div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">fail2ban 차단중 (<?= h((string)$statBanCount) ?>개 IP)</div>
<?php foreach ($statBannedJails as $jailRow): ?>
<div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
<div style="font-weight:700; color:#b45309;"><?= h((string)($jailRow['jail'] ?? '')) ?> — <?= h((string)($jailRow['currently_banned'] ?? '0')) ?>개</div>
<div class="small" style="margin-top:2px;"><?= h(implode(', ', array_slice($jailRow['banned_ips'] ?? [], 0, 5))) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($statBackupStale): ?>
<div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">백업</div>
<div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
<div style="font-weight:700; color:#b45309;">
<?= $statBackupLastAgeDays === null ? '백업이 한 번도 생성되지 않았습니다' : "마지막 백업이 {$statBackupLastAgeDays}일 전" ?>
</div>
<div style="margin-top:4px;"><a href="#" onclick="switchTab('backup'); return false;" class="small">백업 탭으로 ›</a></div>
</div>
<?php endif; ?>
<?php if (!empty($statCertWarnings)): ?>
<div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">인증서 만료 임박</div>
<?php foreach ($statCertWarnings as $certRow): ?>
<div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
<div style="font-weight:700; color:#b42318;"><?= h((string)($certRow['domain'] ?? '')) ?></div>
<div class="small" style="margin-top:2px;">
<?php if (isset($certRow['days_remaining'])): ?>
<?= (int)$certRow['days_remaining'] < 0 ? '이미 만료됨' : ((int)$certRow['days_remaining'] . '일 후 만료') ?>
<?php else: ?>
확인 실패
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($statTempmailAbuseSpike): ?>
<div style="padding:8px 10px 2px; font-weight:800; font-size:11.5px; color:var(--muted); text-transform:uppercase;">임시메일 어뷰징 급증</div>
<div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">
<div style="font-weight:700; color:#b42318;">최근 1시간 <?= h((string)$statTempmailAbuseCount1h) ?>건</div>
<div class="small" style="margin-top:2px;">예약어/한도초과/잘못된 토큰 등 어뷰징 시도가 몰리고 있습니다.</div>
<div style="margin-top:4px;"><a href="#" onclick="switchTab('svclogs'); return false;" class="small">서버 로그 탭으로 ›</a></div>
</div>
<?php endif; ?>
<?php endif; ?>
<div style="padding:8px 10px; border-top:1px solid var(--line); margin-top:4px;">
<a href="#" onclick="switchTab('logs'); return false;" class="small">상세 로그 전체 보기 ›</a>
</div>
</div>
</div>
<button type="button" class="icon-btn" id="themeBtn" title="다크모드 전환" style="cursor:pointer;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path></svg>
</button>
<div class="user-menu">
<button type="button" class="user-pill" id="userMenuBtn">
<span class="avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px; height:13px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
</span>
<?= h($UI_USERNAME) ?>
</button>
<div class="user-dropdown" id="userDropdown">
<form method="post">
<input type="hidden" name="action" value="logout">
<button type="submit">로그아웃</button>
</form>
</div>
</div>
</div>
</div>
<?php if ($flash): ?>
<div class="alert <?= h($flash['type']) ?>"><?= h($flash['message']) ?></div>
<?php endif; ?>
<!-- =====================================================================
탭 화면들
$activeTab 값과 같은 탭의 div만 CSS로 보이고(active 클래스), 나머지는
다 그려지긴 하지만 화면에는 숨겨져 있다(JS 로 탭 전환 시 form 없이도
바로 보이게 하기 위함). 그래서 탭을 눌러도 페이지 전체가 다시 로드될
필요가 없다.
===================================================================== -->
<div class="tab-panel <?= $activeTab === 'dashboard' ? 'active' : '' ?>" id="panel-dashboard">
<div class="grid stat-row">
<div class="stat-card">
<div class="icon-row">
<span class="icon-badge badge-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"></path></svg>
</span>
<div>
<div class="label">도메인</div>
<div class="value"><?= $statDomainCount !== null ? h((string)$statDomainCount) : '—' ?></div>
</div>
</div>
<div class="foot">활성: <b><?= $statDomainCount !== null ? h((string)$statDomainCount) : '—' ?></b></div>
</div>
<div class="stat-card">
<div class="icon-row">
<span class="icon-badge badge-purple">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
</span>
<div>
<div class="label">계정</div>
<div class="value"><?= $statAccountTotal !== null ? h((string)$statAccountTotal) : '—' ?></div>
</div>
</div>
<div class="foot">
<?php if ($statAccountActive !== null): ?>
활성: <b><?= h((string)$statAccountActive) ?></b> · 비활성: <span class="bad"><?= h((string)$statAccountInactive) ?></span>
<?php else: ?>
GET /account/list
<?php endif; ?>
</div>
</div>
<div class="stat-card">
<div class="icon-row">
<span class="icon-badge badge-sky">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M22 6l-10 7L2 6"></path></svg>
</span>
<div>
<div class="label">별칭</div>
<div class="value"><?= $statAliasCount !== null ? h((string)$statAliasCount) : '—' ?></div>
</div>
</div>
<div class="foot">총 별칭 수</div>
</div>
<div class="stat-card">
<div class="icon-row">
<span class="icon-badge badge-amber">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8V21H3V8"></path><path d="M1 3h22v5H1z"></path><path d="M10 12h4"></path></svg>
</span>
<div>
<div class="label">Maildir 용량</div>
<div class="value"><?= $statQuotaTotalBytes !== null ? h(bytes_to_human($statQuotaTotalBytes)) : '—' ?></div>
</div>
</div>
<div class="foot">총 사용량</div>
</div>
<div class="stat-card">
<div class="icon-row">
<span class="icon-badge badge-green">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
</span>
<div>
<div class="label">DKIM Selectors</div>
<div class="value"><?= $statDkimSelectorTotal !== null ? h((string)$statDkimSelectorTotal) : '—' ?></div>
</div>
</div>
<div class="foot">총 Selector 수</div>
</div>
<div class="stat-card">
<div class="icon-row">
<span class="icon-badge badge-amber">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M22 6l-10 7L2 6"></path><path d="M2 4l6 6M22 4l-6 6"></path></svg>
</span>
<div>
<div class="label">임시메일 활성 계정</div>
<div class="value"><?= $tempmailStatsData ? h((string)($tempmailStatsData['active_accounts'] ?? '0')) : '—' ?></div>
</div>
</div>
<div class="foot">
<?php if ($tempmailStatsData): ?>
오늘 생성: <b><?= h((string)($tempmailStatsData['created_today'] ?? '0')) ?></b>
<?php if ($statTempmailAbuseSpike): ?>
· <span class="bad">어뷰징 <?= h((string)$statTempmailAbuseCount1h) ?>건/1시간</span>
<?php endif; ?>
<?php else: ?>
GET /tempmail/stats
<?php endif; ?>
</div>
</div>
</div>
<div class="grid grid-3">
<div class="card">
<h2>빠른 작업</h2>
<div class="desc">자주 쓰는 기능 바로가기</div>
<div class="quick-grid">
<button type="button" class="quick-btn" onclick="switchTab('domains')">
<span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg></span>
도메인 추가
</button>
<button type="button" class="quick-btn" onclick="switchTab('accounts')">
<span class="icon-badge badge-purple"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" y1="8" x2="19" y2="14"></line><line x1="22" y1="11" x2="16" y2="11"></line></svg></span>
계정 추가
</button>
<button type="button" class="quick-btn" onclick="switchTab('security')">
<span class="icon-badge badge-amber"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg></span>
비밀번호 변경
</button>
<button type="button" class="quick-btn" onclick="switchTab('security')">
<span class="icon-badge badge-sky"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg></span>
로그인 테스트
</button>
<button type="button" class="quick-btn" onclick="switchTab('dkim')">
<span class="icon-badge badge-green"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg></span>
DKIM 생성
</button>
<button type="button" class="quick-btn" onclick="switchTab('backup')">
<span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg></span>
백업 생성
</button>
<button type="button" class="quick-btn" onclick="switchTab('aliases')">
<span class="icon-badge badge-sky"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M22 6l-10 7L2 6"></path></svg></span>
별칭 추가
</button>
<button type="button" class="quick-btn" onclick="switchTab('inactive')">
<span class="icon-badge badge-purple"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="17" y1="8" x2="22" y2="13"></line><line x1="22" y1="8" x2="17" y2="13"></line></svg></span>
비활성 계정
</button>
</div>
</div>
<div class="card">
<h2>최근 API 로그</h2>
<div class="desc">
최근 요청
<a href="#" onclick="switchTab('logs'); return false;" class="small" style="float:right;">더보기 ›</a>
</div>
<?php if (!empty($statRecentAuditLines)): ?>
<?php foreach ($statRecentAuditLines as $row): ?>
<div class="log-mini-row">
<span class="method"><?= h((string)($row['method'] ?? '')) ?></span>
<span class="path"><?= h((string)($row['path'] ?? '')) ?></span>
<span class="ts"><?= h(substr((string)($row['ts'] ?? ''), 0, 16)) ?></span>
</div>
<?php endforeach; ?>
<?php else: ?>
<div class="small">표시할 로그가 없습니다.</div>
<?php endif; ?>
</div>
<div class="card">
<h2>서버 상태</h2>
<div class="desc">systemctl is-active 실시간 조회</div>
<?php if ($systemStatusData && isset($systemStatusData['services']) && is_array($systemStatusData['services'])): ?>
<?php foreach ($systemStatusData['services'] as $row): ?>
<div class="log-mini-row">
<span class="path" style="font-family:inherit; text-transform:capitalize;"><?= h((string)($row['service'] ?? '')) ?></span>
<span class="status-dot">
<span class="d <?= !empty($row['active']) ? 'on' : 'off' ?>"></span>
<?= !empty($row['active']) ? '정상' : h((string)($row['state'] ?? '오류')) ?>
</span>
</div>
<?php endforeach; ?>
<div class="log-mini-row">
<span class="path" style="font-family:inherit;">API Server</span>
<span class="status-dot"><span class="d <?= $healthData ? 'on' : 'off' ?>"></span><?= $healthData ? '정상' : '오류' ?></span>
</div>
<?php else: ?>
<pre><?= h(pretty_json($systemStatusErr)) ?></pre>
<?php endif; ?>
</div>
</div>
<div class="section grid grid-2">
<div class="card">
<h2>최근 도메인</h2>
<div class="desc">ID 기준 최근 등록된 도메인 5개<a href="#" onclick="switchTab('domains'); return false;" class="small" style="float:right;">전체 보기 ›</a></div>
<?php if (!empty($statRecentDomains)): ?>
<table>
<thead><tr><th>도메인</th><th>계정 수</th><th>별칭 수</th></tr></thead>
<tbody>
<?php foreach ($statRecentDomains as $row): ?>
<tr>
<td><?= h((string)($row['domain'] ?? '')) ?></td>
<td><?= h((string)($row['user_count'] ?? '0')) ?></td>
<td><?= h((string)($statAliasCountByDomain[(string)($row['domain'] ?? '')] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="small">표시할 도메인이 없습니다.</div>
<?php endif; ?>
</div>
<div class="card">
<h2>Maildir 용량 TOP 10</h2>
<div class="desc">전체 계정 중 사용량이 가장 큰 순<a href="#" onclick="switchTab('quota'); return false;" class="small" style="float:right;">전체 보기 ›</a></div>
<?php if (!empty($statQuotaTop)):
$maxBytes = max(array_map(fn($r) => (int)($r['size_bytes'] ?? 0), $statQuotaTop)) ?: 1;
foreach ($statQuotaTop as $row):
$pct = min(100, round(((int)($row['size_bytes'] ?? 0) / $maxBytes) * 100));
?>
<div class="bar-row">
<div class="name"><?= h((string)($row['email'] ?? '')) ?></div>
<div class="bar-track"><div class="bar-fill" style="width: <?= (int)$pct ?>%;"></div></div>
<div class="val"><?= h((string)($row['size_human'] ?? '')) ?></div>
</div>
<?php endforeach; else: ?>
<div class="small">표시할 데이터가 없습니다.</div>
<?php endif; ?>
</div>
</div>
<div class="section card">
<h2>계정 상태 분포</h2>
<div class="desc">활성 / 비활성 비율</div>
<?php if ($statAccountTotal !== null && $statAccountTotal > 0):
$activePct = round(($statAccountActive / $statAccountTotal) * 100);
?>
<div class="donut-wrap">
<svg width="120" height="120" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="15.9" fill="transparent" stroke="#eef1f6" stroke-width="6"></circle>
<circle cx="21" cy="21" r="15.9" fill="transparent" stroke="#4f46e5" stroke-width="6"
stroke-dasharray="<?= (float)$activePct ?> <?= (float)(100 - $activePct) ?>"
stroke-dashoffset="25" transform="rotate(-90 21 21)"></circle>
<text x="21" y="19" text-anchor="middle" font-size="7" font-weight="700" fill="#101828"><?= h((string)$statAccountTotal) ?></text>
<text x="21" y="26" text-anchor="middle" font-size="3.6" fill="#667085">총 계정</text>
</svg>
<div class="donut-legend">
<div class="row"><span class="swatch" style="background:#4f46e5;"></span>활성 <?= h((string)$statAccountActive) ?>명 (<?= h((string)$activePct) ?>%)</div>
<div class="row"><span class="swatch" style="background:#eef1f6;"></span>비활성 <?= h((string)$statAccountInactive) ?>명 (<?= h((string)(100 - $activePct)) ?>%)</div>
</div>
</div>
<?php else: ?>
<div class="small">표시할 데이터가 없습니다.</div>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'domains' ? 'active' : '' ?>" id="panel-domains">
<div class="grid grid-2">
<div class="card">
<h2>도메인 추가</h2>
<div class="desc">virtual_domains 추가 + /var/mail/vhosts/domain 생성</div>
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="domain_add">
<label for="domain_add">도메인</label>
<input type="text" id="domain_add" name="domain" placeholder="example.com" required>
<button type="submit" class="btn-blue">도메인 추가</button>
</form>
</div>
<div class="card">
<h2>도메인 삭제</h2>
<div class="desc">해당 도메인에 계정이 있으면 API가 409 반환</div>
<form method="post" autocomplete="off" onsubmit="return appConfirm(this, '정말 도메인을 삭제하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="domain_delete">
<label for="domain_del">도메인</label>
<input type="text" id="domain_del" name="domain" placeholder="example.com" required>
<button type="submit" class="btn-red">도메인 삭제</button>
</form>
</div>
</div>
<div class="section card">
<h2>도메인 목록</h2>
<div class="desc">GET /domain/list — 임시메일 열은 클릭 시 즉시 켜기/끄기 (POST /domain/set-tempmail)</div>
<?php if ($domainsData && isset($domainsData['domains']) && is_array($domainsData['domains'])): ?>
<table>
<thead><tr><th>ID</th><th>도메인</th><th>사용자 수</th><th>임시메일</th><th></th></tr></thead>
<tbody>
<?php foreach ($domainsData['domains'] as $row): ?>
<?php $tmEnabled = (int)($row['tempmail_enabled'] ?? 0) === 1; ?>
<tr>
<td><?= h((string)($row['id'] ?? '')) ?></td>
<td><?= h((string)($row['domain'] ?? '')) ?></td>
<td><?= h((string)($row['user_count'] ?? '0')) ?></td>
<td>
<form method="post" style="margin:0;">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="domain_set_tempmail">
<input type="hidden" name="domain" value="<?= h((string)($row['domain'] ?? '')) ?>">
<input type="hidden" name="enabled" value="<?= $tmEnabled ? '0' : '1' ?>">
<button type="submit" class="tempmail-toggle <?= $tmEnabled ? 'on' : '' ?>">
<?= $tmEnabled ? '✓ 허용됨' : '허용 안 함' ?>
</button>
</form>
</td>
<td><a class="small" href="<?= h($_SERVER['PHP_SELF']) . '?tab=accounts&q=&domain=' . urlencode((string)($row['domain'] ?? '')) . '&active=' ?>">이 도메인 계정 보기</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<pre><?= h(pretty_json($domainsErr)) ?></pre>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'accounts' ? 'active' : '' ?>" id="panel-accounts">
<div class="grid grid-2">
<div class="card">
<h2>계정 추가</h2>
<div class="desc">/account/add 호출</div>
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="account_add">
<label for="acc_add_email">이메일</label>
<input type="email" id="acc_add_email" name="email" placeholder="user@example.com" required>
<label for="acc_add_password">비밀번호</label>
<input type="password" id="acc_add_password" name="password" required>
<label for="acc_add_active">활성 여부</label>
<select id="acc_add_active" name="active">
<option value="1" selected>1 (active)</option>
<option value="0">0 (inactive)</option>
</select>
<button type="submit" class="btn-blue">계정 추가</button>
</form>
</div>
<div class="card">
<h2>계정 삭제</h2>
<div class="desc">DB 삭제 + Maildir 제거</div>
<form method="post" autocomplete="off" onsubmit="return appConfirm(this, '정말 계정을 삭제하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="account_delete">
<label for="acc_del_email">이메일</label>
<input type="email" id="acc_del_email" name="email" placeholder="user@example.com" required>
<button type="submit" class="btn-red">계정 삭제</button>
</form>
</div>
</div>
<div class="section card">
<h2>계정 검색 / 도메인 필터</h2>
<div class="desc">이메일 substring / 도메인 / active 필터</div>
<form method="get" autocomplete="off">
<input type="hidden" name="tab" value="accounts">
<div class="grid grid-3">
<div>
<label for="search_q">이메일 검색</label>
<input type="text" id="search_q" name="q" value="<?= h($searchQ) ?>" placeholder="shadow, master, phoenix">
</div>
<div>
<label for="search_domain">도메인</label>
<input type="text" id="search_domain" name="domain" value="<?= h($searchDomain) ?>" placeholder="osmsn.com">
</div>
<div>
<label for="search_active">활성 여부</label>
<select id="search_active" name="active">
<option value="">전체</option>
<option value="1" <?= $searchActive === '1' ? 'selected' : '' ?>>1 (active)</option>
<option value="0" <?= $searchActive === '0' ? 'selected' : '' ?>>0 (inactive)</option>
</select>
</div>
</div>
<div class="inline-actions">
<button type="submit" class="btn-blue">검색</button>
<a href="<?= h($_SERVER['PHP_SELF']) ?>?tab=accounts" class="btn-gray">초기화</a>
</div>
</form>
</div>
<div class="section card">
<h2>계정 목록</h2>
<div class="desc">GET /account/list 또는 /account/search</div>
<?php if ($accountsData && isset($accountsData['accounts']) && is_array($accountsData['accounts'])): ?>
<table>
<thead><tr><th>ID</th><th>이메일</th><th>도메인</th><th>active</th><th>작업</th></tr></thead>
<tbody>
<?php foreach ($accountsData['accounts'] as $row): ?>
<tr>
<td><?= h((string)($row['id'] ?? '')) ?></td>
<td><?= h((string)($row['email'] ?? '')) ?></td>
<td><?= h((string)($row['domain'] ?? '')) ?></td>
<td><?= h((string)($row['active'] ?? '')) ?></td>
<td>
<div class="inline-actions">
<form method="post" onsubmit="return appConfirm(this, 'active 상태를 변경하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="account_set_active">
<input type="hidden" name="email" value="<?= h((string)($row['email'] ?? '')) ?>">
<input type="hidden" name="active" value="<?= ((string)($row['active'] ?? '1') === '1') ? '0' : '1' ?>">
<button type="submit" class="btn-gray"><?= ((string)($row['active'] ?? '1') === '1') ? '비활성화' : '활성화' ?></button>
</form>
<button type="button" class="btn-dark" onclick="fillPassword('<?= h((string)($row['email'] ?? '')) ?>')">비밀번호</button>
<button type="button" class="btn-blue" onclick="fillTestLogin('<?= h((string)($row['email'] ?? '')) ?>')">로그인 테스트</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<pre><?= h(pretty_json($accountsErr)) ?></pre>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'aliases' ? 'active' : '' ?>" id="panel-aliases">
<div class="grid grid-2">
<div class="card">
<h2>별칭 추가</h2>
<div class="desc">/alias/add 호출 — source로 온 메일을 destination으로 전달</div>
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="alias_add">
<label for="alias_source">Source (수신 주소)</label>
<input type="email" id="alias_source" name="source" placeholder="sales@example.com" required>
<label for="alias_destination">Destination (전달 대상)</label>
<input type="email" id="alias_destination" name="destination" placeholder="master@example.com" required>
<label for="alias_active">활성 여부</label>
<select id="alias_active" name="active">
<option value="1" selected>1 (active)</option>
<option value="0">0 (inactive)</option>
</select>
<button type="submit" class="btn-blue">별칭 추가</button>
</form>
</div>
<div class="card">
<h2>안내</h2>
<div class="desc">별칭(alias) 동작 방식</div>
<div class="small">
source 주소로 들어온 메일이 destination 주소로 전달된다.
같은 source에 여러 destination을 등록하면 동시에 여러 곳으로 전달된다 (분배).
source의 도메인은 먼저 도메인 목록에 등록되어 있어야 한다.
</div>
</div>
</div>
<div class="section card">
<h2>별칭 목록</h2>
<div class="desc">GET /alias/list</div>
<?php if ($aliasesData && isset($aliasesData['aliases']) && is_array($aliasesData['aliases'])): ?>
<table>
<thead><tr><th>ID</th><th>Source</th><th>Destination</th><th>도메인</th><th>active</th><th>작업</th></tr></thead>
<tbody>
<?php foreach ($aliasesData['aliases'] as $row): ?>
<tr>
<td><?= h((string)($row['id'] ?? '')) ?></td>
<td><?= h((string)($row['source'] ?? '')) ?></td>
<td><?= h((string)($row['destination'] ?? '')) ?></td>
<td><?= h((string)($row['domain'] ?? '')) ?></td>
<td><?= h((string)($row['active'] ?? '')) ?></td>
<td>
<div class="inline-actions">
<form method="post" onsubmit="return appConfirm(this, 'active 상태를 변경하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="alias_set_active">
<input type="hidden" name="id" value="<?= h((string)($row['id'] ?? '')) ?>">
<input type="hidden" name="active" value="<?= ((string)($row['active'] ?? '1') === '1') ? '0' : '1' ?>">
<button type="submit" class="btn-gray"><?= ((string)($row['active'] ?? '1') === '1') ? '비활성화' : '활성화' ?></button>
</form>
<form method="post" onsubmit="return appConfirm(this, '이 별칭을 삭제하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="alias_delete">
<input type="hidden" name="id" value="<?= h((string)($row['id'] ?? '')) ?>">
<button type="submit" class="btn-red">삭제</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<pre><?= h(pretty_json($aliasesErr)) ?></pre>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'inactive' ? 'active' : '' ?>" id="panel-inactive">
<div class="card">
<h2>비활성 계정</h2>
<div class="desc">GET /account/search?active=0</div>
<?php
$inactiveRows = [];
if (is_array($allAccountsData['accounts'] ?? null)) {
foreach ($allAccountsData['accounts'] as $row) {
if ((int)($row['active'] ?? 1) === 0) {
$inactiveRows[] = $row;
}
}
}
?>
<?php if (!empty($inactiveRows)): ?>
<table>
<thead><tr><th>ID</th><th>이메일</th><th>도메인</th><th>작업</th></tr></thead>
<tbody>
<?php foreach ($inactiveRows as $row): ?>
<tr>
<td><?= h((string)($row['id'] ?? '')) ?></td>
<td><?= h((string)($row['email'] ?? '')) ?></td>
<td><?= h((string)($row['domain'] ?? '')) ?></td>
<td>
<form method="post" onsubmit="return appConfirm(this, '이 계정을 다시 활성화하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="account_set_active">
<input type="hidden" name="email" value="<?= h((string)($row['email'] ?? '')) ?>">
<input type="hidden" name="active" value="1">
<button type="submit" class="btn-blue">활성화</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="small">비활성 계정이 없습니다.</div>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'security' ? 'active' : '' ?>" id="panel-security">
<div class="grid grid-2">
<div class="card">
<h2>비밀번호 변경</h2>
<div class="desc">/account/password 호출</div>
<form method="post" autocomplete="off" id="passwordForm">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="account_password">
<label for="pw_email">이메일</label>
<input type="email" id="pw_email" name="email" placeholder="user@example.com" required>
<label for="pw_new">새 비밀번호</label>
<input type="password" id="pw_new" name="password" required>
<button type="submit" class="btn-dark">비밀번호 변경</button>
</form>
</div>
<div class="card">
<h2>계정 비활성화 → 로그인 차단 테스트</h2>
<div class="desc">실제 doveadm auth test 호출</div>
<form method="post" autocomplete="off" id="testLoginForm">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="account_test_login">
<label for="test_email">이메일</label>
<input type="email" id="test_email" name="email" placeholder="user@example.com" required>
<label for="test_password">테스트 비밀번호</label>
<input type="password" id="test_password" name="password" required>
<button type="submit" class="btn-dark">로그인 테스트</button>
</form>
</div>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'quota' ? 'active' : '' ?>" id="panel-quota">
<div class="card">
<h2>Maildir 용량 표시</h2>
<div class="desc">계정별 실제 Maildir 사용량</div>
<form method="get" autocomplete="off">
<input type="hidden" name="tab" value="quota">
<label for="quota_domain">도메인 필터</label>
<input type="text" id="quota_domain" name="quota_domain" value="<?= h($quotaDomain) ?>" placeholder="osmsn.com">
<div class="inline-actions">
<button type="submit" class="btn-blue">용량 조회</button>
<a href="<?= h($_SERVER['PHP_SELF']) ?>?tab=quota" class="btn-gray">초기화</a>
</div>
</form>
<?php if ($quotaData && isset($quotaData['accounts']) && is_array($quotaData['accounts'])): ?>
<table>
<thead><tr><th>이메일</th><th>도메인</th><th>active</th><th>용량</th><th>Maildir</th></tr></thead>
<tbody>
<?php foreach ($quotaData['accounts'] as $row): ?>
<tr>
<td><?= h((string)($row['email'] ?? '')) ?></td>
<td><?= h((string)($row['domain'] ?? '')) ?></td>
<td><?= h((string)($row['active'] ?? '')) ?></td>
<td><?= h((string)($row['size_human'] ?? '0 B')) ?></td>
<td class="small"><?= h((string)($row['maildir'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<pre><?= h(pretty_json($quotaErr)) ?></pre>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'dkim' ? 'active' : '' ?>" id="panel-dkim">
<div class="card">
<h2>DKIM 관리</h2>
<div class="desc">selector 생성 / TXT 조회</div>
<div class="grid grid-2">
<div>
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="dkim_generate">
<label for="dkim_domain_new">도메인</label>
<input type="text" id="dkim_domain_new" name="domain" placeholder="example.com" required>
<label for="dkim_selector_new">Selector</label>
<input type="text" id="dkim_selector_new" name="selector" value="default" required>
<label for="dkim_bits_new">Key bits</label>
<input type="number" id="dkim_bits_new" name="bits" value="2048" min="1024" step="1024">
<label for="dkim_force_new">덮어쓰기</label>
<select id="dkim_force_new" name="force">
<option value="0" selected>0 (no)</option>
<option value="1">1 (yes)</option>
</select>
<button type="submit" class="btn-blue">DKIM 생성</button>
</form>
</div>
<div>
<form method="get" autocomplete="off">
<input type="hidden" name="tab" value="dkim">
<label for="dkim_domain_view">조회 도메인</label>
<input type="text" id="dkim_domain_view" name="dkim_domain" value="<?= h($dkimViewDomain) ?>" placeholder="example.com">
<label for="dkim_selector_view">조회 selector</label>
<input type="text" id="dkim_selector_view" name="dkim_selector" value="<?= h($dkimViewSelector) ?>" placeholder="default">
<button type="submit" class="btn-dark">TXT 조회</button>
</form>
<?php if ($dkimPublicData): ?>
<pre><?= h(pretty_json($dkimPublicData)) ?></pre>
<?php elseif ($dkimPublicErr): ?>
<pre><?= h(pretty_json($dkimPublicErr)) ?></pre>
<?php endif; ?>
</div>
</div>
<div class="section">
<?php if ($dkimListData && isset($dkimListData['domains']) && is_array($dkimListData['domains'])): ?>
<table>
<thead><tr><th>도메인</th><th>경로</th><th>selector 수</th><th>selectors</th></tr></thead>
<tbody>
<?php foreach ($dkimListData['domains'] as $row): ?>
<tr>
<td><?= h((string)($row['domain'] ?? '')) ?></td>
<td class="small"><?= h((string)($row['path'] ?? '')) ?></td>
<td><?= h((string)($row['selector_count'] ?? '0')) ?></td>
<td class="small">
<?php foreach (($row['selectors'] ?? []) as $selectorRow): ?>
<?= h((string)($selectorRow['selector'] ?? '')) ?><br>
<?php endforeach; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<pre><?= h(pretty_json($dkimListErr)) ?></pre>
<?php endif; ?>
</div>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'backup' ? 'active' : '' ?>" id="panel-backup">
<div class="card">
<h2>백업 / 복구</h2>
<div class="desc">DB + Maildir + DKIM + Postfix/Dovecot 설정 백업</div>
<div class="grid grid-2">
<div>
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="backup_create">
<label for="backup_label">백업 라벨</label>
<input type="text" id="backup_label" name="label" placeholder="before-dkim-change">
<button type="submit" class="btn-blue">백업 생성</button>
</form>
</div>
<div>
<form method="post" autocomplete="off" onsubmit="return appConfirm(this, '정말 복구하시겠습니까? 현재 데이터가 덮어써질 수 있습니다.');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="backup_restore">
<label for="backup_restore_file">복구 파일명</label>
<input type="text" id="backup_restore_file" name="file" placeholder="mailadmin-backup-20260315-120000.tar.gz" required>
<button type="submit" class="btn-red">백업 복구</button>
</form>
</div>
</div>
<div class="section">
<?php if ($backupData && isset($backupData['backups']) && is_array($backupData['backups'])): ?>
<table>
<thead><tr><th>파일</th><th>크기</th><th>수정시각</th></tr></thead>
<tbody>
<?php foreach ($backupData['backups'] as $row): ?>
<tr>
<td class="small"><?= h((string)($row['file'] ?? '')) ?></td>
<td><?= h((string)($row['size_human'] ?? '')) ?></td>
<td class="small"><?= h((string)($row['mtime'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<pre><?= h(pretty_json($backupErr)) ?></pre>
<?php endif; ?>
</div>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'logs' ? 'active' : '' ?>" id="panel-logs">
<div class="card">
<h2>최근 API 감사로그</h2>
<div class="desc">GET /audit/log?lines=50</div>
<?php if ($auditData && isset($auditData['lines']) && is_array($auditData['lines'])): ?>
<pre class="log-box log-box-lg"><?= implode("\n", array_map('highlight_log_line', $auditData['lines'])) ?></pre>
<?php else: ?>
<pre><?= h(pretty_json($auditErr)) ?></pre>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'files' ? 'active' : '' ?>" id="panel-files">
<div class="card">
<h2>파일 탐색기</h2>
<div class="desc">
<b>「허용된 폴더」</b>에 있는 폴더만 열고 변경할 수 있습니다. 시스템 영역은 안 열립니다.
허용된 폴더는 안에서 소유·그룹·권한 변경 가능합니다.
</div>
<div class="card" style="margin:14px 0; background:var(--bg-elevated, transparent);">
<h3 style="margin:0 0 8px;">허용된 폴더</h3>
<?php if ($fmAllowedData && isset($fmAllowedData['allowed'])): ?>
<?php if (empty($fmAllowedData['allowed'])): ?>
<div class="small" style="margin-bottom:10px;">아직 허용한 폴더가 없습니다.</div>
<?php else: ?>
<table style="margin-bottom:10px;">
<thead><tr><th>경로</th><th></th></tr></thead>
<tbody>
<?php foreach ($fmAllowedData['allowed'] as $aPath): ?>
<tr>
<td class="mono small"><?= h((string)$aPath) ?></td>
<td>
<form method="post" style="margin:0;" onsubmit="return appConfirm(this, '이 폴더의 허용을 해제하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="file_allow_remove">
<input type="hidden" name="path" value="<?= h((string)$aPath) ?>">
<button type="submit" class="btn-gray">해제</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (empty($fmAllowedData['password_configured'])): ?>
<div class="alert error">비밀번호가 설정되어 있지 않아 새로 허용할 수 없습니다 (서버에 MAILAPI_FM_PASSWORD 설정 필요).</div>
<?php endif; ?>
<?php else: ?>
<div class="small">허용 목록을 불러오지 못했습니다.</div>
<?php endif; ?>
<form method="post" autocomplete="off" onsubmit="return appConfirm(this, '이 경로를 허용하시겠습니까?');">
<input type="text" name="fm_hp_user" autocomplete="username" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true"><input type="password" name="fm_hp_pass" autocomplete="new-password" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="file_allow">
<div class="grid grid-3">
<div><label for="fa_path">경로 직접 입력해서 허용</label><input type="text" id="fa_path" name="path" placeholder="/etc/nginx" autocomplete="off" spellcheck="false"></div>
<div><label for="fa_password">비밀번호</label><input type="password" id="fa_password" name="password" autocomplete="new-password"></div>
<div style="display:flex; align-items:flex-end;"><button type="submit" class="btn-blue" style="margin-bottom:12px;">허용</button></div>
</div>
</form>
</div>
<div class="inline-actions" style="margin-bottom:14px;">
<?php if ($fmRootsData && isset($fmRootsData['roots']) && is_array($fmRootsData['roots'])): ?>
<?php foreach ($fmRootsData['roots'] as $rootRow): ?>
<a class="btn-gray"
href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode((string)($rootRow['path'] ?? '/')) ?>">
<?= h((string)($rootRow['label'] ?? '')) ?>
</a>
<?php endforeach; ?>
<?php else: ?>
<span class="small">바로가기 목록을 불러오지 못했습니다.</span>
<?php endif; ?>
</div>
<?php if ($fmAllow !== ''): ?>
<?php
$faParent = dirname($fmAllow);
$faParent = ($faParent === '.' || $faParent === '') ? '/' : $faParent;
?>
<div class="small" style="margin-bottom:10px;">
<a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($faParent) ?>">‹ 목록으로</a>
· <span class="mono"><?= h($fmAllow) ?></span>
</div>
<h3 style="margin:6px 0 4px;">이 폴더 허용</h3>
<div class="small" style="margin-bottom:12px;">이 폴더와 그 안의 모든 파일·하위 폴더를 열고 고칠 수 있게 됩니다.</div>
<form method="post" autocomplete="off" onsubmit="return appConfirm(this, '이 폴더를 허용하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="file_allow">
<input type="hidden" name="path" value="<?= h($fmAllow) ?>">
<input type="text" name="fm_hp_user" autocomplete="username" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true"><input type="password" name="fm_hp_pass" autocomplete="new-password" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" tabindex="-1" aria-hidden="true">
<div class="grid grid-3">
<div><label for="fa2_password">비밀번호</label><input type="password" id="fa2_password" name="password" autocomplete="new-password"></div>
<div style="display:flex; align-items:flex-end;">
<button type="submit" class="btn-blue" style="margin-bottom:12px;">허용</button>
<button type="button" class="btn-gray" style="margin-bottom:12px; margin-left:8px;" onclick="location.href=<?= h(json_encode($_SERVER['PHP_SELF'] . '?tab=files&fpath=' . urlencode($faParent))) ?>">취소</button>
</div>
</div>
</form>
<?php elseif ($fmPerm !== ''): ?>
<?php
$fpParent = dirname($fmPerm);
$fpParent = ($fpParent === '.' || $fpParent === '') ? '/' : $fpParent;
?>
<div class="small" style="margin-bottom:10px;">
<a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($fpParent) ?>">‹ 목록으로</a>
· <span class="mono"><?= h($fmPerm) ?></span>
</div>
<?php if ($fmPermData && isset($fmPermData['mode_octal'])):
$fpIsDir = !empty($fmPermData['is_dir']);
$fpMode = (int) octdec((string)$fmPermData['mode_octal']);
$fpOwner = (string)($fmPermData['owner'] ?? '');
$fpGroup = (string)($fmPermData['group'] ?? '');
$fpOwnerOpts = array_values(array_filter((array)($fmPermData['owner_choices'] ?? []), function ($n) use ($fpOwner) { return (string)$n !== $fpOwner; }));
$fpGroupOpts = array_values(array_filter((array)($fmPermData['group_choices'] ?? []), function ($n) use ($fpGroup) { return (string)$n !== $fpGroup; }));
$fpRows = [
['u', 6, '주인 (' . $fpOwner . ')', false],
['g', 3, '같은 그룹 (' . $fpGroup . ')', false],
['o', 0, '나머지 모두 (쓰기는 줄 수 없음)', true],
];
?>
<h3 style="margin:6px 0 4px;">소유·권한 변경 (<?= $fpIsDir ? '폴더' : '파일' ?>)</h3>
<div class="small" style="margin-bottom:12px;">
지금: <span class="mono"><?= h((string)($fmPermData['mode_str'] ?? '')) ?> (<?= h((string)$fmPermData['mode_octal']) ?>)</span>
· 소유 <span class="mono"><?= h($fpOwner) ?>:<?= h($fpGroup) ?></span>
<?php if ($fpIsDir): ?><br>이 폴더 하나만 바뀌고, 안에 있는 파일·폴더는 그대로입니다.<?php endif; ?>
</div>
<?php if (empty($fmPermData['can_change'])): ?>
<div class="alert error"><?= h((string)($fmPermData['reason'] ?? '바꿀 수 없습니다.')) ?></div>
<?php else: ?>
<form method="post" autocomplete="off" onsubmit="return appConfirm(this, '소유·권한을 바꾸시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="file_perm">
<input type="hidden" name="path" value="<?= h($fmPerm) ?>">
<table style="width:auto; margin-bottom:12px;">
<thead><tr><th></th><th>읽기</th><th>쓰기</th><th><?= $fpIsDir ? '들어가기' : '실행' ?></th></tr></thead>
<tbody>
<?php foreach ($fpRows as [$who, $shift, $label, $otherRow]): ?>
<tr>
<td><?= h($label) ?></td>
<td style="text-align:center;"><input type="checkbox" name="pm_<?= $who ?>r" value="1" style="width:18px; height:18px;" <?= ($fpMode & (4 << $shift)) ? 'checked' : '' ?>></td>
<td style="text-align:center;"><input type="checkbox" name="pm_<?= $who ?>w" value="1" style="width:18px; height:18px;" <?= ($fpMode & (2 << $shift)) ? 'checked' : '' ?> <?= $otherRow ? 'disabled' : '' ?>></td>
<td style="text-align:center;"><input type="checkbox" name="pm_<?= $who ?>x" value="1" style="width:18px; height:18px;" <?= ($fpMode & (1 << $shift)) ? 'checked' : '' ?>></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="inline-actions" style="margin-bottom:14px; gap:18px;">
<label class="small">주인
<select name="owner" style="padding:6px 8px; border:1px solid #cfd7e3; border-radius:8px; min-width:160px;">
<option value="<?= h($fpOwner) ?>" selected><?= h($fpOwner) ?> (지금)</option>
<?php foreach ($fpOwnerOpts as $n): ?><option value="<?= h((string)$n) ?>"><?= h((string)$n) ?></option><?php endforeach; ?>
</select>
</label>
<label class="small">그룹
<select name="group" style="padding:6px 8px; border:1px solid #cfd7e3; border-radius:8px; min-width:160px;">
<option value="<?= h($fpGroup) ?>" selected><?= h($fpGroup) ?> (지금)</option>
<?php foreach ($fpGroupOpts as $n): ?><option value="<?= h((string)$n) ?>"><?= h((string)$n) ?></option><?php endforeach; ?>
</select>
</label>
</div>
<div class="inline-actions">
<button type="submit" class="btn-blue">적용</button>
<button type="button" class="btn-gray" onclick="location.href=<?= h(json_encode($_SERVER['PHP_SELF'] . '?tab=files&fpath=' . urlencode($fpParent))) ?>">취소</button>
</div>
</form>
<?php endif; ?>
<?php else: ?>
<?php if (is_array($fmPermErr['json'] ?? null) && isset($fmPermErr['json']['message'])): ?>
<div class="alert error"><?= h((string)$fmPermErr['json']['message']) ?></div>
<?php else: ?>
<pre><?= h(pretty_json($fmPermErr)) ?></pre>
<?php endif; ?>
<?php endif; ?>
<?php elseif ($fmFile !== ''): ?>
<?php
$fmParentDir = dirname($fmFile);
$fmParentDir = ($fmParentDir === '.' || $fmParentDir === '') ? '/' : $fmParentDir;
?>
<div class="small" style="margin-bottom:10px;">
<a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($fmParentDir) ?>">‹ 목록으로</a>
· <span class="mono"><?= h($fmFile) ?></span>
</div>
<?php if ($fmReadErr && is_array($fmReadErr['json'] ?? null) && ($fmReadErr['json']['error'] ?? '') === 'not_allowed'): ?>
<div class="alert error"><?= h((string)$fmReadErr['json']['message']) ?></div>
<a class="btn-blue" style="text-decoration:none; display:inline-block;" href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fallow=' . urlencode($fmParentDir) ?>">이 폴더 허용하기</a>
<?php elseif ($fmReadData && isset($fmReadData['content'])): ?>
<?php if (empty($fmReadData['editable'])): ?>
<div class="alert error" style="margin-bottom:10px;">파일이 3MB보다 커서 미리보기만 가능하다 (수정/저장 불가).</div>
<?php endif; ?>
<form method="post" autocomplete="off" id="fmEditForm" onsubmit="return appConfirm(this, '이 파일을 저장하시겠습니까? 저장 전 원본은 자동으로 백업됩니다.');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="file_write">
<input type="hidden" name="path" value="<?= h($fmFile) ?>">
<textarea name="content" id="fmEditor" spellcheck="false" style="width:100%; min-height:480px; font-family:Consolas,Monaco,monospace; font-size:13px; padding:14px; border:1px solid #cfd7e3; border-radius:10px;" <?= empty($fmReadData['editable']) ? 'readonly' : '' ?>><?= h((string)$fmReadData['content']) ?></textarea>
<div class="inline-actions" style="margin-top:10px;">
<?php if (!empty($fmReadData['editable'])): ?>
<button type="submit" class="btn-blue">저장 (자동 백업 후 덮어쓰기)</button>
<?php endif; ?>
<span class="small">크기: <?= h((string)($fmReadData['size_bytes'] ?? '0')) ?> bytes</span>
</div>
</form>
<?php else: ?>
<pre><?= h(pretty_json($fmReadErr)) ?></pre>
<?php endif; ?>
<?php else: ?>
<?php if ($fmListData && isset($fmListData['entries']) && is_array($fmListData['entries'])):
$fmCurPath = ($fmListData['path'] ?? '') !== '' ? $fmListData['path'] : '/';
?>
<div class="small" style="margin-bottom:10px;">
<span class="mono"><?= h($fmCurPath) ?></span>
<?php if ($fmCurPath !== '/'): ?>
·
<?php $fmUpDir = dirname($fmCurPath); $fmUpDir = ($fmUpDir === '.' || $fmUpDir === '') ? '/' : $fmUpDir; ?>
<a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($fmUpDir) ?>">‹ 상위 폴더</a>
<?php endif; ?>
</div>
<?php if (empty($fmListData['entries'])): ?>
<div class="small">빈 폴더입니다.</div>
<?php else: ?>
<table>
<thead><tr><th>이름</th><th>크기</th><th>수정시각</th><th>소유(주인:그룹)</th><th>권한</th><th>변경</th></tr></thead>
<tbody>
<?php foreach ($fmListData['entries'] as $entryRow):
$entryRel = rtrim($fmCurPath, '/') . '/' . (string)($entryRow['name'] ?? '');
$entryState = (string)($entryRow['state'] ?? ($entryRow['blocked'] ?? false ? 'deny' : 'write'));
$entryDeny = $entryState === 'deny';
$entryLocked = $entryState === 'locked';
?>
<tr<?= ($entryDeny || $entryLocked) ? ' style="opacity:.6;"' : '' ?>>
<td>
<?php if ($entryDeny): ?>
🔒 <span title="시스템 영역 — 절대 허용 불가"><?= h((string)($entryRow['name'] ?? '')) ?></span>
<?php elseif ($entryLocked && !empty($entryRow['is_dir'])): ?>
🔒 <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($entryRel) ?>" title="허용되지 않은 폴더 — 눌러서 안을 볼 수는 있음"><?= h((string)($entryRow['name'] ?? '')) ?></a>
<?php elseif ($entryLocked): ?>
🔒 <span title="허용되지 않은 폴더의 파일"><?= h((string)($entryRow['name'] ?? '')) ?></span>
<?php elseif (!empty($entryRow['is_dir'])): ?>
📁 <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fpath=' . urlencode($entryRel) ?>"><?= h((string)($entryRow['name'] ?? '')) ?></a>
<?php else: ?>
📄 <a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&ffile=' . urlencode($entryRel) ?>"<?= file_name_style((string)($entryRow['name'] ?? '')) ?>><?= h((string)($entryRow['name'] ?? '')) ?></a>
<?php endif; ?>
</td>
<td><?= $entryDeny ? '—' : h((string)($entryRow['size_human'] ?? '')) ?></td>
<td class="small"><?= $entryDeny ? '—' : h((string)($entryRow['mtime'] ?? '')) ?></td>
<?php
$epOwner = h((string)($entryRow['owner'] ?? '?')) . ':' . h((string)($entryRow['group'] ?? '?'));
$epMode = h((string)($entryRow['mode_str'] ?? '')) . ' <span class="small">(' . h((string)($entryRow['mode_octal'] ?? '')) . ')</span>';
$epHref = h($_SERVER['PHP_SELF']) . '?tab=files&fperm=' . urlencode($entryRel);
$epLink = 'style="text-decoration:none; color:inherit; border-bottom:1px dashed var(--muted);" title="눌러서 소유·권한 변경"';
?>
<?php if ($entryDeny): ?>
<td>—</td><td>—</td><td class="small">🔒 접근 불가</td>
<?php elseif ($entryLocked): ?>
<td>—</td><td>—</td>
<td class="small">
🔒 허용 안 됨
<?php if (!empty($entryRow['can_allow'])): ?>
<a href="<?= h($_SERVER['PHP_SELF']) . '?tab=files&fallow=' . urlencode($entryRel) ?>">허용</a>
<?php endif; ?>
</td>
<?php elseif (!empty($entryRow['can_change'])): ?>
<td><a class="mono small" href="<?= $epHref ?>" <?= $epLink ?>><?= $epOwner ?></a></td>
<td><a class="mono small" href="<?= $epHref ?>" <?= $epLink ?>><?= $epMode ?></a></td>
<td class="small" style="color:var(--ok); font-weight:700;">가능</td>
<?php else: ?>
<td><span class="mono small" style="color:var(--muted);"><?= $epOwner ?></span></td>
<td><?= !empty($entryRow['is_symlink']) ? '<span class="small" style="color:var(--muted);">링크</span>' : '<span class="mono small" style="color:var(--muted);">' . $epMode . '</span>' ?></td>
<td class="small" style="color:var(--muted);" title="<?= h((string)($entryRow['reason'] ?? '')) ?>">불가</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php else: ?>
<pre><?= h(pretty_json($fmListErr)) ?></pre>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'svclogs' ? 'active' : '' ?>" id="panel-svclogs">
<div class="card">
<h2>현재 차단(Ban) 상태</h2>
<div class="desc">fail2ban-client 실시간 조회 — 지금 이 순간 차단 중인 IP</div>
<form method="post" autocomplete="off" style="margin-bottom:16px;" onsubmit="return appConfirm(this, '이 IP의 차단을 해제하시겠습니까?');">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="fail2ban_unban">
<div class="grid grid-3">
<div>
<label for="unban_ip">차단 해제할 IP</label>
<input type="text" id="unban_ip" name="unban_ip" placeholder="1.2.3.4">
</div>
<div>
<label for="unban_jail">Jail (비우면 전체에서 시도)</label>
<select id="unban_jail" name="unban_jail">
<option value="">전체</option>
<?php if ($fail2banData && isset($fail2banData['jails']) && is_array($fail2banData['jails'])): ?>
<?php foreach ($fail2banData['jails'] as $jailRow): ?>
<option value="<?= h((string)($jailRow['jail'] ?? '')) ?>"><?= h((string)($jailRow['jail'] ?? '')) ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div style="display:flex; align-items:flex-end;">
<button type="submit" class="btn-blue" style="margin-bottom:12px;">차단 해제</button>
</div>
</div>
</form>
<?php if ($fail2banData && isset($fail2banData['jails']) && is_array($fail2banData['jails'])): ?>
<?php
$totalBanned = 0;
foreach ($fail2banData['jails'] as $jailRow) {
$totalBanned += (int)($jailRow['currently_banned'] ?? 0);
}
?>
<?php if ($totalBanned === 0): ?>
<div class="small">지금 차단 중인 IP가 없습니다.</div>
<?php else: ?>
<details>
<summary style="cursor:pointer; font-weight:700; font-size:13.5px;">
현재 <span style="color:var(--bad);"><?= h((string)$totalBanned) ?>개 IP</span> 차단 중 — 클릭해서 목록 보기
</summary>
<table style="margin-top:10px;">
<thead><tr><th>Jail</th><th style="white-space:nowrap; text-align:right;">차단 수</th><th>IP 목록</th></tr></thead>
<tbody>
<?php foreach ($fail2banData['jails'] as $jailRow): ?>
<?php if ((int)($jailRow['currently_banned'] ?? 0) > 0): ?>
<tr>
<td><b><?= h((string)($jailRow['jail'] ?? '')) ?></b></td>
<td style="text-align:right;"><?= h((string)($jailRow['currently_banned'] ?? '0')) ?></td>
<td class="small mono"><?= h(implode(', ', $jailRow['banned_ips'] ?? [])) ?></td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</details>
<?php endif; ?>
<?php else: ?>
<div class="small">fail2ban 상태를 불러오지 못했습니다.</div>
<?php endif; ?>
</div>
<div class="card section">
<h2>서버 로그</h2>
<div class="desc">메일 서버가 실제로 기록 중인 로그 파일을 원클릭으로 조회 (읽기 전용, 파일당 최근 500줄까지)</div>
<div class="quick-grid svclog-grid">
<?php if ($logSourcesData && isset($logSourcesData['sources']) && is_array($logSourcesData['sources'])): ?>
<?php foreach ($logSourcesData['sources'] as $srcRow): ?>
<?php if (!empty($srcRow['exists'])): ?>
<a class="quick-btn" style="text-decoration:none; <?= $logKey === (string)($srcRow['key'] ?? '') ? 'outline:2px solid var(--accent);' : '' ?>"
href="<?= h($_SERVER['PHP_SELF']) . '?tab=svclogs&logkey=' . urlencode((string)($srcRow['key'] ?? '')) . '&loglines=' . urlencode($logLinesParam) ?>">
<span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></span>
<?= h((string)($srcRow['label'] ?? '')) ?>
<span class="small"><?= h((string)($srcRow['size_human'] ?? '')) ?></span>
</a>
<?php else: ?>
<div class="quick-btn" style="opacity:.4; cursor:not-allowed;" title="파일 없음">
<span class="icon-badge badge-blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg></span>
<?= h((string)($srcRow['label'] ?? '')) ?>
<span class="small">파일 없음</span>
</div>
<?php endif; ?>
<?php endforeach; ?>
<?php else: ?>
<div class="small">로그 목록을 불러오지 못했습니다.</div>
<?php endif; ?>
</div>
<?php if ($logKey !== ''): ?>
<div class="section">
<?php if ($logTailData && isset($logTailData['lines'])): ?>
<div class="small" style="margin-bottom:10px;">
<span class="mono"><?= h((string)($logTailData['path'] ?? '')) ?></span>
· 총 <?= h((string)($logTailData['size_bytes'] ?? '0')) ?> bytes
·
<form method="get" style="display:inline-flex; align-items:center; gap:6px;">
<input type="hidden" name="tab" value="svclogs">
<input type="hidden" name="logkey" value="<?= h($logKey) ?>">
<label for="loglines_sel" class="small" style="margin:0;">줄 수:</label>
<select id="loglines_sel" name="loglines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:4px 8px;">
<?php foreach ([50, 100, 200, 500] as $n): ?>
<option value="<?= $n ?>" <?= (string)$n === $logLinesParam ? 'selected' : '' ?>><?= $n ?>줄</option>
<?php endforeach; ?>
</select>
</form>
·
<a href="<?= h($_SERVER['PHP_SELF']) . '?tab=svclogs&logkey=' . urlencode($logKey) . '&loglines=' . urlencode($logLinesParam) ?>">새로고침</a>
</div>
<pre class="log-box log-box-lg"><?= implode("\n", array_map('highlight_log_line', $logTailData['lines'])) ?></pre>
<?php else: ?>
<pre><?= h(pretty_json($logTailErr)) ?></pre>
<?php endif; ?>
</div>
<?php else: ?>
<div class="small section">위에서 로그를 선택하세요.</div>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'connips' ? 'active' : '' ?>" id="panel-connips">
<div class="card">
<h2>접속 IP → 차단 보내기</h2>
<div class="desc">
차단할 IP를 클릭하고 jail을 선택한 다음 차단을 누르세요.
선택한 jail의 설정대로 차단됩니다. 내부 주소와 SSH로 접속 중인 IP는 🔒 잠깁니다.
</div>
<form method="post" autocomplete="off" style="margin-bottom:16px;" onsubmit="return this.ban_ip.value ? appConfirm(this, '이 IP를 선택한 jail로 보내 차단하시겠습니까?') : false;">
<input type="hidden" name="csrf_token" value="<?= h($csrf) ?>">
<input type="hidden" name="action" value="fail2ban_ban">
<div class="grid grid-3">
<div>
<label for="ban_ip">차단할 IP (아래 표에서 눌러 선택)</label>
<input type="text" id="ban_ip" name="ban_ip" readonly placeholder="표에서 IP를 누르세요">
</div>
<div>
<label for="ban_jail">보낼 Jail</label>
<select id="ban_jail" name="ban_jail">
<?php if ($fail2banData && isset($fail2banData['jails']) && is_array($fail2banData['jails'])): ?>
<?php foreach ($fail2banData['jails'] as $jailRow): ?>
<option value="<?= h((string)($jailRow['jail'] ?? '')) ?>"><?= h((string)($jailRow['jail'] ?? '')) ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div style="display:flex; align-items:flex-end;">
<button type="submit" class="btn-red" style="margin-bottom:12px;">차단</button>
</div>
</div>
</form>
<?php if ($connData && isset($connData['ips']) && is_array($connData['ips'])): ?>
<?php if (empty($connData['ips'])): ?>
<div class="small">지금 접속 중인 외부 IP가 없습니다.</div>
<?php else: ?>
<div class="small" style="margin-bottom:10px;">지금 접속 중인 IP <b><?= h((string)($connData['total'] ?? count($connData['ips']))) ?></b>개 (연결 수 많은 순)</div>
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(230px, 1fr)); gap:8px;">
<?php foreach ($connData['ips'] as $cRow):
$cIp = (string)($cRow['ip'] ?? '');
$cWhy = (string)($cRow['block_reason'] ?? '');
$cBanned = (array)($cRow['banned_in'] ?? []);
$cPorts = array_map('strval', (array)($cRow['ports'] ?? []));
?>
<div style="border:1px solid <?= !empty($cBanned) ? 'var(--bad)' : 'var(--line)' ?>; border-radius:10px; padding:8px 10px; opacity:<?= $cWhy !== '' ? '.7' : '1' ?>; min-width:0;">
<?php if ($cWhy !== ''): ?>
<span class="mono" style="font-weight:700; word-break:break-all;" title="<?= h($cWhy) ?>">🔒 <?= h($cIp) ?></span>
<?php else: ?>
<a class="mono" href="#" title="눌러서 위 칸에 넣기" style="font-weight:700; word-break:break-all; text-decoration:none; border-bottom:1px dashed var(--muted);"
onclick="document.getElementById('ban_ip').value=<?= h(json_encode($cIp)) ?>; document.getElementById('ban_jail').focus(); return false;"><?= h($cIp) ?></a>
<?php endif; ?>
<div class="small">연결 <b><?= h((string)($cRow['count'] ?? '')) ?></b> · 포트 <?= h(implode(', ', array_slice($cPorts, 0, 6))) ?><?= count($cPorts) > 6 ? ' …' : '' ?></div>
<?php if (!empty($cBanned) || $cWhy !== ''): ?>
<div class="small">
<?php if (!empty($cBanned)): ?><span style="color:var(--bad); font-weight:700;">차단 중: <?= h(implode(', ', array_map('strval', $cBanned))) ?></span><?php endif; ?>
<?php if (!empty($cBanned) && $cWhy !== ''): ?> · <?php endif; ?>
<?= h($cWhy) ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php if ((int)($connData['total'] ?? 0) > (int)($connData['shown'] ?? 0)): ?>
<div class="small" style="margin-top:8px;">상위 <?= h((string)$connData['shown']) ?>개만 표시 (전체 <?= h((string)$connData['total']) ?>개)</div>
<?php endif; ?>
<?php endif; ?>
<?php else: ?>
<div class="small">접속 목록을 불러오지 못했습니다<?= is_array($connErr['json'] ?? null) && isset($connErr['json']['message']) ? ': ' . h((string)$connErr['json']['message']) : '' ?>.</div>
<?php endif; ?>
</div>
</div>
<div class="tab-panel <?= $activeTab === 'seccheck' ? 'active' : '' ?>" id="panel-seccheck">
<div class="card">
<h2>보안 점검</h2>
<div class="desc">
탭에 들어올 때마다 실제 서버에서 진단 명령을 실행해 결과를 보여줍니다 (읽기 전용).
각 항목의 힌트를 참고해서 낯선/이상한 게 있는지 눈으로 확인하세요.
</div>
<div class="inline-actions" style="margin-bottom:6px; align-items:center;">
<form method="get" style="display:inline-flex; align-items:center; gap:6px;">
<input type="hidden" name="tab" value="seccheck">
<label for="seclines_sel" class="small" style="margin:0;">항목별 줄 수:</label>
<select id="seclines_sel" name="seclines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:6px 10px;">
<?php foreach ([50, 100, 200, 500] as $n): ?>
<option value="<?= $n ?>" <?= (string)$n === $secLinesParam ? 'selected' : '' ?>><?= $n ?>줄</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn-blue">다시 실행</button>
</form>
</div>
<?php if ($securityChecksData && isset($securityChecksData['checks']) && is_array($securityChecksData['checks'])): ?>
<?php foreach ($securityChecksData['checks'] as $checkRow): ?>
<div class="section" style="border-top:1px solid var(--line); padding-top:14px;">
<div style="font-weight:800; font-size:14px; margin-bottom:2px;"><?= h((string)($checkRow['label'] ?? '')) ?></div>
<div class="small" style="margin-bottom:8px;">⚑ <?= h((string)($checkRow['hint'] ?? '')) ?></div>
<pre class="log-box log-box-lg"><?= implode("\n", array_map('highlight_log_line', $checkRow['lines'] ?? [])) ?></pre>
</div>
<?php endforeach; ?>
<?php else: ?>
<div class="small">보안 점검 결과를 불러오지 못했습니다.</div>
<?php endif; ?>
</div>
</div>
</main>
</div>
<div id="appConfirmOverlay" style="display:none; position:fixed; inset:0; background:rgba(15,20,30,0.55); z-index:9999; align-items:center; justify-content:center; padding:20px;">
<div style="background:var(--panel); color:var(--ink); border-radius:14px; padding:24px; max-width:400px; width:100%; box-shadow:0 24px 64px rgba(0,0,0,0.35); border:1px solid var(--line);">
<div id="appConfirmMsg" style="margin-bottom:22px; font-size:14.5px; line-height:1.55; text-align:center;"></div>
<div style="display:flex; gap:8px; justify-content:center;">
<button type="button" class="btn-gray" onclick="appConfirmCancel()">취소</button>
<button type="button" class="btn-red" onclick="appConfirmOk()">확인</button>
</div>
</div>
</div>
<!-- =====================================================================
화면 동작 스크립트 (JS)
탭 전환, 비밀번호 칸 자동 채움, 확인 창(appConfirm) 같은 화면 안에서만
일어나는 동작들이다. 서버에 진짜로 값을 바꾸는 요청(POST)은 전부 위의
<form> 들이 보내고, 이 스크립트는 그 폼을 채우거나 확인만 받는다.
===================================================================== -->
<script>
function fillPassword(email) {
switchTab('security');
const el = document.getElementById('pw_email');
if (el) {
el.value = email;
setTimeout(function () {
window.scrollTo({ top: document.getElementById('passwordForm').offsetTop - 20, behavior: 'smooth' });
document.getElementById('pw_new').focus();
}, 30);
}
}
function fillTestLogin(email) {
switchTab('security');
const el = document.getElementById('test_email');
if (el) {
el.value = email;
setTimeout(function () {
window.scrollTo({ top: document.getElementById('testLoginForm').offsetTop - 20, behavior: 'smooth' });
document.getElementById('test_password').focus();
}, 30);
}
}
const TABS_NEEDING_RELOAD = ['files', 'svclogs', 'connips', 'seccheck'];
const TAB_LABELS = <?= json_encode(array_map(function ($t) { return $t['label']; }, $TABS), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
let appConfirmCallback = null;
function appConfirm(form, message) {
appConfirmCallback = function (ok) {
if (ok) {
form.submit();
}
};
document.getElementById('appConfirmMsg').textContent = message;
document.getElementById('appConfirmOverlay').style.display = 'flex';
return false;
}
function appConfirmOk() {
document.getElementById('appConfirmOverlay').style.display = 'none';
const cb = appConfirmCallback;
appConfirmCallback = null;
if (cb) cb(true);
}
function appConfirmCancel() {
document.getElementById('appConfirmOverlay').style.display = 'none';
appConfirmCallback = null;
}
document.addEventListener('DOMContentLoaded', function () {
const overlay = document.getElementById('appConfirmOverlay');
if (overlay) {
overlay.addEventListener('click', function (e) {
if (e.target === overlay) {
appConfirmCancel();
}
});
}
});
function switchTab(tab) {
if (TABS_NEEDING_RELOAD.includes(tab)) {
const url = new URL(window.location.href);
url.searchParams.set('tab', tab);
url.searchParams.delete('fpath');
url.searchParams.delete('ffile');
url.searchParams.delete('fperm');
url.searchParams.delete('logkey');
window.location.href = url.toString();
return;
}
document.querySelectorAll('.tab-panel').forEach(function (el) {
el.classList.toggle('active', el.id === 'panel-' + tab);
});
document.querySelectorAll('.nav-item').forEach(function (el) {
el.classList.toggle('active', el.getAttribute('data-tab') === tab);
});
const url = new URL(window.location.href);
url.searchParams.set('tab', tab);
window.history.replaceState({}, '', url);
const titleEl = document.querySelector('.topbar .page-title');
if (titleEl && TAB_LABELS[tab]) {
titleEl.textContent = TAB_LABELS[tab];
}
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.nav-item[data-tab]').forEach(function (btn) {
btn.addEventListener('click', function () {
switchTab(btn.getAttribute('data-tab'));
});
});
const pwNew = document.getElementById('pw_new');
if (pwNew) {
pwNew.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('passwordForm').requestSubmit();
}
});
}
const userMenuBtn = document.getElementById('userMenuBtn');
const userDropdown = document.getElementById('userDropdown');
if (userMenuBtn && userDropdown) {
userMenuBtn.addEventListener('click', function (e) {
e.stopPropagation();
userDropdown.classList.toggle('open');
});
}
const notifBtn = document.getElementById('notifBtn');
const notifDropdown = document.getElementById('notifDropdown');
if (notifBtn && notifDropdown) {
notifBtn.addEventListener('click', function (e) {
e.stopPropagation();
notifDropdown.classList.toggle('open');
});
}
document.addEventListener('click', function () {
if (userDropdown) userDropdown.classList.remove('open');
if (notifDropdown) notifDropdown.classList.remove('open');
});
const themeBtn = document.getElementById('themeBtn');
if (themeBtn) {
const applyTheme = function (isDark) {
document.body.classList.toggle('dark', isDark);
themeBtn.title = isDark ? '라이트모드 전환' : '다크모드 전환';
};
applyTheme(localStorage.getItem('mailadmin-theme') === 'dark');
themeBtn.addEventListener('click', function () {
const next = !document.body.classList.contains('dark');
applyTheme(next);
localStorage.setItem('mailadmin-theme', next ? 'dark' : 'light');
});
}
const fmEditor = document.getElementById('fmEditor');
const fmEditForm = document.getElementById('fmEditForm');
if (fmEditor && !fmEditor.readOnly) {
let fmDirty = false;
const fmInitial = fmEditor.value;
fmEditor.addEventListener('input', function () {
fmDirty = (fmEditor.value !== fmInitial);
});
window.addEventListener('beforeunload', function (e) {
if (fmDirty) {
e.preventDefault();
e.returnValue = '';
}
});
if (fmEditForm) {
fmEditForm.addEventListener('submit', function () {
fmDirty = false;
});
}
}
});
</script>
</body>
</html>
<?php endif; ?>
간단히 구조만 짚어보면:
- 로그인 처리 (
handle_ui_login): 화면 자체의 로그인(아이디·비밀번호)을 확인합니다. API 키와는 별개의 문입니다. - 저장 동작(POST) 처리:
switch ($action)한 곳에서 도메인 추가, 계정 삭제, 파일 저장, IP 차단 같은 모든 “바꾸는 요청"을 나눠서 처리합니다. CSRF 토큰 검사를 반드시 통과해야 합니다. api_request/api_get_json/api_post_json: 뒷단 API 서버(mailapi.py)에 실제로 요청을 보내는 함수입니다. 이 화면의 모든 데이터는 결국 이 함수를 거쳐서 옵니다.- 탭 화면들:
$activeTab값에 따라 대시보드, 도메인, 계정, 파일 탐색기, 접속 IP 같은 탭 하나만 보여주고 나머지는 숨깁니다. - 파일 탐색기의 허용(
file_allow) 동작: 이 화면에서 가장 민감한 기능입니다. 화면(PHP)은 형식만 가볍게 확인하고, 진짜 검사(비밀번호가 맞는지, 경로가 시스템 영역인지)는 전부 API 서버가 다시 합니다.
AI의 도움으로 작성되었습니다.