1685 lines
52 KiB
PHP
1685 lines
52 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']),
|
|
'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);
|