严重漏洞:roster_import 使用 $_FILES 的原始扩展名拼 roster_YYYYMMDD_HHMMSS.<ext>
落在 Web 根目录内的 data/uploads/,既无扩展名白名单(可上传 .php),文件名又
可按秒枚举;而 bootstrap 只写了 data/.htaccess,nginx 不读 .htaccess,
生产环境(nginx + PHP-FPM)下可直接远程代码执行。
修复(api.php roster_import 四层防护):
- 扩展名白名单:仅 xlsx / xlsm / csv / txt
- 随机文件名:bin2hex(random_bytes(16)),杜绝枚举
- 解析完成后 @unlink 立即删除,Web 目录下不留存任何上传文件
- 20MB 大小限制 + UPLOAD_ERR 检查
加固(bootstrap.php):data 及 cache/uploads/snapshots 每个目录都写
.htaccess(Deny from all)+ index.html 占位;README 与文档补充 nginx
location ^~ /data/ { deny all; return 404; } 规则。
测试:新增 e2e tools/test-upload-guard.js(11 断言,覆盖 .php/.phtml 被拒、
无残留、合法 csv 导入成功且即删、无可枚举文件名),并入 tests/run-all.sh。
全套 90 项通过。
docs: 新增 VitePress 文档站(18 页)
- 使用指南 12 页:简介/部署/快速上手/系统设置/名单管理/统计口径/试卷设置/
快照趋势异常/导出打印/数据看板/随机抽取/常见问题
- 安全 2 页:安全总览(含本次漏洞复盘)与部署加固清单(P0-P2)
- 开发 4 页:架构与目录/接口一览/SQLite 存储/测试
- 配图迁移至 docs/public/images/;.gitignore 忽略文档构建产物
509 lines
16 KiB
PHP
509 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* NY 问卷考试系统数据平台 - 引导 / 公共函数
|
||
* PHP >= 8.0
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
const NY_APP_NAME = 'NY问卷考试系统数据平台';
|
||
const NY_VERSION = '1.0.0';
|
||
|
||
define('NY_ROOT', str_replace('\\', '/', __DIR__));
|
||
define('NY_DATA', NY_ROOT . '/data');
|
||
|
||
// ---------------- 环境预检 ----------------
|
||
if (PHP_VERSION_ID < 80000) {
|
||
http_response_code(500);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo '<h2>环境不满足</h2><p>本平台需要 <b>PHP 8.0 及以上</b>,当前版本:' . PHP_VERSION . '</p>';
|
||
exit;
|
||
}
|
||
$missing = [];
|
||
foreach (['curl' => 'curl', 'json' => 'json', 'mbstring' => 'mbstring'] as $fn => $ext) {
|
||
if (!extension_loaded($ext)) {
|
||
$missing[] = $ext;
|
||
}
|
||
}
|
||
if ($missing !== []) {
|
||
http_response_code(500);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo '<h2>缺少 PHP 扩展</h2><p>请在 php.ini 中启用:<b>' . implode('、', $missing) . '</b></p>'
|
||
. '<p>Windows:去掉 <code>extension=' . $missing[0] . '</code> 前面的分号并重启服务。'
|
||
. '(<code>zip</code> 扩展可选,未启用时会用内置解析器读取 xlsx)</p>';
|
||
exit;
|
||
}
|
||
|
||
date_default_timezone_set('Asia/Shanghai');
|
||
mb_internal_encoding('UTF-8');
|
||
|
||
// ---------------- 内存上限(数据接口可能处理大量答卷)----------------
|
||
// 拉取卷王全量答卷做 json_decode 时极易触顶 128M;在未被禁用 / 未被 php_admin_value 硬锁的前提下
|
||
// 临时放宽到 512M。若主机用 php_admin_value 锁定了 memory_limit,下面的 ini_set 会静默失效,
|
||
// 需要改主机 php.ini(或 fpm 池配置)才能生效。
|
||
(function (): void {
|
||
$cur = (string) ini_get('memory_limit');
|
||
if ($cur === '' || $cur === '-1') {
|
||
return; // 未设置 / 已无限制,无需处理
|
||
}
|
||
$curBytes = (int) $cur;
|
||
$unit = strtoupper(substr($cur, -1));
|
||
if ($unit === 'G') {
|
||
$curBytes *= 1024 * 1024 * 1024;
|
||
} elseif ($unit === 'M') {
|
||
$curBytes *= 1024 * 1024;
|
||
} elseif ($unit === 'K') {
|
||
$curBytes *= 1024;
|
||
}
|
||
$want = 512 * 1024 * 1024;
|
||
if ($curBytes > 0 && $curBytes < $want) {
|
||
@ini_set('memory_limit', '512M');
|
||
}
|
||
})();
|
||
|
||
// ---------------- 会话 ----------------
|
||
function ny_session_start(): void
|
||
{
|
||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||
return;
|
||
}
|
||
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||
session_name('NYSESS');
|
||
session_set_cookie_params([
|
||
'lifetime' => 0,
|
||
'path' => '/',
|
||
'httponly' => true,
|
||
'samesite' => 'Lax',
|
||
'secure' => $secure,
|
||
]);
|
||
@session_start();
|
||
}
|
||
|
||
/** 当前是否已登录(本系统会话) */
|
||
function ny_is_auth(): bool
|
||
{
|
||
ny_session_start();
|
||
return !empty($_SESSION['ny_auth']);
|
||
}
|
||
|
||
/** 标记登录 */
|
||
function ny_login(): void
|
||
{
|
||
ny_session_start();
|
||
$_SESSION['ny_auth'] = true;
|
||
}
|
||
|
||
/** 登出 */
|
||
function ny_logout(): void
|
||
{
|
||
ny_session_start();
|
||
$_SESSION = [];
|
||
if (ini_get('session.use_cookies')) {
|
||
$p = session_get_cookie_params();
|
||
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
|
||
}
|
||
session_destroy();
|
||
}
|
||
|
||
// ---------------- CSRF 防护 ----------------
|
||
/**
|
||
* 会话绑定的 CSRF token:写操作(POST)必须携带,防止跨站请求伪造。
|
||
* 前端从 config_get / auth_check 响应里拿到 csrf_token,后续写请求附在
|
||
* X-CSRF-Token 请求头或 body 的 _csrf 字段。
|
||
*/
|
||
function ny_csrf_token(): string
|
||
{
|
||
ny_session_start();
|
||
if (empty($_SESSION['ny_csrf']) || !is_string($_SESSION['ny_csrf'])) {
|
||
$_SESSION['ny_csrf'] = bin2hex(random_bytes(16));
|
||
}
|
||
return $_SESSION['ny_csrf'];
|
||
}
|
||
|
||
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
|
||
function ny_csrf_ok(): bool
|
||
{
|
||
$given = '';
|
||
if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
|
||
$given = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
|
||
} elseif (!empty($_POST['_csrf'])) {
|
||
$given = (string)$_POST['_csrf'];
|
||
} else {
|
||
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
|
||
$raw = (string)file_get_contents('php://input');
|
||
if ($raw !== '') {
|
||
$j = json_decode($raw, true);
|
||
if (is_array($j) && isset($j['_csrf'])) {
|
||
$given = (string)$j['_csrf'];
|
||
}
|
||
}
|
||
}
|
||
$token = ny_csrf_token();
|
||
return $given !== '' && hash_equals($token, $given);
|
||
}
|
||
|
||
// ---------------- 登录失败限流 ----------------
|
||
/**
|
||
* 登录失败限流:按 IP 记录连续失败次数与时间戳(写入 data/login_fail.json)。
|
||
* 5 次失败后锁 10 分钟。仅防御暴力破解,不做超强语义(单机小工具足够)。
|
||
*/
|
||
function ny_login_throttle_check(): array
|
||
{
|
||
$path = NY_DATA . '/login_fail.json';
|
||
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||
$rec = ['count' => 0, 'until' => 0];
|
||
if (is_file($path)) {
|
||
$j = json_decode((string)file_get_contents($path), true);
|
||
if (is_array($j) && isset($j[$ip])) {
|
||
$rec = array_merge($rec, $j[$ip]);
|
||
}
|
||
}
|
||
return $rec;
|
||
}
|
||
|
||
function ny_login_throttle_hit(): void
|
||
{
|
||
$path = NY_DATA . '/login_fail.json';
|
||
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||
$all = is_file($path) ? (json_decode((string)file_get_contents($path), true) ?: []) : [];
|
||
$rec = isset($all[$ip]) ? $all[$ip] : ['count' => 0, 'until' => 0];
|
||
$rec['count'] = ((int)($rec['count'] ?? 0)) + 1;
|
||
if ($rec['count'] >= 5) {
|
||
$rec['count'] = 0; // 重新计数,进入冷却
|
||
$rec['until'] = time() + 600; // 锁 10 分钟
|
||
}
|
||
$all[$ip] = $rec;
|
||
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
|
||
}
|
||
|
||
function ny_login_throttle_reset(): void
|
||
{
|
||
$path = NY_DATA . '/login_fail.json';
|
||
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||
if (!is_file($path)) {
|
||
return;
|
||
}
|
||
$all = json_decode((string)file_get_contents($path), true) ?: [];
|
||
if (isset($all[$ip])) {
|
||
unset($all[$ip]);
|
||
@file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
|
||
}
|
||
}
|
||
|
||
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
|
||
function ny_is_installed(): bool
|
||
{
|
||
$cfg = ny_load_config();
|
||
if (trim((string)($cfg['base_url'] ?? '')) === '') {
|
||
return false;
|
||
}
|
||
return is_file(ny_config_file());
|
||
}
|
||
|
||
// ---------------- 目录初始化 ----------------
|
||
foreach (['', '/cache', '/uploads', '/snapshots'] as $sub) {
|
||
$dir = NY_DATA . $sub;
|
||
if (!is_dir($dir)) {
|
||
@mkdir($dir, 0777, true);
|
||
}
|
||
// Apache:禁止直接访问(nginx 不读 .htaccess,需在 server 配置中额外 deny,见 docs/部署与安全)
|
||
if (!file_exists($dir . '/.htaccess')) {
|
||
@file_put_contents($dir . '/.htaccess', "Deny from all\n");
|
||
}
|
||
// 兜底:即使目录被索引也不暴露文件清单
|
||
if (!file_exists($dir . '/index.html')) {
|
||
@file_put_contents($dir . '/index.html', "<!doctype html><meta charset=\"utf-8\"><title>403 Forbidden</title><h1>403 Forbidden</h1>\n");
|
||
}
|
||
}
|
||
|
||
// ---------------- 默认配置 ----------------
|
||
function ny_default_config(): array
|
||
{
|
||
return [
|
||
'base_url' => 'https://exam.nxxy335.top',
|
||
'api_prefix' => '/api',
|
||
'username' => 'admin',
|
||
'password' => '123456',
|
||
'verify_ssl' => true,
|
||
'timeout' => 20,
|
||
'refresh_interval' => 15,
|
||
'page_size' => 200,
|
||
'include_temp_save' => false,
|
||
'dedupe' => true,
|
||
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
|
||
'full_score' => 100,
|
||
// 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
|
||
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
|
||
'pass_rate' => 60,
|
||
'layout' => 'default',
|
||
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
|
||
'history_display' => 'click',
|
||
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
|
||
'export_sort' => 'time',
|
||
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
|
||
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
|
||
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
|
||
'cache_ttl' => 15,
|
||
];
|
||
}
|
||
|
||
function ny_config_file(): string
|
||
{
|
||
return NY_DATA . '/config.json';
|
||
}
|
||
|
||
/**
|
||
* 配置来源:db | file | default
|
||
* (default 表示既没读到 DB 也没读到文件——此时账号密码是出厂默认值,几乎必然连不上卷王)
|
||
*/
|
||
function ny_config_source(): string
|
||
{
|
||
return (string)($GLOBALS['__ny_cfg_source'] ?? 'default');
|
||
}
|
||
|
||
function ny_load_config(): array
|
||
{
|
||
$GLOBALS['__ny_cfg_source'] = 'default';
|
||
|
||
$cfg = ny_default_config();
|
||
$file = ny_config_file();
|
||
$saved = null;
|
||
|
||
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
|
||
if (class_exists('Db') && Db::pdo() !== null) {
|
||
$raw = Db::settingsGet('config', '');
|
||
if ($raw !== '') {
|
||
$j = json_decode($raw, true);
|
||
if (is_array($j)) {
|
||
$saved = $j;
|
||
$GLOBALS['__ny_cfg_source'] = 'db';
|
||
}
|
||
}
|
||
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
|
||
if ($saved === null) {
|
||
$fromFile = ny_read_json($file, null);
|
||
if (is_array($fromFile)) {
|
||
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
|
||
$saved = $fromFile;
|
||
$GLOBALS['__ny_cfg_source'] = 'db';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2) DB 不可用时退回文件
|
||
if ($saved === null) {
|
||
$saved = ny_read_json($file, null);
|
||
if (is_array($saved)) {
|
||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||
}
|
||
}
|
||
|
||
if (is_array($saved)) {
|
||
foreach ($cfg as $k => $v) {
|
||
if (array_key_exists($k, $saved)) {
|
||
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
|
||
}
|
||
}
|
||
}
|
||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||
return $cfg;
|
||
}
|
||
|
||
function ny_save_config(array $cfg): bool
|
||
{
|
||
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
||
$ok = false;
|
||
if (class_exists('Db') && Db::pdo() !== null) {
|
||
try {
|
||
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
|
||
$GLOBALS['__ny_cfg_source'] = 'db';
|
||
$ok = true;
|
||
} catch (Throwable $e) {
|
||
$ok = false;
|
||
}
|
||
}
|
||
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
||
if (!$ok) {
|
||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||
}
|
||
return $ok || $fileOk;
|
||
}
|
||
|
||
/**
|
||
* 解析「本卷满分 + 合格率」:
|
||
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
||
*
|
||
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
|
||
*/
|
||
function ny_resolve_score_cfg(string $projectId, array $c): array
|
||
{
|
||
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
|
||
|
||
$fs = (int)($pCfg['full_score'] ?? 0);
|
||
if ($fs < 1) {
|
||
$fs = (int)($c['full_score'] ?? 100);
|
||
}
|
||
if ($fs < 1) {
|
||
$fs = 100;
|
||
}
|
||
|
||
$pr = $pCfg['pass_rate'] ?? null;
|
||
if ($pr === null || $pr === '') {
|
||
$pr = $c['pass_rate'] ?? 60;
|
||
}
|
||
$pr = (float)$pr;
|
||
if ($pr <= 0 || $pr > 100) {
|
||
$pr = 60;
|
||
}
|
||
|
||
return [$fs, $pr];
|
||
}
|
||
|
||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||
function ny_str(mixed $v): string
|
||
{
|
||
if ($v === null || is_array($v) || is_object($v)) {
|
||
return '';
|
||
}
|
||
return trim((string)$v);
|
||
}
|
||
|
||
// ---------------- JSON 文件读写 ----------------
|
||
function ny_read_json(string $file, mixed $default = null): mixed
|
||
{
|
||
if (!is_file($file)) {
|
||
return $default;
|
||
}
|
||
$raw = @file_get_contents($file);
|
||
if ($raw === false || trim($raw) === '') {
|
||
return $default;
|
||
}
|
||
$data = json_decode($raw, true);
|
||
return $data === null ? $default : $data;
|
||
}
|
||
|
||
function ny_write_json(string $file, mixed $data): bool
|
||
{
|
||
$tmp = $file . '.tmp';
|
||
$ok = @file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
|
||
if ($ok === false) {
|
||
return false;
|
||
}
|
||
return @rename($tmp, $file) || @copy($tmp, $file);
|
||
}
|
||
|
||
// ---------------- 输出 ----------------
|
||
function ny_json(mixed $data, int $http = 200): void
|
||
{
|
||
http_response_code($http);
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
header('Cache-Control: no-store');
|
||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||
}
|
||
|
||
function ny_ok(mixed $data = null): void
|
||
{
|
||
ny_json(['ok' => true, 'data' => $data]);
|
||
exit;
|
||
}
|
||
|
||
function ny_fail(string $msg, mixed $extra = null): void
|
||
{
|
||
ny_json(['ok' => false, 'msg' => $msg, 'extra' => $extra]);
|
||
exit;
|
||
}
|
||
|
||
// ---------------- 工具函数 ----------------
|
||
/** 规范化姓名:去空白、去全角空格、统一大小写 */
|
||
function ny_norm_name(string $s): string
|
||
{
|
||
$s = str_replace(["\xC2\xA0", ' '], '', $s);
|
||
$s = preg_replace('/\s+/u', '', $s) ?? '';
|
||
return mb_strtolower(trim($s));
|
||
}
|
||
|
||
/** 把任意结构的答卷值压平成字符串 */
|
||
function ny_flatten_value(mixed $v): string
|
||
{
|
||
if ($v === null) {
|
||
return '';
|
||
}
|
||
if (is_bool($v)) {
|
||
return $v ? '1' : '';
|
||
}
|
||
if (is_int($v) || is_float($v)) {
|
||
return trim((string)$v);
|
||
}
|
||
if (is_string($v)) {
|
||
return trim($v);
|
||
}
|
||
if (is_array($v)) {
|
||
foreach (['value', 'text', 'label', 'name', 'content', 'title'] as $k) {
|
||
if (isset($v[$k]) && (is_string($v[$k]) || is_numeric($v[$k]))) {
|
||
return trim((string)$v[$k]);
|
||
}
|
||
}
|
||
$parts = [];
|
||
foreach ($v as $item) {
|
||
$s = ny_flatten_value($item);
|
||
if ($s !== '') {
|
||
$parts[] = $s;
|
||
}
|
||
}
|
||
return implode(',', $parts);
|
||
}
|
||
return trim((string)$v);
|
||
}
|
||
|
||
/** 时间字段 -> 时间戳(秒) */
|
||
function ny_to_timestamp(mixed $v): int
|
||
{
|
||
if ($v === null || $v === '') {
|
||
return 0;
|
||
}
|
||
if (is_numeric($v)) {
|
||
$n = (int)$v;
|
||
if ($n > 1000000000000) { // 毫秒
|
||
return intdiv($n, 1000);
|
||
}
|
||
return $n;
|
||
}
|
||
$t = strtotime((string)$v);
|
||
return $t === false ? 0 : $t;
|
||
}
|
||
|
||
/** 秒 -> 12分34秒 */
|
||
function ny_format_duration(?int $sec): string
|
||
{
|
||
if ($sec === null || $sec < 0) {
|
||
return '-';
|
||
}
|
||
if ($sec < 60) {
|
||
return $sec . '秒';
|
||
}
|
||
$m = intdiv($sec, 60);
|
||
$s = $sec % 60;
|
||
if ($m < 60) {
|
||
return $m . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
|
||
}
|
||
$h = intdiv($m, 60);
|
||
return $h . '小时' . str_pad((string)($m % 60), 2, '0', STR_PAD_LEFT) . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
|
||
}
|
||
|
||
// ---------------- 类自动加载 ----------------
|
||
spl_autoload_register(function (string $class): void {
|
||
$map = [
|
||
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
|
||
'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
|
||
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
|
||
'Roster' => NY_ROOT . '/lib/Roster.php',
|
||
'DataService' => NY_ROOT . '/lib/DataService.php',
|
||
'DataCache' => NY_ROOT . '/lib/DataCache.php',
|
||
'Snapshot' => NY_ROOT . '/lib/Snapshot.php',
|
||
'Db' => NY_ROOT . '/lib/Db.php',
|
||
];
|
||
if (isset($map[$class]) && is_file($map[$class])) {
|
||
require $map[$class];
|
||
}
|
||
});
|