diff --git a/index.php b/index.php index 5c71998..ea5faa5 100644 --- a/index.php +++ b/index.php @@ -224,6 +224,7 @@ function public_user($u) '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'] : '', @@ -246,33 +247,42 @@ function log_row_out($r) } 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 ($user['role'] === 'admin') { + if (isset($user['role']) && $user['role'] === 'admin') { return true; } return $row['owner_id'] === $user['id']; } -function can_read_topo($row, $user) +/** + * 受保护账号(默认管理员)不允许被任何用户改动 + */ +function assert_not_protected($target, $action) { - if (!$row || !$user) { - return false; + if (!empty($target['protected'])) { + api_error('受保护的默认管理员账号不允许' . $action, 403); } - 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']; + $perm = topo_permission_for($row, $user); return array( 'id' => $row['id'], 'name' => $row['name'], @@ -283,7 +293,11 @@ function topo_summary($row, $user, $names) 'updatedAt' => isset($row['updated_at']) ? $row['updated_at'] : '', 'nodeCount' => (int) $row['node_count'], 'edgeCount' => (int) $row['edge_count'], - 'canEdit' => can_write_topo($row, $user), + '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, ); } @@ -488,6 +502,9 @@ function api_user_update($admin, $id, $in) if (!$u) { api_error('用户不存在', 404); } + if (!empty($u['protected']) && (isset($in['role']) || isset($in['disabled']))) { + api_error('受保护的默认管理员账号不允许修改角色或状态', 403); + } $fields = array(); $changes = array(); @@ -531,6 +548,7 @@ function api_user_reset_password($admin, $id, $in) 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'])) { @@ -557,6 +575,7 @@ function api_user_delete($admin, $id) if (!$u) { api_error('用户不存在', 404); } + assert_not_protected($u, '删除'); if ($u['id'] === $admin['id']) { api_error('不能删除当前登录账号'); } @@ -648,6 +667,29 @@ function api_topo_save($user, $id, $in) 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(); @@ -883,6 +925,434 @@ function api_logs() 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']))); +} + /* ============================================================ * 路由 * ============================================================ */ @@ -923,6 +1393,30 @@ function handle_api($method, $segments) 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); @@ -975,6 +1469,33 @@ function handle_api($method, $segments) 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') { @@ -1009,6 +1530,22 @@ function handle_api($method, $segments) 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); diff --git a/lib/db.php b/lib/db.php index 1ebc3fa..c1be6c6 100644 --- a/lib/db.php +++ b/lib/db.php @@ -129,6 +129,47 @@ function init_schema($pdo, $driver) PRIMARY KEY (skey) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $pdo->exec("CREATE TABLE IF NOT EXISTS user_groups ( + id VARCHAR(32) NOT NULL, + name VARCHAR(64) NOT NULL, + description VARCHAR(255) DEFAULT NULL, + created_at VARCHAR(32) DEFAULT NULL, + updated_at VARCHAR(32) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_groups_name (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS user_group_members ( + group_id VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + created_at VARCHAR(32) DEFAULT NULL, + PRIMARY KEY (group_id, user_id), + KEY idx_ugm_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS topo_shares ( + id VARCHAR(32) NOT NULL, + topo_id VARCHAR(32) NOT NULL, + target_type VARCHAR(8) NOT NULL, + target_id VARCHAR(32) NOT NULL, + permission VARCHAR(8) NOT NULL, + created_by VARCHAR(32) DEFAULT NULL, + created_at VARCHAR(32) DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_shares_topo (topo_id), + KEY idx_shares_target (target_type, target_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS topo_presence ( + topo_id VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + username VARCHAR(32) DEFAULT NULL, + last_seen BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (topo_id, user_id), + KEY idx_presence_seen (last_seen) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + migrate_columns($pdo, $driver); return; } @@ -183,7 +224,80 @@ function init_schema($pdo, $driver) updated_at TEXT )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS user_groups ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT, + updated_at TEXT + )"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS user_group_members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT, + PRIMARY KEY (group_id, user_id) + )"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS topo_shares ( + id TEXT NOT NULL PRIMARY KEY, + topo_id TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + permission TEXT NOT NULL, + created_by TEXT, + created_at TEXT + )"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS topo_presence ( + topo_id TEXT NOT NULL, + user_id TEXT NOT NULL, + username TEXT, + last_seen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (topo_id, user_id) + )"); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)"); $pdo->exec("CREATE INDEX IF NOT EXISTS idx_topo_owner ON topologies(owner_id)"); $pdo->exec("CREATE INDEX IF NOT EXISTS idx_logs_created ON activity_logs(created_at)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_ugm_user ON user_group_members(user_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_shares_topo ON topo_shares(topo_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_shares_target ON topo_shares(target_type, target_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_presence_seen ON topo_presence(last_seen)"); + + migrate_columns($pdo, $driver); +} + +/** + * 幂等加列:已存在的库平滑升级(表名/列名均为代码内常量,无注入风险) + */ +function ensure_column($pdo, $driver, $table, $col, $ddl) +{ + if ($driver === 'mysql') { + $cols = array(); + foreach ($pdo->query('SHOW COLUMNS FROM ' . $table) as $r) { + $cols[] = isset($r['Field']) ? $r['Field'] : ''; + } + if (!in_array($col, $cols, true)) { + $pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $ddl); + } + return; + } + $cols = array(); + foreach ($pdo->query('PRAGMA table_info(' . $table . ')') as $r) { + $cols[] = $r['name']; + } + if (!in_array($col, $cols, true)) { + $pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $ddl); + } +} + +/** + * 列迁移:为老库补齐新增字段 + */ +function migrate_columns($pdo, $driver) +{ + ensure_column($pdo, $driver, 'users', 'protected', 'protected INTEGER NOT NULL DEFAULT 0'); + ensure_column($pdo, $driver, 'topologies', 'rev', 'rev INTEGER NOT NULL DEFAULT 0'); + ensure_column($pdo, $driver, 'topologies', 'share_mode', 'share_mode INTEGER NOT NULL DEFAULT 0'); } diff --git a/lib/repo.php b/lib/repo.php index 7f16464..6232f41 100644 --- a/lib/repo.php +++ b/lib/repo.php @@ -99,8 +99,8 @@ function user_count($q) function user_insert($data) { db_run( - 'INSERT INTO users (id, username, password_hash, role, disabled, must_change_password, created_at, last_login_at, login_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO users (id, username, password_hash, role, disabled, must_change_password, protected, created_at, last_login_at, login_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', array( $data['id'], $data['username'], @@ -108,6 +108,7 @@ function user_insert($data) isset($data['role']) ? $data['role'] : 'user', !empty($data['disabled']) ? 1 : 0, !empty($data['must_change_password']) ? 1 : 0, + !empty($data['protected']) ? 1 : 0, isset($data['created_at']) ? $data['created_at'] : now_iso(), isset($data['last_login_at']) ? $data['last_login_at'] : null, isset($data['login_count']) ? (int) $data['login_count'] : 0, @@ -146,6 +147,120 @@ function user_touch_login($id) db_run('UPDATE users SET last_login_at = ?, login_count = login_count + 1 WHERE id = ?', array(now_iso(), $id)); } +/* ============================================================ + * 用户分组 + * ============================================================ */ +function group_list() +{ + return db_all('SELECT * FROM user_groups ORDER BY created_at ASC, name ASC'); +} + +function group_find($id) +{ + return db_one('SELECT * FROM user_groups WHERE id = ?', array($id)); +} + +function group_find_by_name($name) +{ + return db_one('SELECT * FROM user_groups WHERE name = ?', array($name)); +} + +function group_insert($data) +{ + db_run( + 'INSERT INTO user_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', + array( + $data['id'], + $data['name'], + isset($data['description']) ? $data['description'] : '', + isset($data['created_at']) ? $data['created_at'] : now_iso(), + isset($data['updated_at']) ? $data['updated_at'] : now_iso(), + ) + ); + return $data['id']; +} + +function group_update($id, $fields) +{ + $allowed = array('name', 'description'); + $sets = array(); + $params = array(); + foreach ($allowed as $f) { + if (array_key_exists($f, $fields)) { + $sets[] = $f . ' = ?'; + $params[] = $fields[$f]; + } + } + if (!$sets) { + return false; + } + $sets[] = 'updated_at = ?'; + $params[] = now_iso(); + $params[] = $id; + db_run('UPDATE user_groups SET ' . implode(', ', $sets) . ' WHERE id = ?', $params); + return true; +} + +function group_delete($id) +{ + db_run('DELETE FROM user_groups WHERE id = ?', array($id)); + db_run('DELETE FROM user_group_members WHERE group_id = ?', array($id)); + db_run("DELETE FROM topo_shares WHERE target_type = 'group' AND target_id = ?", array($id)); +} + +function group_members($gid) +{ + return db_all( + 'SELECT u.id, u.username FROM user_group_members m JOIN users u ON u.id = m.user_id + WHERE m.group_id = ? ORDER BY u.username ASC', + array($gid) + ); +} + +function group_member_ids($gid) +{ + $out = array(); + foreach (db_all('SELECT user_id FROM user_group_members WHERE group_id = ?', array($gid)) as $r) { + $out[] = $r['user_id']; + } + return $out; +} + +function group_set_members($gid, $userIds) +{ + db_run('DELETE FROM user_group_members WHERE group_id = ?', array($gid)); + $seen = array(); + foreach ((array) $userIds as $uid) { + $uid = (string) $uid; + if ($uid === '' || isset($seen[$uid])) { + continue; + } + $seen[$uid] = true; + db_run( + 'INSERT INTO user_group_members (group_id, user_id, created_at) VALUES (?, ?, ?)', + array($gid, $uid, now_iso()) + ); + } +} + +function group_ids_of_user($uid) +{ + $out = array(); + foreach (db_all('SELECT group_id FROM user_group_members WHERE user_id = ?', array($uid)) as $r) { + $out[] = $r['group_id']; + } + return $out; +} + +function group_member_counts() +{ + $map = array(); + foreach (db_all('SELECT group_id, COUNT(*) AS c FROM user_group_members GROUP BY group_id') as $r) { + $map[$r['group_id']] = (int) $r['c']; + } + return $map; +} + /* ============================================================ * 会话 * ============================================================ */ @@ -187,11 +302,21 @@ function topo_find($id) function topo_list_for_user($user) { - /* 主应用「我的拓扑」仅返回:当前用户创建的 + 公开的。 + /* 主应用 / 用户后台返回:本人创建的 + 公开的 + 通过成员/分组共享且已开启共享模式的。 全部拓扑(含他人私有)只能在管理后台查看,见 topo_admin_search()。 */ return db_all( - 'SELECT * FROM topologies WHERE owner_id = ? OR visibility = ? ORDER BY updated_at DESC', - array($user['id'], 'public') + "SELECT DISTINCT t.* FROM topologies t + LEFT JOIN topo_shares s ON s.topo_id = t.id + WHERE t.owner_id = ? + OR t.visibility = 'public' + OR (t.share_mode = 1 AND ( + (s.target_type = 'user' AND s.target_id = ?) + OR (s.target_type = 'group' AND s.target_id IN ( + SELECT group_id FROM user_group_members WHERE user_id = ? + )) + )) + ORDER BY t.updated_at DESC", + array($user['id'], $user['id'], $user['id']) ); } @@ -276,12 +401,12 @@ function topo_update($id, $data, $nodeCount, $edgeCount, $updatedAt, $name = nul { if ($name !== null && $name !== '') { db_run( - 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, name = ? WHERE id = ?', + 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, rev = rev + 1, name = ? WHERE id = ?', array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $name, $id) ); } else { db_run( - 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ? WHERE id = ?', + 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $id) ); } @@ -294,12 +419,146 @@ function topo_delete($id) function topo_set_visibility($id, $vis, $updatedAt) { - db_run('UPDATE topologies SET visibility = ?, updated_at = ? WHERE id = ?', array($vis, $updatedAt, $id)); + db_run('UPDATE topologies SET visibility = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($vis, $updatedAt, $id)); } function topo_set_name($id, $name, $updatedAt) { - db_run('UPDATE topologies SET name = ?, updated_at = ? WHERE id = ?', array($name, $updatedAt, $id)); + db_run('UPDATE topologies SET name = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($name, $updatedAt, $id)); +} + +function topo_set_share_mode($id, $on, $updatedAt) +{ + db_run('UPDATE topologies SET share_mode = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($on ? 1 : 0, $updatedAt, $id)); +} + +/* ============================================================ + * 拓扑共享 + * ============================================================ */ +function share_list_by_topo($topoId) +{ + return db_all('SELECT * FROM topo_shares WHERE topo_id = ? ORDER BY created_at ASC', array($topoId)); +} + +/** + * 整体替换某拓扑的共享列表(事务) + */ +function share_replace($topoId, $rows, $by) +{ + $pdo = db(); + $pdo->beginTransaction(); + try { + db_run('DELETE FROM topo_shares WHERE topo_id = ?', array($topoId)); + foreach ((array) $rows as $r) { + db_run( + 'INSERT INTO topo_shares (id, topo_id, target_type, target_id, permission, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)', + array( + gen_id('s'), + $topoId, + $r['target_type'], + $r['target_id'], + $r['permission'], + $by, + now_iso(), + ) + ); + } + $pdo->commit(); + } catch (Exception $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } +} + +/** + * 计算用户对拓扑的有效权限:owner | edit | view | none + */ +function topo_permission_for($row, $user) +{ + if (!$row || !$user) { + return 'none'; + } + if (isset($user['role']) && $user['role'] === 'admin') { + return 'owner'; + } + if ($row['owner_id'] === $user['id']) { + return 'owner'; + } + $best = 'none'; + if (!empty($row['share_mode'])) { + $gids = group_ids_of_user($user['id']); + foreach (share_list_by_topo($row['id']) as $s) { + $hit = false; + if ($s['target_type'] === 'user' && $s['target_id'] === $user['id']) { + $hit = true; + } elseif ($s['target_type'] === 'group' && in_array($s['target_id'], $gids, true)) { + $hit = true; + } + if (!$hit) { + continue; + } + if ($s['permission'] === 'edit') { + return 'edit'; + } + if ($s['permission'] === 'view') { + $best = 'view'; + } + } + } + if ($best === 'view') { + return 'view'; + } + if ($row['visibility'] === 'public') { + return 'view'; + } + return 'none'; +} + +/* ============================================================ + * 在线状态(协同) + * ============================================================ */ +function presence_touch($topoId, $user) +{ + $now = time(); + $exists = db_one('SELECT topo_id FROM topo_presence WHERE topo_id = ? AND user_id = ?', array($topoId, $user['id'])); + if ($exists) { + db_run( + 'UPDATE topo_presence SET username = ?, last_seen = ? WHERE topo_id = ? AND user_id = ?', + array($user['username'], $now, $topoId, $user['id']) + ); + } else { + db_run( + 'INSERT INTO topo_presence (topo_id, user_id, username, last_seen) VALUES (?, ?, ?, ?)', + array($topoId, $user['id'], $user['username'], $now) + ); + } +} + +function presence_list($topoId, $activeWithin) +{ + $cut = time() - (int) $activeWithin; + return db_all( + 'SELECT user_id, username, last_seen FROM topo_presence WHERE topo_id = ? AND last_seen >= ? ORDER BY last_seen DESC', + array($topoId, $cut) + ); +} + +function presence_remove($topoId, $userId) +{ + db_run('DELETE FROM topo_presence WHERE topo_id = ? AND user_id = ?', array($topoId, $userId)); +} + +function presence_purge($beforeTs) +{ + db_run('DELETE FROM topo_presence WHERE last_seen < ?', array((int) $beforeTs)); +} + +function topo_rev($id) +{ + return (int) db_val('SELECT rev FROM topologies WHERE id = ?', array($id)); } /* ============================================================ @@ -463,6 +722,7 @@ function ensure_seed() migrate_legacy_json(); if ((int) db_val('SELECT COUNT(*) FROM users') > 0) { + protect_configured_admin(); return; } $cfg = app_config(); @@ -475,5 +735,18 @@ function ensure_seed() 'disabled' => 0, 'must_change_password' => 1, 'created_at' => now_iso(), + 'protected' => 1, )); } + +/** + * 默认管理员(配置文件中的 default_admin)始终置为受保护账号,不可被其他用户改动 + */ +function protect_configured_admin() +{ + $cfg = app_config(); + $name = isset($cfg['default_admin']['username']) ? $cfg['default_admin']['username'] : 'admin'; + if ($name !== '' && $name !== null) { + db_run('UPDATE users SET protected = 1 WHERE username = ?', array($name)); + } +} diff --git a/web/admin.html b/web/admin.html index 7e5bb4c..fbf3db9 100644 --- a/web/admin.html +++ b/web/admin.html @@ -49,22 +49,41 @@ @@ -199,6 +218,39 @@ + + + + + +