= 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']), 'protected' => !empty($u['protected']), '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) { $p = topo_permission_for($row, $user); return $p === 'owner' || $p === 'edit'; } function can_read_topo($row, $user) { return topo_permission_for($row, $user) !== 'none'; } /* 共享管理(增删共享 / 开关共享模式)仅限创建者本人或管理员 */ function can_manage_shares($row, $user) { if (!$row || !$user) { return false; } if (isset($user['role']) && $user['role'] === 'admin') { return true; } return $row['owner_id'] === $user['id']; } /** * 受保护账号(默认管理员)不允许被任何用户改动 */ function assert_not_protected($target, $action) { if (!empty($target['protected'])) { api_error('受保护的默认管理员账号不允许' . $action, 403); } } function topo_summary($row, $user, $names) { $ownerId = $row['owner_id']; $perm = topo_permission_for($row, $user); 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' => ($perm === 'owner' || $perm === 'edit'), 'permission' => $perm, 'shareMode' => !empty($row['share_mode']), 'sharedWithMe' => ($ownerId !== $user['id'] && !empty($row['share_mode']) && ($perm === 'edit' || $perm === 'view')), 'rev' => isset($row['rev']) ? (int) $row['rev'] : 0, ); } /* ============================================================ * 认证接口 * ============================================================ */ 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); } if (!empty($u['protected']) && (isset($in['role']) || isset($in['disabled']))) { api_error('受保护的默认管理员账号不允许修改角色或状态', 403); } $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); } assert_not_protected($u, '重置密码'); $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); } assert_not_protected($u, '删除'); 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); } // 协同版本冲突检测:客户端携带的 baseRev 与服务端不一致时,返回最新数据供三方合并 if (isset($in['baseRev'])) { $curRev = isset($row['rev']) ? (int) $row['rev'] : 0; if ((int) $in['baseRev'] !== $curRev) { $content = json_decode($row['data'], true); if (!is_array($content)) { $content = array(); } json_out(array( 'ok' => false, 'error' => '版本冲突:该拓扑已被其他协作者更新', 'conflict' => true, 'rev' => $curRev, '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(), ), ), 409); } } $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 group_out($g, $count) { return array( 'id' => $g['id'], 'name' => $g['name'], 'description' => isset($g['description']) ? $g['description'] : '', 'memberCount' => (int) $count, 'createdAt' => isset($g['created_at']) ? $g['created_at'] : '', 'updatedAt' => isset($g['updated_at']) ? $g['updated_at'] : '', ); } function api_groups_list() { $counts = group_member_counts(); $list = array(); foreach (group_list() as $g) { $list[] = group_out($g, isset($counts[$g['id']]) ? $counts[$g['id']] : 0); } api_ok(array('groups' => $list)); } function api_group_create($admin, $in) { $name = isset($in['name']) ? trim((string) $in['name']) : ''; $desc = isset($in['description']) ? trim((string) $in['description']) : ''; if ($name === '') { api_error('请输入分组名称'); } if (str_len($name) > 40) { $name = str_cut($name, 40); } if (str_len($desc) > 120) { $desc = str_cut($desc, 120); } if (group_find_by_name($name)) { api_error('分组名称已存在', 409); } $id = gen_id('g'); group_insert(array('id' => $id, 'name' => $name, 'description' => $desc)); log_add($admin['id'], $admin['username'], 'group_create', $id, '创建分组 ' . $name, client_ip()); api_ok(array('group' => group_out(group_find($id), 0))); } function api_group_update($admin, $id, $in) { $g = group_find($id); if (!$g) { api_error('分组不存在', 404); } $fields = array(); if (isset($in['name'])) { $name = trim((string) $in['name']); if ($name === '') { api_error('分组名称不能为空'); } if (str_len($name) > 40) { $name = str_cut($name, 40); } $dup = group_find_by_name($name); if ($dup && $dup['id'] !== $id) { api_error('分组名称已存在', 409); } $fields['name'] = $name; } if (isset($in['description'])) { $desc = trim((string) $in['description']); if (str_len($desc) > 120) { $desc = str_cut($desc, 120); } $fields['description'] = $desc; } if ($fields) { group_update($id, $fields); log_add($admin['id'], $admin['username'], 'group_update', $id, '更新分组 ' . $g['name'], client_ip()); } $counts = group_member_counts(); api_ok(array('group' => group_out(group_find($id), isset($counts[$id]) ? $counts[$id] : 0))); } function api_group_delete($admin, $id) { $g = group_find($id); if (!$g) { api_error('分组不存在', 404); } group_delete($id); log_add($admin['id'], $admin['username'], 'group_delete', $id, '删除分组 ' . $g['name'], client_ip()); api_ok(); } function api_group_members($id) { if (!group_find($id)) { api_error('分组不存在', 404); } api_ok(array('members' => group_members($id))); } function api_group_members_set($admin, $id, $in) { $g = group_find($id); if (!$g) { api_error('分组不存在', 404); } $ids = (isset($in['userIds']) && is_array($in['userIds'])) ? $in['userIds'] : array(); $valid = array(); foreach ($ids as $uid) { $uid = (string) $uid; if ($uid !== '' && user_find_by_id($uid) && !in_array($uid, $valid, true)) { $valid[] = $uid; } } group_set_members($id, $valid); log_add($admin['id'], $admin['username'], 'group_members', $id, '更新分组成员 ' . $g['name'] . ':共 ' . count($valid) . ' 人', client_ip()); api_ok(array('members' => group_members($id))); } function group_name_map() { $map = array(); foreach (group_list() as $g) { $map[$g['id']] = $g['name']; } return $map; } /* ============================================================ * 拓扑共享接口 * ============================================================ */ function share_out($s, $userNames, $groupNames) { if ($s['target_type'] === 'user') { $name = isset($userNames[$s['target_id']]) ? $userNames[$s['target_id']] : '未知用户'; } else { $name = isset($groupNames[$s['target_id']]) ? $groupNames[$s['target_id']] : '未知分组'; } return array( 'id' => $s['id'], 'targetType' => $s['target_type'], 'targetId' => $s['target_id'], 'targetName' => $name, 'permission' => ($s['permission'] === 'edit') ? 'edit' : 'view', ); } function api_topo_shares_get($user, $id) { if (!valid_id($id)) { api_error('非法的拓扑 ID'); } $row = topo_find($id); if (!$row) { api_error('拓扑不存在', 404); } if (!can_manage_shares($row, $user)) { api_error('仅创建者可管理该拓扑的共享', 403); } $userNames = user_name_map(); $groupNames = group_name_map(); $shares = array(); foreach (share_list_by_topo($id) as $s) { $shares[] = share_out($s, $userNames, $groupNames); } api_ok(array( 'shareMode' => !empty($row['share_mode']), 'shares' => $shares, )); } function api_topo_shares_set($user, $id, $in) { if (!valid_id($id)) { api_error('非法的拓扑 ID'); } $row = topo_find($id); if (!$row) { api_error('拓扑不存在', 404); } if (!can_manage_shares($row, $user)) { api_error('仅创建者可管理该拓扑的共享', 403); } $shareMode = !empty($in['shareMode']); $rows = array(); $seen = array(); if ($shareMode && isset($in['shares']) && is_array($in['shares'])) { foreach ($in['shares'] as $s) { if (!is_array($s)) { continue; } $tt = (isset($s['targetType']) && $s['targetType'] === 'group') ? 'group' : 'user'; $tid = isset($s['targetId']) ? (string) $s['targetId'] : ''; if ($tid === '') { continue; } if ($tt === 'user') { if ($tid === $row['owner_id'] || !user_find_by_id($tid)) { continue; } } else { if (!group_find($tid)) { continue; } } $key = $tt . ':' . $tid; if (isset($seen[$key])) { continue; } $seen[$key] = true; $rows[] = array( 'target_type' => $tt, 'target_id' => $tid, 'permission' => (isset($s['permission']) && $s['permission'] === 'edit') ? 'edit' : 'view', ); } } share_replace($id, $rows, $user['id']); topo_set_share_mode($id, $shareMode, now_iso()); log_add( $user['id'], $user['username'], 'topo_share', $id, ($shareMode ? '开启共享' : '关闭共享') . '(共 ' . count($rows) . ' 项):' . $row['name'], client_ip() ); api_topo_shares_get($user, $id); } /* ============================================================ * 通讯录 / 概览 * ============================================================ */ function api_directory() { $users = array(); foreach (db_all('SELECT id, username FROM users WHERE disabled = 0 ORDER BY username ASC') as $u) { $users[] = array('id' => $u['id'], 'username' => $u['username']); } $groups = array(); foreach (group_list() as $g) { $groups[] = array('id' => $g['id'], 'name' => $g['name']); } api_ok(array('users' => $users, 'groups' => $groups)); } function api_overview($user) { $own = 0; $shared = 0; foreach (topo_list_for_user($user) as $r) { if ($r['owner_id'] === $user['id']) { $own++; continue; } $perm = topo_permission_for($r, $user); if (!empty($r['share_mode']) && ($perm === 'edit' || $perm === 'view')) { $shared++; } } api_ok(array( 'overview' => array( 'myTopo' => $own, 'sharedToMe' => $shared, 'myGroups' => count(group_ids_of_user($user['id'])), 'publicTopo' => (int) db_val("SELECT COUNT(*) FROM topologies WHERE visibility = 'public'"), ) )); } /* 当前用户所属分组(普通用户可用) */ function api_my_groups($user) { $out = array(); foreach (group_ids_of_user($user['id']) as $gid) { $g = group_find($gid); if (!$g) { continue; } $out[] = array( 'id' => $g['id'], 'name' => $g['name'], 'description' => isset($g['description']) ? $g['description'] : '', 'memberCount' => count(group_member_ids($gid)), ); } api_ok(array('groups' => $out)); } /* ============================================================ * 协同:在线状态 / 版本推送 * ============================================================ */ function presence_online_out($plist, $selfId) { $out = array(); foreach ($plist as $p) { $out[] = array( 'id' => $p['user_id'], 'username' => $p['username'], 'self' => ($p['user_id'] === $selfId), ); } return $out; } function sse_event($event, $data) { echo 'event: ' . $event . "\n"; echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n"; if (ob_get_level() > 0) { @ob_flush(); } @flush(); } /** * SSE 推送:拓扑版本变化 + 在线成员。 * 有界存活(约 30s)后关闭,由前端 EventSource 自动重连。 * 注意:php -S 单进程下会占用一个 worker,建议设置 PHP_CLI_SERVER_WORKERS; * Apache / PHP-FPM 无此限制。 */ function api_topo_stream($user, $id, $since) { 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); } @ini_set('zlib.output_compression', '0'); @ini_set('output_buffering', '0'); @set_time_limit(0); while (ob_get_level() > 0) { @ob_end_flush(); } header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache, no-store'); header('X-Accel-Buffering: no'); $deadline = time() + 30; $lastRev = null; $lastPresence = null; presence_touch($id, $user); presence_purge(time() - 120); while (time() < $deadline) { if (connection_aborted()) { return; } $cur = topo_find($id); if (!$cur) { sse_event('gone', array('reason' => 'deleted')); return; } $rev = isset($cur['rev']) ? (int) $cur['rev'] : 0; if ($rev !== $lastRev) { $lastRev = $rev; if ($rev !== $since) { sse_event('revision', array('rev' => $rev)); } } presence_touch($id, $user); $plist = presence_online_out(presence_list($id, 15), $user['id']); $hash = md5(json_encode($plist)); if ($hash !== $lastPresence) { $lastPresence = $hash; sse_event('presence', array('users' => $plist)); } sse_event('ping', array('t' => time())); usleep(1500000); } sse_event('bye', array('t' => time())); } function api_topo_poll($user, $id, $since) { 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); } presence_touch($id, $user); presence_purge(time() - 120); $rev = isset($row['rev']) ? (int) $row['rev'] : 0; api_ok(array( 'rev' => $rev, 'changed' => ($since >= 0 && (int) $since !== $rev), 'presence' => presence_online_out(presence_list($id, 15), $user['id']), )); } function api_topo_presence($user, $id, $in) { 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); } if (!empty($in['leave'])) { presence_remove($id, $user['id']); } else { presence_touch($id, $user); } presence_purge(time() - 120); api_ok(array('presence' => presence_online_out(presence_list($id, 15), $user['id']))); } /* ============================================================ * 路由 * ============================================================ */ 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 'directory': require_auth(); if ($method !== 'GET') { api_error('方法不允许', 405); } api_directory(); break; case 'overview': $u = require_auth(); if ($method !== 'GET') { api_error('方法不允许', 405); } api_overview($u); break; case 'mygroups': $u = require_auth(); if ($method !== 'GET') { api_error('方法不允许', 405); } api_my_groups($u); 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 'groups': $admin = require_admin(); if ($method === 'GET' && $id === '') { api_groups_list(); } if ($method === 'POST' && $id === '') { api_group_create($admin, json_input()); } if ($id !== '') { if ($action === 'members') { if ($method === 'GET') { api_group_members($id); } if ($method === 'PUT') { api_group_members_set($admin, $id, json_input()); } } if ($action === '' && $method === 'PUT') { api_group_update($admin, $id, json_input()); } if ($action === '' && $method === 'DELETE') { api_group_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 === 'shares' && $method === 'GET') { api_topo_shares_get($user, $id); } if ($action === 'shares' && $method === 'PUT') { api_topo_shares_set($user, $id, json_input()); } if ($action === 'stream' && $method === 'GET') { api_topo_stream($user, $id, isset($_GET['since']) ? (int) $_GET['since'] : -1); exit; } if ($action === 'poll' && $method === 'GET') { api_topo_poll($user, $id, isset($_GET['since']) ? (int) $_GET['since'] : -1); } if ($action === 'presence' && $method === 'POST') { api_topo_presence($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);