= 7.3;存储使用数据库(SQLite / MySQL),详见 config.php。 * * 目录约定: * web/ 前端静态文件(可整体替换) * data/ 数据目录(数据库文件,持久保存) * * 启动:php -S 0.0.0.0:8080 index.php 或 Apache 站点根指向本目录 */ require_once __DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'repo.php'; define('SESSION_COOKIE', 'rt_session'); define('WEB_DIR', __DIR__ . DIRECTORY_SEPARATOR . 'web'); /* ============================================================ * 输出 / 输入 * ============================================================ */ function json_out($data, $code = 200) { if (!headers_sent()) { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); } echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; } function api_ok($extra = array()) { $payload = array('ok' => true); if (is_array($extra)) { $payload = array_merge($payload, $extra); } json_out($payload, 200); } function api_error($message, $code = 400) { json_out(array('ok' => false, 'error' => $message), $code); } function json_input() { $raw = file_get_contents('php://input'); if ($raw === false || $raw === '') { return array(); } $data = json_decode($raw, true); return is_array($data) ? $data : array(); } function cfg($key, $default = null) { $c = app_config(); return array_key_exists($key, $c) ? $c[$key] : $default; } /** * 站点设置:config.php 提供默认值,数据库中保存的值优先 */ function site_settings() { $defaults = array( 'site_name' => '路由拓扑', 'site_logo' => '', 'allow_registration' => cfg('allow_registration', true) ? '1' : '0', 'session_ttl_days' => (string) max(1, (int) round(((int) cfg('session_ttl', 60 * 60 * 24 * 30)) / 86400)), ); $saved = settings_all(); foreach ($defaults as $k => $v) { if (array_key_exists($k, $saved) && $saved[$k] !== null && $saved[$k] !== '') { $defaults[$k] = $saved[$k]; } } return $defaults; } function session_ttl_seconds() { $days = (int) site_settings()['session_ttl_days']; if ($days < 1) { $days = 30; } return $days * 86400; } function client_ip() { return isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : ''; } function user_agent_str() { $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; return str_cut($ua, 250); } function str_len($s) { $s = (string) $s; return function_exists('mb_strlen') ? mb_strlen($s, 'UTF-8') : strlen($s); } function str_cut($s, $len) { $s = (string) $s; return function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len); } function valid_id($id) { return is_string($id) && preg_match('/^[A-Za-z0-9_\-]{1,64}$/', $id) === 1; } function page_params() { $page = isset($_GET['page']) ? (int) $_GET['page'] : 1; $pageSize = isset($_GET['pageSize']) ? (int) $_GET['pageSize'] : 10; if ($page < 1) { $page = 1; } if ($pageSize < 1) { $pageSize = 10; } if ($pageSize > 100) { $pageSize = 100; } return array($page, $pageSize, ($page - 1) * $pageSize); } /* ============================================================ * 会话 * ============================================================ */ function set_session_cookie($token, $expires) { $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); if (PHP_VERSION_ID >= 70300) { setcookie(SESSION_COOKIE, $token, array( 'expires' => $expires, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', 'secure' => $secure, )); } else { setcookie(SESSION_COOKIE, $token, $expires, '/; samesite=Lax', '', $secure, true); } } function clear_session_cookie() { if (PHP_VERSION_ID >= 70300) { setcookie(SESSION_COOKIE, '', array( 'expires' => time() - 3600, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', )); } else { setcookie(SESSION_COOKIE, '', time() - 3600, '/'); } } function current_user() { static $cached = null; static $done = false; if ($done) { return $cached; } $done = true; $token = isset($_COOKIE[SESSION_COOKIE]) ? $_COOKIE[SESSION_COOKIE] : ''; if ($token === '') { return null; } $s = session_find($token); if (!$s) { return null; } if ((int) $s['expires_at'] < time()) { session_delete($token); return null; } $u = user_find_by_id($s['user_id']); if (!$u || !empty($u['disabled'])) { return null; } $cached = $u; return $cached; } function require_auth() { $u = current_user(); if (!$u) { api_error('未登录或登录已过期', 401); } return $u; } function require_admin() { $u = require_auth(); if (!isset($u['role']) || $u['role'] !== 'admin') { api_error('需要管理员权限', 403); } return $u; } /* ============================================================ * 转换 * ============================================================ */ function public_user($u) { if (!$u) { return null; } return array( 'id' => $u['id'], 'username' => $u['username'], 'role' => isset($u['role']) ? $u['role'] : 'user', 'disabled' => !empty($u['disabled']), 'mustChangePassword' => !empty($u['must_change_password']), 'createdAt' => isset($u['created_at']) ? $u['created_at'] : '', 'lastLoginAt' => isset($u['last_login_at']) ? $u['last_login_at'] : '', 'loginCount' => isset($u['login_count']) ? (int) $u['login_count'] : 0, ); } function log_row_out($r) { return array( 'id' => isset($r['id']) ? (int) $r['id'] : 0, 'userId' => $r['user_id'], 'username' => $r['username'], 'action' => $r['action'], 'target' => $r['target'], 'detail' => $r['detail'], 'ip' => $r['ip'], 'createdAt' => $r['created_at'], ); } function can_write_topo($row, $user) { if (!$row || !$user) { return false; } if ($user['role'] === 'admin') { return true; } return $row['owner_id'] === $user['id']; } function can_read_topo($row, $user) { if (!$row || !$user) { return false; } if ($user['role'] === 'admin') { return true; } if ($row['owner_id'] === $user['id']) { return true; } return $row['visibility'] === 'public'; } function topo_summary($row, $user, $names) { $ownerId = $row['owner_id']; return array( 'id' => $row['id'], 'name' => $row['name'], 'ownerId' => $ownerId, 'ownerName' => isset($names[$ownerId]) ? $names[$ownerId] : '未知', 'visibility' => ($row['visibility'] === 'public') ? 'public' : 'private', 'createdAt' => isset($row['created_at']) ? $row['created_at'] : '', 'updatedAt' => isset($row['updated_at']) ? $row['updated_at'] : '', 'nodeCount' => (int) $row['node_count'], 'edgeCount' => (int) $row['edge_count'], 'canEdit' => can_write_topo($row, $user), ); } /* ============================================================ * 认证接口 * ============================================================ */ function api_login($in) { $username = isset($in['username']) ? trim((string) $in['username']) : ''; $password = isset($in['password']) ? (string) $in['password'] : ''; if ($username === '' || $password === '') { api_error('请输入用户名和密码'); } $u = user_find_by_name($username); if (!$u || !password_verify($password, $u['password_hash'])) { api_error('用户名或密码错误', 401); } if (!empty($u['disabled'])) { api_error('该账号已被禁用', 403); } $ttl = session_ttl_seconds(); $token = bin2hex(random_bytes(32)); $expires = time() + $ttl; session_purge_expired(time()); session_create($token, $u['id'], $expires, client_ip(), user_agent_str()); user_touch_login($u['id']); log_add($u['id'], $u['username'], 'login', '', '登录成功', client_ip()); set_session_cookie($token, $expires); api_ok(array('user' => public_user(user_find_by_id($u['id'])))); } /** * 密码强度校验(不满足时返回错误文案,满足返回 null) */ function weak_password($pw, $username) { $pw = (string) $pw; if (strlen($pw) < 8) { return '密码至少 8 位'; } if (!preg_match('/[A-Za-z]/', $pw) || !preg_match('/[0-9]/', $pw)) { return '密码必须同时包含字母和数字'; } if ($username !== null && $username !== '' && strcasecmp($pw, (string) $username) === 0) { return '密码不能与用户名相同'; } return null; } function api_register($in) { if (site_settings()['allow_registration'] !== '1') { api_error('当前未开放注册,请联系管理员开通账号', 403); } $username = isset($in['username']) ? trim((string) $in['username']) : ''; $password = isset($in['password']) ? (string) $in['password'] : ''; if ($username === '' || $password === '') { api_error('用户名和密码不能为空'); } if (!preg_match('/^[A-Za-z0-9_.\-]{2,32}$/', $username)) { api_error('用户名需为 2-32 位字母、数字、_ . -'); } $weak = weak_password($password, $username); if ($weak !== null) { api_error($weak); } if (user_find_by_name($username)) { api_error('用户名已存在', 409); } $id = gen_id('u'); user_insert(array( 'id' => $id, 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'role' => 'user', 'disabled' => 0, 'must_change_password' => 0, 'created_at' => now_iso(), )); $ttl = session_ttl_seconds(); $token = bin2hex(random_bytes(32)); $expires = time() + $ttl; session_create($token, $id, $expires, client_ip(), user_agent_str()); user_touch_login($id); log_add($id, $username, 'register', '', '注册账号', client_ip()); set_session_cookie($token, $expires); api_ok(array('user' => public_user(user_find_by_id($id)))); } function api_logout() { $u = current_user(); $token = isset($_COOKIE[SESSION_COOKIE]) ? $_COOKIE[SESSION_COOKIE] : ''; if ($token !== '') { session_delete($token); } if ($u) { log_add($u['id'], $u['username'], 'logout', '', '退出登录', client_ip()); } clear_session_cookie(); api_ok(array('user' => null)); } /* 公开:站点基本信息(名称 / Logo / 是否开放注册) */ function api_site() { $s = site_settings(); api_ok(array( 'site' => array( 'name' => $s['site_name'], 'logo' => $s['site_logo'], 'allowRegistration' => ($s['allow_registration'] === '1'), ) )); } function api_change_password($user, $in) { $old = isset($in['oldPassword']) ? (string) $in['oldPassword'] : ''; $new = isset($in['newPassword']) ? (string) $in['newPassword'] : ''; if (!password_verify($old, $user['password_hash'])) { api_error('原密码不正确', 403); } $weak = weak_password($new, $user['username']); if ($weak !== null) { api_error($weak); } if (password_verify($new, $user['password_hash'])) { api_error('新密码不能与原密码相同'); } user_update($user['id'], array( 'password_hash' => password_hash($new, PASSWORD_DEFAULT), 'must_change_password' => 0, )); log_add($user['id'], $user['username'], 'password_change', '', '修改密码', client_ip()); api_ok(array('user' => public_user(user_find_by_id($user['id'])))); } /* ============================================================ * 用户管理接口(管理员) * ============================================================ */ function admin_count() { return (int) db_val("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0"); } function api_users_list() { list($page, $pageSize, $offset) = page_params(); $q = isset($_GET['q']) ? trim((string) $_GET['q']) : ''; $total = user_count($q); $rows = user_search($q, $pageSize, $offset); $list = array(); foreach ($rows as $u) { $list[] = public_user($u); } api_ok(array('users' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize)); } function api_user_create($admin, $in) { $username = isset($in['username']) ? trim((string) $in['username']) : ''; $password = isset($in['password']) ? (string) $in['password'] : ''; $role = (isset($in['role']) && $in['role'] === 'admin') ? 'admin' : 'user'; if ($username === '' || $password === '') { api_error('用户名和密码不能为空'); } if (!preg_match('/^[A-Za-z0-9_.\-]{2,32}$/', $username)) { api_error('用户名需为 2-32 位字母、数字、_ . -'); } $weak = weak_password($password, $username); if ($weak !== null) { api_error($weak); } if (user_find_by_name($username)) { api_error('用户名已存在', 409); } $id = gen_id('u'); user_insert(array( 'id' => $id, 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'role' => $role, 'disabled' => 0, 'must_change_password' => 1, 'created_at' => now_iso(), )); log_add($admin['id'], $admin['username'], 'user_create', $id, '创建用户 ' . $username, client_ip()); api_ok(array('user' => public_user(user_find_by_id($id)))); } function api_user_update($admin, $id, $in) { $u = user_find_by_id($id); if (!$u) { api_error('用户不存在', 404); } $fields = array(); $changes = array(); if (isset($in['role'])) { $newRole = ($in['role'] === 'admin') ? 'admin' : 'user'; if ($u['role'] === 'admin' && $newRole !== 'admin' && admin_count() <= 1) { api_error('至少保留一名管理员'); } $fields['role'] = $newRole; $changes[] = '角色改为' . ($newRole === 'admin' ? '管理员' : '普通用户'); } if (isset($in['disabled'])) { $disabled = !empty($in['disabled']) ? 1 : 0; if ($disabled) { if ($u['id'] === $admin['id']) { api_error('不能禁用当前登录账号'); } if ($u['role'] === 'admin' && admin_count() <= 1) { api_error('至少保留一名可用管理员'); } } $fields['disabled'] = $disabled; $changes[] = $disabled ? '禁用账号' : '启用账号'; } if ($fields) { user_update($id, $fields); log_add($admin['id'], $admin['username'], 'user_update', $id, '更新用户 ' . $u['username'] . ':' . implode('、', $changes), client_ip()); } api_ok(array('user' => public_user(user_find_by_id($id)))); } /** * 重置指定用户的密码:需先验证当前管理员本人的密码 */ function api_user_reset_password($admin, $id, $in) { $u = user_find_by_id($id); if (!$u) { api_error('用户不存在', 404); } $adminPw = isset($in['adminPassword']) ? (string) $in['adminPassword'] : ''; $newPw = isset($in['newPassword']) ? (string) $in['newPassword'] : ''; if (!password_verify($adminPw, $admin['password_hash'])) { api_error('管理员密码不正确', 403); } $weak = weak_password($newPw, $u['username']); if ($weak !== null) { api_error($weak); } if (password_verify($newPw, $u['password_hash'])) { api_error('新密码不能与原密码相同'); } user_update($id, array( 'password_hash' => password_hash($newPw, PASSWORD_DEFAULT), 'must_change_password' => 1, )); log_add($admin['id'], $admin['username'], 'user_reset_password', $id, '重置用户密码 ' . $u['username'], client_ip()); api_ok(array('user' => public_user(user_find_by_id($id)))); } function api_user_delete($admin, $id) { $u = user_find_by_id($id); if (!$u) { api_error('用户不存在', 404); } if ($u['id'] === $admin['id']) { api_error('不能删除当前登录账号'); } if ($u['role'] === 'admin' && admin_count() <= 1) { api_error('至少保留一名管理员'); } user_delete($id); log_add($admin['id'], $admin['username'], 'user_delete', $id, '删除用户 ' . $u['username'], client_ip()); api_ok(); } /* ============================================================ * 拓扑接口 * ============================================================ */ function api_topo_list($user) { $rows = topo_list_for_user($user); $names = user_name_map(); $out = array(); foreach ($rows as $r) { $out[] = topo_summary($r, $user, $names); } api_ok(array('topologies' => $out)); } function api_topo_create($user, $in) { $name = isset($in['name']) ? trim((string) $in['name']) : ''; if ($name === '') { $name = '未命名拓扑'; } if (str_len($name) > 60) { $name = str_cut($name, 60); } $nodes = (isset($in['nodes']) && is_array($in['nodes'])) ? $in['nodes'] : array(); $edges = (isset($in['edges']) && is_array($in['edges'])) ? $in['edges'] : array(); $id = gen_id('t'); $now = now_iso(); topo_insert(array( 'id' => $id, 'name' => $name, 'owner_id' => $user['id'], 'visibility' => 'private', 'data' => json_encode(array('nodes' => $nodes, 'edges' => $edges), JSON_UNESCAPED_UNICODE), 'node_count' => count($nodes), 'edge_count' => count($edges), 'created_at' => $now, 'updated_at' => $now, )); log_add($user['id'], $user['username'], 'topo_create', $id, '创建拓扑 ' . $name, client_ip()); api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map()))); } function api_topo_get($user, $id) { if (!valid_id($id)) { api_error('非法的拓扑 ID'); } $row = topo_find($id); if (!$row) { api_error('拓扑不存在', 404); } if (!can_read_topo($row, $user)) { api_error('无权访问该拓扑', 403); } $content = json_decode($row['data'], true); if (!is_array($content)) { $content = array(); } api_ok(array( 'topology' => topo_summary($row, $user, user_name_map()), 'data' => array( 'nodes' => (isset($content['nodes']) && is_array($content['nodes'])) ? $content['nodes'] : array(), 'edges' => (isset($content['edges']) && is_array($content['edges'])) ? $content['edges'] : array(), ), )); } function api_topo_save($user, $id, $in) { if (!valid_id($id)) { api_error('非法的拓扑 ID'); } $row = topo_find($id); if (!$row) { api_error('拓扑不存在', 404); } if (!can_write_topo($row, $user)) { api_error('无权修改该拓扑', 403); } $nodes = (isset($in['nodes']) && is_array($in['nodes'])) ? $in['nodes'] : array(); $edges = (isset($in['edges']) && is_array($in['edges'])) ? $in['edges'] : array(); $name = null; if (isset($in['name']) && trim((string) $in['name']) !== '') { $name = str_cut(trim((string) $in['name']), 60); } topo_update( $id, json_encode(array('nodes' => $nodes, 'edges' => $edges), JSON_UNESCAPED_UNICODE), count($nodes), count($edges), now_iso(), $name ); api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map()))); } function api_topo_delete($user, $id) { if (!valid_id($id)) { api_error('非法的拓扑 ID'); } $row = topo_find($id); if (!$row) { api_error('拓扑不存在', 404); } if (!can_write_topo($row, $user)) { api_error('无权删除该拓扑', 403); } topo_delete($id); log_add($user['id'], $user['username'], 'topo_delete', $id, '删除拓扑 ' . $row['name'], client_ip()); api_ok(); } function api_topo_permission($user, $id, $in) { if (!valid_id($id)) { api_error('非法的拓扑 ID'); } $row = topo_find($id); if (!$row) { api_error('拓扑不存在', 404); } if (!can_write_topo($row, $user)) { api_error('无权修改该拓扑', 403); } $vis = (isset($in['visibility']) && $in['visibility'] === 'public') ? 'public' : 'private'; topo_set_visibility($id, $vis, now_iso()); log_add($user['id'], $user['username'], 'topo_permission', $id, '拓扑可见性设为' . ($vis === 'public' ? '公开' : '私有') . ':' . $row['name'], client_ip()); api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map()))); } function api_topo_rename($user, $id, $in) { if (!valid_id($id)) { api_error('非法的拓扑 ID'); } $row = topo_find($id); if (!$row) { api_error('拓扑不存在', 404); } if (!can_write_topo($row, $user)) { api_error('无权修改该拓扑', 403); } $name = isset($in['name']) ? trim((string) $in['name']) : ''; if ($name === '') { api_error('拓扑名称不能为空'); } if (str_len($name) > 60) { $name = str_cut($name, 60); } topo_set_name($id, $name, now_iso()); log_add($user['id'], $user['username'], 'topo_rename', $id, '重命名拓扑 ' . $row['name'] . ' → ' . $name, client_ip()); api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map()))); } function api_admin_topologies() { list($page, $pageSize, $offset) = page_params(); $q = isset($_GET['q']) ? trim((string) $_GET['q']) : ''; $owner = isset($_GET['owner']) ? trim((string) $_GET['owner']) : ''; $total = topo_admin_count($q, $owner); $rows = topo_admin_search($q, $owner, $pageSize, $offset); $user = current_user(); $names = user_name_map(); $list = array(); foreach ($rows as $r) { $list[] = topo_summary($r, $user, $names); } api_ok(array('topologies' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize)); } /* 导出全部或指定的拓扑(含完整节点/连线数据),仅管理员可用 */ function api_admin_topologies_export() { $idsParam = isset($_GET['ids']) ? trim((string) $_GET['ids']) : ''; $ids = array(); if ($idsParam !== '') { foreach (explode(',', $idsParam) as $one) { $one = trim($one); if ($one !== '' && valid_id($one)) { $ids[] = $one; } } if (!$ids) { api_error('未提供有效的拓扑 ID'); } } $rows = topo_export_rows($ids); $names = user_name_map(); $list = array(); foreach ($rows as $r) { $content = json_decode($r['data'], true); if (!is_array($content)) { $content = array(); } $list[] = array( 'id' => $r['id'], 'name' => $r['name'], 'ownerId' => $r['owner_id'], 'ownerName' => isset($names[$r['owner_id']]) ? $names[$r['owner_id']] : '未知', 'visibility' => ($r['visibility'] === 'public') ? 'public' : 'private', 'createdAt' => isset($r['created_at']) ? $r['created_at'] : '', 'updatedAt' => isset($r['updated_at']) ? $r['updated_at'] : '', 'nodeCount' => (int) $r['node_count'], 'edgeCount' => (int) $r['edge_count'], 'nodes' => (isset($content['nodes']) && is_array($content['nodes'])) ? $content['nodes'] : array(), 'edges' => (isset($content['edges']) && is_array($content['edges'])) ? $content['edges'] : array(), ); } api_ok(array('topologies' => $list, 'total' => count($list), 'exportedAt' => now_iso())); } /* 站点设置:读取(管理员) */ function api_admin_settings_get() { $s = site_settings(); api_ok(array( 'settings' => array( 'site_name' => $s['site_name'], 'site_logo' => $s['site_logo'], 'allow_registration' => ($s['allow_registration'] === '1'), 'session_ttl_days' => (int) $s['session_ttl_days'], ) )); } /* 站点设置:保存(管理员) */ function api_admin_settings_update($user, $in) { $changes = array(); if (isset($in['site_name'])) { $name = trim((string) $in['site_name']); if ($name === '') { api_error('网站名称不能为空'); } if (str_len($name) > 40) { $name = str_cut($name, 40); } setting_set('site_name', $name); $changes[] = '名称'; } if (array_key_exists('site_logo', $in)) { $logo = (string) $in['site_logo']; if ($logo !== '') { if (!preg_match('#^data:image/(png|jpe?g|gif|webp|svg\+xml);base64,#i', $logo)) { api_error('Logo 格式不支持,请上传 PNG / JPG / GIF / WEBP / SVG 图片'); } if (strlen($logo) > 400000) { api_error('Logo 图片过大,请控制在 300KB 以内'); } } setting_set('site_logo', $logo); $changes[] = 'Logo'; } if (array_key_exists('allow_registration', $in)) { setting_set('allow_registration', !empty($in['allow_registration']) ? '1' : '0'); $changes[] = '注册开关'; } if (isset($in['session_ttl_days'])) { $days = (int) $in['session_ttl_days']; if ($days < 1) { $days = 1; } if ($days > 365) { $days = 365; } setting_set('session_ttl_days', (string) $days); $changes[] = '会话有效期'; } log_add($user['id'], $user['username'], 'site_settings', '', '更新站点设置:' . ($changes ? implode('、', $changes) : '无变更'), client_ip()); api_admin_settings_get(); } /* ============================================================ * 概览 / 日志(管理员) * ============================================================ */ function api_dashboard() { $stats = stats_overview(); $user = current_user(); $names = user_name_map(); $recentLogs = array(); foreach (log_recent(8) as $r) { $recentLogs[] = log_row_out($r); } $recentTopos = array(); foreach (topo_recent(5) as $r) { $recentTopos[] = topo_summary($r, $user, $names); } api_ok(array('stats' => $stats, 'recentLogs' => $recentLogs, 'recentTopos' => $recentTopos)); } function api_logs() { list($page, $pageSize, $offset) = page_params(); $q = isset($_GET['q']) ? trim((string) $_GET['q']) : ''; $total = log_count($q); $rows = log_search($q, $pageSize, $offset); $list = array(); foreach ($rows as $r) { $list[] = log_row_out($r); } api_ok(array('logs' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize)); } /* ============================================================ * 路由 * ============================================================ */ function handle_api($method, $segments) { $resource = isset($segments[1]) ? $segments[1] : ''; $id = isset($segments[2]) ? $segments[2] : ''; $action = isset($segments[3]) ? $segments[3] : ''; switch ($resource) { case 'login': if ($method !== 'POST') { api_error('方法不允许', 405); } api_login(json_input()); break; case 'register': if ($method !== 'POST') { api_error('方法不允许', 405); } api_register(json_input()); break; case 'logout': if ($method !== 'POST') { api_error('方法不允许', 405); } api_logout(); break; case 'me': if ($method !== 'GET') { api_error('方法不允许', 405); } $u = current_user(); api_ok(array('user' => $u ? public_user($u) : null)); break; case 'site': if ($method !== 'GET') { api_error('方法不允许', 405); } api_site(); break; case 'password': if ($method !== 'POST' && $method !== 'PUT') { api_error('方法不允许', 405); } api_change_password(require_auth(), json_input()); break; case 'dashboard': require_admin(); if ($method !== 'GET') { api_error('方法不允许', 405); } api_dashboard(); break; case 'logs': require_admin(); if ($method !== 'GET') { api_error('方法不允许', 405); } api_logs(); break; case 'users': $admin = require_admin(); if ($method === 'GET' && $id === '') { api_users_list(); } if ($method === 'POST' && $id === '') { api_user_create($admin, json_input()); } if ($id !== '') { if ($action === 'password' && $method === 'PUT') { api_user_reset_password($admin, $id, json_input()); } if ($action === '' && $method === 'PUT') { api_user_update($admin, $id, json_input()); } if ($action === '' && $method === 'DELETE') { api_user_delete($admin, $id); } } api_error('接口不存在', 404); break; case 'admin': $admin = require_admin(); if ($id === 'topologies' && $action === '' && $method === 'GET') { api_admin_topologies(); } if ($id === 'topologies' && $action === 'export' && $method === 'GET') { api_admin_topologies_export(); } if ($id === 'settings' && $action === '') { if ($method === 'GET') { api_admin_settings_get(); } if ($method === 'PUT') { api_admin_settings_update($admin, json_input()); } } api_error('接口不存在', 404); break; case 'topologies': $user = require_auth(); if ($method === 'GET' && $id === '') { api_topo_list($user); } if ($method === 'POST' && $id === '') { api_topo_create($user, json_input()); } if ($id !== '') { if ($action === 'permission' && $method === 'PUT') { api_topo_permission($user, $id, json_input()); } if ($action === 'rename' && $method === 'PUT') { api_topo_rename($user, $id, json_input()); } if ($action === '') { if ($method === 'GET') { api_topo_get($user, $id); } if ($method === 'PUT') { api_topo_save($user, $id, json_input()); } if ($method === 'DELETE') { api_topo_delete($user, $id); } } } api_error('接口不存在', 404); break; default: api_error('接口不存在', 404); } } /* ============================================================ * 静态文件 * ============================================================ */ function mime_type($file) { $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $map = array( 'html' => 'text/html; charset=utf-8', 'htm' => 'text/html; charset=utf-8', 'js' => 'application/javascript; charset=utf-8', 'mjs' => 'application/javascript; charset=utf-8', 'css' => 'text/css; charset=utf-8', 'json' => 'application/json; charset=utf-8', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'svg' => 'image/svg+xml', 'webp' => 'image/webp', 'ico' => 'image/x-icon', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', 'map' => 'application/json; charset=utf-8', 'txt' => 'text/plain; charset=utf-8', ); return isset($map[$ext]) ? $map[$ext] : 'application/octet-stream'; } function serve_static($path) { $rel = ltrim((string) $path, '/'); if ($rel === '' || $rel === 'index.php' || $rel === 'web') { $rel = 'index.html'; } // 兼容以 web/ 前缀访问(如 Apache 直接请求 /web/style.css) if (strpos($rel, 'web/') === 0) { $rel = substr($rel, 4); } // 仅放行前端资源,避免通过静态服务泄露后端文件 $ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION)); $allow = array('html', 'htm', 'css', 'js', 'mjs', 'json', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'woff', 'woff2', 'ttf', 'txt', 'map'); if (!in_array($ext, $allow, true)) { $rel = 'index.html'; } $root = realpath(WEB_DIR); $file = realpath(WEB_DIR . DIRECTORY_SEPARATOR . $rel); if ($root === false || $file === false || strpos($file, $root . DIRECTORY_SEPARATOR) !== 0 || !is_file($file)) { $index = realpath(WEB_DIR . DIRECTORY_SEPARATOR . 'index.html'); if ($index !== false && is_file($index)) { header('Content-Type: text/html; charset=utf-8'); readfile($index); return; } http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo '404 Not Found'; return; } header('Content-Type: ' . mime_type($file)); header('Content-Length: ' . filesize($file)); readfile($file); } /* ============================================================ * 入口 * ============================================================ */ ensure_seed(); $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'; // 支持三种入口(无需 URL 重写): // 1) ?r=/api/xxx 查询串,兼容性最好(前端默认使用) // 2) /index.php/api/xxx PATH_INFO // 3) /api/xxx 需重写或内置服务器路由 if (isset($_GET['r']) && $_GET['r'] !== '') { $r = (string) $_GET['r']; $rq = parse_url($r, PHP_URL_QUERY); if ($rq !== null && $rq !== false && $rq !== '') { parse_str($rq, $extra); if (is_array($extra)) { foreach ($extra as $k => $v) { if (!isset($_GET[$k])) { $_GET[$k] = $v; } } } } $parsedPath = parse_url($r, PHP_URL_PATH); } elseif (!empty($_SERVER['PATH_INFO'])) { $parsedPath = $_SERVER['PATH_INFO']; } else { $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/'; $parsedPath = parse_url($uri, PHP_URL_PATH); } if ($parsedPath === false || $parsedPath === null || $parsedPath === '') { $parsedPath = '/'; } $segments = array_values(array_filter(explode('/', $parsedPath), function ($s) { return $s !== ''; })); if (count($segments) > 0 && $segments[0] === 'index.php') { array_shift($segments); } if (count($segments) > 0 && $segments[0] === 'api') { handle_api($method, $segments); exit; } serve_static($parsedPath);