1148 lines
35 KiB
PHP
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);
|