RoutePage/index.php
2026-09-15 02:00:21 +08:00

1148 lines
35 KiB
PHP

<?php
/**
* 路由拓扑 · 多用户版 — 前端控制器
* PHP >= 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);