fix: PHP frontend JWT auth + API endpoints + missing tables + branding

- login.php: Fix JWT response format (success/access_token vs status=ok),
  store JWT tokens in session, support TOTP 202 flow
- api_client.php: Dual auth (JWT Bearer + X-API-Key fallback), add
  auto-refresh on 401, fix API endpoints (checkServerHealth → array,
  getServerLogs, getAuditLogs, getDashboardStats field mapping)
- api_proxy.php: Fix check_all to use /api/servers/check (not per-server),
  fix get_logs to support global endpoint, restart_multisync → restart_nexus
- install.php: Add 4 missing tables (platforms, nodes, ssh_sessions,
  command_logs), fix table creation order for FK dependencies, add JWT
  columns to admins, add new indexes, add platform_id/node_id/protocols/
  extra_attrs/connectivity to servers
- logout.php: Call /api/auth/logout to invalidate JWT refresh token
- index.php: Use JWT Bearer auth for API calls
- server_service.py: Implement real check_all_servers with concurrent
  httpx Agent health checks (was stub)
- servers.py: Add GET /api/servers/logs global sync logs endpoint
- Branding: MultiSync → Nexus across all PHP files (sidebar, titles,
  TOTP issuer, version strings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-21 23:11:30 +08:00
parent 814e11588e
commit 1a27327036
26 changed files with 298 additions and 125 deletions
+16 -1
View File
@@ -13,9 +13,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from server.api.dependencies import get_server_service, get_sync_service, get_db
from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService
from server.domain.models import Server
from server.domain.models import Server, SyncLog
from server.infrastructure.redis.client import get_redis
from server.infrastructure.database.session import AsyncSessionLocal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("nexus.servers")
@@ -221,6 +223,19 @@ async def push_to_servers(
# ── Sync Logs ──
@router.get("/logs", response_model=list)
async def get_all_sync_logs(
limit: int = Query(100, ge=1, le=500),
):
"""Get recent sync logs across all servers (for dashboard charts)"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SyncLog).order_by(SyncLog.started_at.desc()).limit(limit)
)
logs = result.scalars().all()
return [_sync_log_to_dict(log) for log in logs]
@router.get("/{id}/logs", response_model=list)
async def get_server_logs(
id: int,
+30 -9
View File
@@ -48,17 +48,38 @@ class ServerService:
await self.server_repo.update_heartbeat(id, is_online, system_info, agent_version)
async def check_all_servers(self, server_ids: List[int]) -> Dict[int, dict]:
"""Check health of multiple servers via Agent API (no SSH)"""
"""Check health of multiple servers via Agent API (async concurrent)"""
import httpx
results = {}
# TODO: Use httpx to call each Agent's /health endpoint
for id in server_ids:
server = await self.server_repo.get_by_id(id)
if server and server.is_online:
results[id] = {"status": "online", "system_info": json.loads(server.system_info or "{}")}
elif server:
results[id] = {"status": "offline"}
async def _check_one(server: Server) -> tuple:
url = f"http://{server.domain}:{server.agent_port}/health"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
headers = {}
if server.agent_api_key:
headers["X-API-Key"] = server.agent_api_key
resp = await client.get(url, headers=headers)
if resp.status_code == 200:
data = resp.json()
return server.id, {"status": "online", "system_info": data}
return server.id, {"status": "offline", "error": f"HTTP {resp.status_code}"}
except Exception as e:
return server.id, {"status": "offline", "error": str(e)[:200]}
servers = []
for sid in server_ids:
server = await self.server_repo.get_by_id(sid)
if server:
servers.append(server)
else:
results[id] = {"status": "not_found"}
results[sid] = {"status": "not_found"}
if servers:
check_results = await asyncio.gather(*[_check_one(s) for s in servers])
for sid, result in check_results:
results[sid] = result
return results
async def push_to_servers(
+1 -1
View File
@@ -8,7 +8,7 @@ $current_page = basename($_SERVER['SCRIPT_NAME']);
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?? 'MultiSync' ?></title>
<title><?= $pageTitle ?? 'Nexus' ?></title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/css/adminlte.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
+2 -2
View File
@@ -4,8 +4,8 @@ $current_page = basename($_SERVER['SCRIPT_NAME']);
?>
<nav class="sidebar">
<div class="sidebar-header">
<h1><i class="fas fa-sync-alt"></i> MultiSync</h1>
<span class="version">v1.0.0</span>
<h1><i class="fas fa-network-wired"></i> Nexus</h1>
<span class="version">v6.0.0</span>
</div>
<ul class="nav-menu">
<li class="nav-section-title">主菜单</li>
+1 -1
View File
@@ -24,7 +24,7 @@ $sysMenu = [
</nav>
<aside class="main-sidebar sidebar-dark-primary elevation-4">
<a href="index.php" class="brand-link"><i class="fas fa-sync-alt brand-image"></i><span class="brand-text font-weight-light">MultiSync</span></a>
<a href="index.php" class="brand-link"><i class="fas fa-sync-alt brand-image"></i><span class="brand-text font-weight-light">Nexus</span></a>
<div class="sidebar">
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu">
+72 -22
View File
@@ -1,6 +1,7 @@
<?php
/**
* API Client - communicates with FastAPI backend
* Supports both JWT Bearer (admin session) and X-API-Key (agent/service) auth.
*/
require_once __DIR__ . '/config.php';
@@ -8,10 +9,16 @@ require_once __DIR__ . '/config.php';
class ApiClient {
private $baseUrl;
private $apiKey;
private $accessToken;
public function __construct() {
$this->baseUrl = API_BASE_URL;
// Read API key from MySQL first (settings page), fallback to config constant
// JWT token from session (admin login) takes priority
if (session_status() === PHP_SESSION_NONE) session_start();
$this->accessToken = $_SESSION['access_token'] ?? '';
// Fallback: API key from MySQL settings, then config constant
$key = defined('API_KEY') ? API_KEY : '';
try {
require_once __DIR__ . '/db.php';
@@ -25,20 +32,28 @@ class ApiClient {
}
/**
* Make API request
* Make API request — uses JWT Bearer if available, else X-API-Key
*/
public function request($method, $endpoint, $data = null) {
$url = $this->baseUrl . $endpoint;
$headers = [
'Content-Type: application/json',
'Accept: application/json',
];
// JWT Bearer takes priority (admin session), X-API-Key as fallback (agent/service)
if (!empty($this->accessToken)) {
$headers[] = 'Authorization: Bearer ' . $this->accessToken;
} else {
$headers[] = 'X-API-Key: ' . $this->apiKey;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
'X-API-Key: ' . $this->apiKey,
]);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
switch (strtoupper($method)) {
case 'POST':
@@ -67,10 +82,48 @@ class ApiClient {
return ['error' => "Connection error: $error", 'code' => 0];
}
// JWT expired — try refresh
if ($httpCode === 401 && !empty($_SESSION['refresh_token'])) {
$refreshed = $this->_refreshToken();
if ($refreshed) {
return $this->request($method, $endpoint, $data);
}
}
$decoded = json_decode($response, true);
return ['data' => $decoded, 'code' => $httpCode];
}
/**
* Refresh JWT tokens using refresh_token
*/
private function _refreshToken() {
try {
$ch = curl_init($this->baseUrl . '/api/auth/refresh');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['refresh_token' => $_SESSION['refresh_token']]),
]);
$resp = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && ($resp['success'] ?? false)) {
$_SESSION['access_token'] = $resp['access_token'];
$_SESSION['refresh_token'] = $resp['refresh_token'] ?? $_SESSION['refresh_token'];
$_SESSION['token_expires'] = time() + ($resp['expires_in'] ?? 1800);
$this->accessToken = $resp['access_token'];
return true;
}
} catch (Exception $e) {}
// Refresh failed — clear session, force re-login
unset($_SESSION['access_token'], $_SESSION['refresh_token'], $_SESSION['token_expires']);
return false;
}
// --- Servers ---
public function getServers() {
@@ -93,8 +146,9 @@ class ApiClient {
return $this->request('DELETE', "/api/servers/$id");
}
public function checkServerHealth($id) {
return $this->request('POST', "/api/servers/$id/check");
public function checkServerHealth($ids) {
// Backend: POST /api/servers/check with body: [id1, id2, ...]
return $this->request('POST', '/api/servers/check', $ids);
}
// --- Push ---
@@ -106,34 +160,30 @@ class ApiClient {
]);
}
// --- Logs ---
// --- Sync Logs (per-server) ---
public function getAllLogs($limit = 100) {
return $this->request('GET', "/api/servers/logs?limit=$limit");
public function getServerLogs($serverId, $limit = 50) {
return $this->request('GET', "/api/servers/$serverId/logs?limit=$limit");
}
// --- Audit ---
// --- Audit Logs ---
public function getAuditLogs($params = []) {
$query = http_build_query($params);
return $this->request('GET', "/api/servers/audit-logs?$query");
return $this->request('GET', "/api/audit/?$query");
}
// --- Dashboard ---
public function getDashboardStats() {
// SQL COUNT — no need to fetch all 2000 servers
$statsResp = $this->request('GET', '/api/servers/stats');
$stats = $statsResp['data'] ?? [];
$logsResp = $this->getAllLogs(10);
$recentLogs = $logsResp['data'] ?? [];
return [
'total_servers' => $stats['total_servers'] ?? 0,
'online_servers' => $stats['online_servers'] ?? 0,
'offline_servers' => $stats['offline_servers'] ?? 0,
'push_ready' => $stats['push_ready'] ?? 0,
'recent_logs' => is_array($recentLogs) ? $recentLogs : [],
'total_servers' => $stats['total'] ?? 0,
'online_servers' => $stats['online'] ?? 0,
'offline_servers' => $stats['offline'] ?? 0,
'categories' => $stats['categories'] ?? [],
];
}
}
+13 -33
View File
@@ -123,39 +123,13 @@ try {
$result = api()->getServer($id);
break;
case 'check_server':
$id = $_GET['id'] ?? null;
$result = api()->checkServerHealth($id);
$id = intval($_GET['id'] ?? 0);
$result = api()->checkServerHealth([$id]);
break;
case 'check_all':
$servers = api()->getServers()['data'] ?? [];
// Concurrent health checks using curl_multi
$handles = [];
$mh = curl_multi_init();
foreach ($servers as $s) {
$ch = curl_init(API_BASE . '/api/servers/' . $s['id'] . '/health');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
if (API_KEY) curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: ' . API_KEY]);
curl_multi_add_handle($mh, $ch);
$handles[$s['id']] = $ch;
}
// Execute all requests concurrently
do { $status = curl_multi_exec($mh, $active); } while ($status === CURLM_CALL_MULTI_PERFORM);
while ($active && $status === CURLM_OK) {
curl_multi_select($mh, 1);
do { $status = curl_multi_exec($mh, $active); } while ($status === CURLM_CALL_MULTI_PERFORM);
}
// Collect results
$results = [];
foreach ($servers as $s) {
$ch = $handles[$s['id']];
$resp = curl_multi_getcontent($ch);
$data = json_decode($resp, true) ?: ['server_id' => $s['id'], 'is_online' => false];
$results[] = $data;
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
$result = ['data' => $results, 'code' => 200];
$serverIds = array_map(fn($s) => $s['id'], $servers);
$result = api()->checkServerHealth($serverIds);
break;
// --- Push ---
@@ -167,7 +141,13 @@ try {
// --- Logs ---
case 'get_logs':
$limit = (int)($_GET['limit'] ?? 100);
$result = api()->getAllLogs($limit);
$serverId = $_GET['server_id'] ?? null;
if ($serverId) {
$result = api()->getServerLogs(intval($serverId), $limit);
} else {
// Global logs endpoint
$result = api()->request('GET', "/api/servers/logs?limit=$limit");
}
break;
// --- Dashboard ---
@@ -263,8 +243,8 @@ try {
$result = ['code' => 200, 'ip' => $ip];
break;
case 'restart_multisync':
exec('sudo systemctl restart multisync 2>&1', $out, $code);
case 'restart_nexus':
exec('sudo supervisorctl restart nexus 2>&1', $out, $code);
$result = ['code' => $code === 0 ? 200 : 500, 'message' => $code === 0 ? 'Restarted' : implode("\n", $out)];
break;
+1 -1
View File
@@ -19,7 +19,7 @@ $resp = api()->getAuditLogs($params);
$logs = $resp['data']['logs'] ?? [];
$total = $resp['data']['total'] ?? 0;
?>
<?php $pageTitle = '审计日志 - MultiSync';
<?php $pageTitle = '审计日志 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
?>
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
/**
* MultiSync - 鉴权模块
* Nexus - 鉴权模块
* 所有需要登录的页面引入此文件即可
*/
+2 -2
View File
@@ -1,6 +1,6 @@
<?php
/**
* MultiSync - MySQL 数据库操作类
* Nexus - MySQL 数据库操作类
*
* 注:主要 CRUD 已迁移到 Python API (server/api/servers.py)
* 此文件仅保留 deploy.php 所需的 getServers() 和基本服务器操作
@@ -131,7 +131,7 @@ class Database {
function _encryptPw($plain) {
if (empty($plain)) return $plain;
if (!defined('API_KEY') || empty(API_KEY)) {
error_log('MultiSync: SSH password not encrypted — API_KEY not configured');
error_log('Nexus: SSH password not encrypted — API_KEY not configured');
return $plain;
}
$key = hash('sha256', API_KEY, true);
+2 -2
View File
@@ -1,6 +1,6 @@
<?php
/**
* MultiSync - 子服务器部署页面
* Nexus - 子服务器部署页面
* 根据当前网站地址自动生成安装命令
*/
require_once __DIR__ . '/auth.php';
@@ -272,7 +272,7 @@ journalctl -u multisync -f <span class="code-comment"># 查看日志</span><
</p>
<div class="code-block" id="cmdQuick">
<button class="copy-btn" onclick="copyCode('cmdQuick', this)"><i class="fas fa-copy"></i> 复制</button><span class="code-comment"># 一键安装 MultiSync Agent</span>
<button class="copy-btn" onclick="copyCode('cmdQuick', this)"><i class="fas fa-copy"></i> 复制</button><span class="code-comment"># 一键安装 Nexus Agent</span>
curl -fsSL <span class="code-value"><?= htmlspecialchars($installScriptUrl) ?></span> -o /tmp/ms-install.sh && \
bash /tmp/ms-install.sh \
<span class="code-flag">--url</span> <span class="code-value"><?= htmlspecialchars($apiUrl) ?></span> \
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
require_once __DIR__ . '/auth.php';
$path = $_GET['path'] ?? '/www/wwwroot/';
$pageTitle = '文件管理 - MultiSync';
$pageTitle = '文件管理 - Nexus';
require_once __DIR__ . '/_layout_head.php';
?>
<style>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
$pageTitle = '文件管理 - MultiSync';
$pageTitle = '文件管理 - Nexus';
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
+2 -2
View File
@@ -1,6 +1,6 @@
<?php
/**
* MultiSync — 首次使用引导(3 步)
* Nexus — 首次使用引导(3 步)
*/
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/api_client.php';
@@ -18,7 +18,7 @@ $recentLogs = $logResp['data'] ?? [];
$hasPushed = !empty($recentLogs);
$step = $hasPushed ? 3 : ($hasOnline ? 2 : ($hasServers ? 1 : 0));
$pageTitle = '使用引导 - MultiSync';
$pageTitle = '使用引导 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
?>
+10 -2
View File
@@ -5,7 +5,7 @@ require_once __DIR__ . '/api_client.php';
$stats = api()->getDashboardStats();
$logResp = api()->getAllLogs(10);
$recentLogs = $logResp['data'] ?? [];
$pageTitle = '仪表盘 - MultiSync';
$pageTitle = '仪表盘 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
@@ -118,11 +118,19 @@ $pyOnline = $_SESSION['api_online'] ?? false;
<script>
const API_BASE = <?= json_encode(defined('API_BASE_URL') ? API_BASE_URL : 'http://localhost:8600') ?>;
const API_KEY = <?= json_encode(get_api_key()) ?>;
const JWT_TOKEN = <?= json_encode($_SESSION['access_token'] ?? '') ?>;
function apiHeaders() {
const h = {'Content-Type': 'application/json'};
if (JWT_TOKEN) h['Authorization'] = 'Bearer ' + JWT_TOKEN;
else if (API_KEY) h['X-API-Key'] = API_KEY;
return h;
}
// === Push Chart ===
let pushChart = null;
function loadPushStats() {
fetch(API_BASE + '/api/servers/logs?limit=500', { headers: API_KEY ? {'X-API-Key': API_KEY} : {} })
fetch(API_BASE + '/api/servers/logs?limit=500', { headers: apiHeaders() })
.then(r => r.json()).then(data => {
const logs = data.data || data || [];
const cutoff = Date.now() - 7*86400000;
+74 -15
View File
@@ -71,8 +71,41 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
);
// ── Create tables (match SQLAlchemy models exactly) ──
// ── Create tables in dependency order (referenced tables first) ──
$tables = [
"platforms" => "
CREATE TABLE IF NOT EXISTS platforms (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE COMMENT '平台名称: Linux服务器/Windows/MySQL',
category VARCHAR(50) NOT NULL COMMENT '分类: host/database/device',
type VARCHAR(50) NOT NULL COMMENT '类型: linux/windows/mysql/switch',
default_protocols JSON COMMENT '默认协议: [{name:\"ssh\",port:22,primary:true}]',
charset VARCHAR(20) DEFAULT 'utf-8' COMMENT '默认字符集',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"nodes" => "
CREATE TABLE IF NOT EXISTS nodes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '节点名称',
parent_id INT COMMENT '父节点ID',
sort_order INT DEFAULT 0 COMMENT '排序权重',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES nodes(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"admins" => "
CREATE TABLE IF NOT EXISTS admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(255),
totp_secret VARCHAR(64),
totp_enabled TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
jwt_refresh_token VARCHAR(500) COMMENT 'JWT刷新令牌',
jwt_token_expires DATETIME COMMENT '令牌过期时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"servers" => "
CREATE TABLE IF NOT EXISTS servers (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -90,13 +123,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
agent_api_key VARCHAR(255) COMMENT 'Agent API密钥',
description TEXT COMMENT '备注说明',
target_path VARCHAR(500) COMMENT '推送目标路径',
category VARCHAR(100) COMMENT '服务器分类',
category VARCHAR(100) COMMENT '服务器分类(旧字段,保留兼容)',
platform_id INT COMMENT '平台类型ID',
node_id INT COMMENT '所属节点ID',
protocols JSON COMMENT '实例级协议覆盖',
extra_attrs JSON COMMENT '扩展属性',
connectivity VARCHAR(20) DEFAULT 'unknown' COMMENT '连接状态: ok/err/unknown',
is_online TINYINT(1) DEFAULT 0 COMMENT '是否在线',
last_heartbeat DATETIME COMMENT '最后心跳时间',
system_info TEXT COMMENT '系统信息JSON(Agent上报)',
agent_version VARCHAR(20) COMMENT 'Agent版本',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (platform_id) REFERENCES platforms(id),
FOREIGN KEY (node_id) REFERENCES nodes(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"sync_logs" => "
CREATE TABLE IF NOT EXISTS sync_logs (
@@ -119,18 +159,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
finished_at DATETIME,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"admins" => "
CREATE TABLE IF NOT EXISTS admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(255),
totp_secret VARCHAR(64),
totp_enabled TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"login_attempts" => "
CREATE TABLE IF NOT EXISTS login_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -227,6 +255,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
database VARCHAR(100) COMMENT '数据库库名',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"ssh_sessions" => "
CREATE TABLE IF NOT EXISTS ssh_sessions (
id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
server_id INT NOT NULL COMMENT '服务器ID',
admin_id INT COMMENT '操作人ID',
remote_addr VARCHAR(45) COMMENT '客户端IP',
status VARCHAR(20) DEFAULT 'active' COMMENT 'active/closed',
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"command_logs" => "
CREATE TABLE IF NOT EXISTS command_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(36) COMMENT 'SSH会话ID',
server_id INT NOT NULL COMMENT '服务器ID',
admin_id INT COMMENT '操作人ID',
command VARCHAR(2000) NOT NULL COMMENT '执行的命令',
remote_addr VARCHAR(45) COMMENT '客户端IP',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES ssh_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
];
foreach ($tables as $name => $sql) {
@@ -237,8 +290,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$indexes = [
"ALTER TABLE servers ADD INDEX idx_servers_is_online (is_online)",
"ALTER TABLE servers ADD INDEX idx_servers_category (category)",
"ALTER TABLE servers ADD INDEX idx_servers_platform_id (platform_id)",
"ALTER TABLE servers ADD INDEX idx_servers_node_id (node_id)",
"ALTER TABLE sync_logs ADD INDEX idx_sync_logs_srv_start (server_id, started_at)",
"ALTER TABLE login_attempts ADD INDEX idx_login_attempts_username_ip (username, ip_address)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_server_id (server_id)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_admin_id (admin_id)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_srv_time (server_id, created_at)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_session_id (session_id)",
];
foreach ($indexes as $sql) {
try { $pdo->exec($sql); } catch (PDOException $e) {
+27 -8
View File
@@ -1,6 +1,6 @@
<?php
/**
* MultiSync - 登录页面
* Nexus - 登录页面
* 支持: 登录失败次数限制(5次冻结) + Google 身份验证器(TOTP)
*/
session_start();
@@ -169,14 +169,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: ' . $apiKey],
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['username' => $username, 'password' => $password]),
]);
$apiResp = json_decode(curl_exec($ch), true);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (($apiResp['status'] ?? '') === 'ok' || ($apiResp['status'] ?? '') === 'totp_required') {
// FastAPI JWT response: {"success":true, "access_token":"...", ...}
// TOTP required: HTTP 202 with detail message
if ($httpCode === 202 || ($apiResp['reason'] ?? '') === 'totp_required') {
$passwordValid = true;
$admin = ['username' => $username, 'id' => $apiResp['admin_id'] ?? null];
$needTotp = true;
$tempUser = $username;
$_SESSION['totp_pending'] = $username;
$_SESSION['auth_password'] = $password;
$_SESSION['auth_time'] = time();
} elseif (($apiResp['success'] ?? false) === true && isset($apiResp['access_token'])) {
$passwordValid = true;
$admin = [
'username' => $username,
'id' => $apiResp['admin']['id'] ?? null,
];
// Store JWT tokens in session for API calls
$_SESSION['access_token'] = $apiResp['access_token'];
$_SESSION['refresh_token'] = $apiResp['refresh_token'] ?? '';
$_SESSION['token_expires'] = time() + ($apiResp['expires_in'] ?? 1800);
}
} catch (Exception $e) {}
}
@@ -269,7 +288,7 @@ $expiredMsg = ($msg === 'expired') ? '会话已过期,请重新登录' : '';
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - MultiSync</title>
<title>登录 - Nexus</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -495,9 +514,9 @@ $expiredMsg = ($msg === 'expired') ? '会话已过期,请重新登录' : '';
<body>
<div class="login-container">
<div class="login-header">
<div class="logo">MS</div>
<h1>MultiSync</h1>
<p>服务器文件同步管理系统</p>
<div class="logo">N</div>
<h1>Nexus</h1>
<p>服务器运维管理平台</p>
</div>
<div class="login-card">
@@ -566,7 +585,7 @@ $expiredMsg = ($msg === 'expired') ? '会话已过期,请重新登录' : '';
</div>
<div class="login-footer">
MultiSync v1.0.0 |
Nexus v6.0.0 |
<a href="install.php">运行安装向导</a>
</div>
</div>
+22 -1
View File
@@ -1,8 +1,29 @@
<?php
/**
* MultiSync - 退出登录
* Nexus - 退出登录
*/
session_start();
// 通知后端注销 JWT refresh token
if (!empty($_SESSION['refresh_token'])) {
try {
$apiUrl = defined('API_BASE_URL') ? API_BASE_URL : 'http://localhost:8600';
$ch = curl_init("$apiUrl/api/auth/logout");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . ($_SESSION['access_token'] ?? ''),
],
CURLOPT_POSTFIELDS => json_encode(['admin_id' => $_SESSION['admin_id'] ?? 0]),
]);
curl_exec($ch);
curl_close($ch);
} catch (Exception $e) {}
}
session_unset();
session_destroy();
+1 -1
View File
@@ -15,7 +15,7 @@ $logs = $logResp['data'] ?? [];
$serverResp = api()->getServers();
$servers = $serverResp['data'] ?? [];
?>
<?php $pageTitle = '同步日志 - MultiSync';
<?php $pageTitle = '同步日志 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
?>
+1 -1
View File
@@ -42,7 +42,7 @@ $totalPages = max(1, (int)ceil($total / $perPage));
// 状态映射(中文显示)
$statusLabels = ['pending' => '等待重试', 'retrying' => '重试中', 'abandoned' => '已放弃'];
$statusBadges = ['pending' => 'badge-info', 'retrying' => 'badge-warning', 'abandoned' => 'badge-danger'];
$pageTitle = '重试队列 - MultiSync';
$pageTitle = '重试队列 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
?>
+1 -1
View File
@@ -6,7 +6,7 @@ $schedules = $resp['data'] ?? [];
$srvResp = api()->getServers();
$servers = $srvResp['data'] ?? [];
$pageTitle = '定时推送 - MultiSync';
$pageTitle = '定时推送 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
?>
+1 -1
View File
@@ -89,7 +89,7 @@ if ($action === 'list') {
$server = $resp['data'] ?? null;
}
$pageTitle = '服务器管理 - MultiSync';
$pageTitle = '服务器管理 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
?>
+3 -3
View File
@@ -1,6 +1,6 @@
<?php
/**
* MultiSync - 系统设置(含谷歌身份验证器绑定)
* Nexus - 系统设置(含谷歌身份验证器绑定)
*/
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/db.php';
@@ -125,7 +125,7 @@ $setupSecret = $_SESSION['totp_setup_secret'] ?? '';
$setupMode = (!empty($setupSecret)) || (isset($_GET['setup']) && !empty($_SESSION['totp_setup_secret']));
$totpEnabled = !empty($admin['totp_enabled']);
$pageTitle = '系统设置 - MultiSync';
$pageTitle = '系统设置 - Nexus';
require_once __DIR__ . '/_layout_head.php';
require_once __DIR__ . '/_sidebar_adminlte.php';
?>
@@ -917,7 +917,7 @@ loadSessionLifetime();
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
new QRCode(document.getElementById("qrcode"), {
text: <?= json_encode(TOTP::getQRCodeUri($setupSecret, CURRENT_USER, 'MultiSync')) ?>,
text: <?= json_encode(TOTP::getQRCodeUri($setupSecret, CURRENT_USER, 'Nexus')) ?>,
width: 200, height: 200,
correctLevel: QRCode.CorrectLevel.L
});
+4 -4
View File
@@ -1,15 +1,15 @@
<?php
// MultiSync File Manager — powered by Tiny File Manager v2.6
// Nexus File Manager — powered by Tiny File Manager v2.6
// Default Configuration
$CONFIG = '{"lang":"zh","error_reporting":false,"show_hidden":false,"hide_Cols":false,"theme":"light"}';
define('VERSION', '2.6');
define('APP_TITLE', 'MultiSync 文件管理');
define('APP_TITLE', 'Nexus 文件管理');
// --- MultiSync 集成 ---
// --- Nexus 集成 ---
require_once __DIR__ . '/auth.php';
// Auth via MultiSync auth.php(已有 session 保护)
// Auth via Nexus auth.php(已有 session 保护)
$use_auth = false; // 关闭内置 auth,由 auth.php 接管
$auth_users = array();
$readonly_users = array();
+2 -2
View File
@@ -1,6 +1,6 @@
<?php
/**
* MultiSync - TOTP (Google Authenticator) 纯 PHP 实现
* Nexus - TOTP (Google Authenticator) 纯 PHP 实现
* RFC 6238 compliant
*/
@@ -54,7 +54,7 @@ class TOTP {
/**
* 生成 Google Authenticator URI(用于生成二维码)
*/
public static function getQRCodeUri($secret, $email, $issuer = 'MultiSync') {
public static function getQRCodeUri($secret, $email, $issuer = 'Nexus') {
$issuer = rawurlencode($issuer);
$email = rawurlencode($email);
return "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}&digits=6&period=30";
+7 -7
View File
@@ -1,12 +1,12 @@
<?php
// MultiSync
// Nexus
require_once __DIR__ . "/auth.php";
if (file_exists(__DIR__ . '/config.php')) {
include __DIR__ . '/config.php';}
if (!defined('APP_TITLE')) define('APP_TITLE', '文件管理器');
if (!isset($base_url)) $base_url = '';
if (!isset($auth_user)) $auth_user = 'admin'; // MultiSync: 不生效,auth.php 已接管鉴权
if (!isset($auth_pass)) $auth_pass = '$2y$10$m7JT3rdrZ3nK4tKxpJ8z5.pDalU75BZH9nu82zBJkVNhLJgoC6uXm'; // MultiSync: 不生效
if (!isset($auth_user)) $auth_user = 'admin'; // Nexus: 不生效,auth.php 已接管鉴权
if (!isset($auth_pass)) $auth_pass = '$2y$10$m7JT3rdrZ3nK4tKxpJ8z5.pDalU75BZH9nu82zBJkVNhLJgoC6uXm'; // Nexus: 不生效
if (!isset($root_path)) $root_path = $_SERVER['DOCUMENT_ROOT'];
$_doc_root = rtrim(str_replace('\\', '/', realpath($_SERVER['DOCUMENT_ROOT'])), '/');
$_root_real = rtrim(str_replace('\\', '/', realpath($root_path)), '/');
@@ -368,8 +368,8 @@ if ($a !== $b && strpos($a . '/', $b . '/') !== 0) {
http_response_code(400);
die('操作无效');}}
$self = 'http' . (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 's' : '') . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
$is_logged = !empty($_SESSION['logged_in']); // MultiSync: auth.php 已接管鉴权
if (isset($_GET['logout'])) { session_destroy(); header('Location: login.php'); exit; } // MultiSync: 直接跳 login.php
$is_logged = !empty($_SESSION['logged_in']); // Nexus: auth.php 已接管鉴权
if (isset($_GET['logout'])) { session_destroy(); header('Location: login.php'); exit; } // Nexus: 直接跳 login.php
if ($is_logged) fm_trash_clean(7);
if ($is_logged && isset($_POST['ajax_star'])) {
header('Content-Type: application/json');
@@ -806,7 +806,7 @@ exit;}
if (isset($_POST['ajax_logout'])) {
session_destroy();
ob_end_clean();
echo json_encode(['success' => true, 'redirect' => 'login.php']); // MultiSync: logout 跳 login.php
echo json_encode(['success' => true, 'redirect' => 'login.php']); // Nexus: logout 跳 login.php
exit;}
ob_end_clean();}
if ($is_logged && isset($_GET['ajax_trash_list'])) {
@@ -1062,7 +1062,7 @@ if(typeof closeDesktopMenu==='function')closeDesktopMenu();}
<script src="ace/ace.js"></script> <!-- 先尝试 CDN.js失败时自动回退到本地 /js/ace.js-->
</head>
<body class="<?=$is_logged?'logged':''?>">
<?php if (!$is_logged): header('Location: login.php'); exit; // MultiSync 兜底:auth.php 已拦截,此处防止边缘情况 ?>
<?php if (!$is_logged): header('Location: login.php'); exit; // Nexus 兜底:auth.php 已拦截,此处防止边缘情况 ?>
<?php else: ?>
<!-- 主界面 -->
<div class="music-panel" id="musicPanel">