莫名
This commit is contained in:
@@ -17,7 +17,6 @@ class App
|
||||
// 1. 时区 & 会话
|
||||
date_default_timezone_set(self::config('app.timezone', 'Asia/Shanghai'));
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
self::ensureSessionPath();
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => 'Lax',
|
||||
@@ -49,43 +48,6 @@ class App
|
||||
self::dispatch(self::parseRoute());
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 session 存储路径可用。
|
||||
* 服务器 session.save_path 可能指向不存在/不可写目录(如旧域名残留配置、
|
||||
* php_admin_value 锁定等),此时 session_start() 会报 Warning 并失败。
|
||||
* 本方法依次尝试:1) session_save_path() 切换到项目本地目录;2) 若被锁则
|
||||
* 注册自定义文件 session handler 完全绕过服务器配置。
|
||||
*/
|
||||
private static function ensureSessionPath()
|
||||
{
|
||||
$sp = session_save_path();
|
||||
// session.save_path 可能带 N;/path 深度前缀,取实际路径部分判断
|
||||
$spDir = ($pos = strpos($sp, ';')) !== false ? substr($sp, $pos + 1) : $sp;
|
||||
if ($spDir !== '' && is_dir($spDir) && is_writable($spDir)) {
|
||||
return; // 服务器路径正常,无需处理
|
||||
}
|
||||
|
||||
$localSession = BASE_PATH . '/storage/sessions';
|
||||
if (!is_dir($localSession)) {
|
||||
@mkdir($localSession, 0755, true);
|
||||
}
|
||||
if (!is_dir($localSession) || !is_writable($localSession)) {
|
||||
return; // 本地目录也建不了,交给 session_start() 原样报错
|
||||
}
|
||||
|
||||
// 尝试 1:session_save_path() 切换(php_value 级别可生效)
|
||||
session_save_path($localSession);
|
||||
$checkPath = session_save_path();
|
||||
$checkDir = ($pos = strpos($checkPath, ';')) !== false ? substr($checkPath, $pos + 1) : $checkPath;
|
||||
if ($checkDir === $localSession) {
|
||||
return; // 切换成功
|
||||
}
|
||||
|
||||
// 尝试 2:被 php_admin_value 锁定,注册自定义文件 handler 完全绕过
|
||||
$handler = new LocalSessionHandler($localSession);
|
||||
session_set_save_handler($handler, true);
|
||||
}
|
||||
|
||||
/** 解析请求路径为段数组 */
|
||||
public static function parseRoute(): array
|
||||
{
|
||||
@@ -443,59 +405,3 @@ class App
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地文件 session handler —— 当服务器 session.save_path 不可用/被锁时,
|
||||
* 将 session 数据存到项目 storage/sessions/ 目录,完全绕过服务器配置。
|
||||
* 兼容 PHP 7.4 ~ 8.x(不声明返回类型,用 #[\ReturnTypeWillChange] 抑制 8.x 弃用提示)。
|
||||
*/
|
||||
class LocalSessionHandler implements \SessionHandlerInterface
|
||||
{
|
||||
private $dir;
|
||||
|
||||
public function __construct(string $dir)
|
||||
{
|
||||
$this->dir = $dir;
|
||||
}
|
||||
|
||||
public function open($savePath, $sessionName)
|
||||
{
|
||||
return is_dir($this->dir) && is_writable($this->dir);
|
||||
}
|
||||
|
||||
public function close()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function read($id)
|
||||
{
|
||||
$f = $this->dir . '/sess_' . $id;
|
||||
return is_file($f) ? (string) @file_get_contents($f) : '';
|
||||
}
|
||||
|
||||
public function write($id, $data)
|
||||
{
|
||||
return @file_put_contents($this->dir . '/sess_' . $id, $data) !== false;
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$f = $this->dir . '/sess_' . $id;
|
||||
return is_file($f) ? @unlink($f) : true;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function gc($maxlifetime)
|
||||
{
|
||||
$n = 0;
|
||||
foreach ((array) @glob($this->dir . '/sess_*') as $f) {
|
||||
if (is_file($f) && filemtime($f) + $maxlifetime < time()) {
|
||||
@unlink($f);
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
}
|
||||
|
||||
+123
-21
@@ -561,9 +561,15 @@ if (!function_exists('site_url')) {
|
||||
{
|
||||
if (headers_sent()) return;
|
||||
$nonce = csp_nonce();
|
||||
// 通用安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options 等)已统一在
|
||||
// Nginx 服务器层下发(含静态资源),无需在此重复。
|
||||
// 此处仅补充依赖动态随机数的「严格 CSP」——nonce 每次请求不同,必须走 PHP。
|
||||
// 通用安全响应头从 PHP 兜底补齐:即便 Nginx 层未下发也不会缺失(防配置漂移)。
|
||||
// 与审计整改要求一致:补充 X-Content-Type-Options / Referrer-Policy / Permissions-Policy,
|
||||
// 并将 HSTS 升级为含 includeSubDomains + preload。若 Nginx 也下发 HSTS,重复为无害,
|
||||
// 浏览器取更严格项(max-age 取最大值并合并指令)。
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||
header("Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()");
|
||||
header("Strict-Transport-Security: max-age=63072000; includeSubDomains; preload");
|
||||
// 严格 CSP(nonce 每次请求不同,必须走 PHP)
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
|
||||
}
|
||||
|
||||
@@ -692,35 +698,89 @@ if (!function_exists('site_url')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------- IP 级失败限速(fail2ban 式,文件缓存,越会话更抗爆破) ---------- */
|
||||
function ip_login_blocked(string $ip): bool
|
||||
/* ---------- IP 级登录限速(fail2ban 式,文件缓存,越会话更抗爆破) ----------
|
||||
* 双窗口独立限速(按需求定制):
|
||||
* · 失败登录:任意 10 分钟内最多 5 次;超出即封锁 30 分钟
|
||||
* · 成功登录:任意 30 分钟内最多 5 次;超出即限制(封锁至最早成功滑出 30 分钟窗口)
|
||||
* 数据文件:storage/login_ip.json —— 每个 IP 记失败/成功时间戳列表 + 封锁截止时间
|
||||
* --------------------------------------------------------------------- */
|
||||
defined('LOGIN_FAIL_WINDOW') or define('LOGIN_FAIL_WINDOW', 600); // 失败计数窗口:10 分钟
|
||||
defined('LOGIN_FAIL_LIMIT') or define('LOGIN_FAIL_LIMIT', 5); // 失败次数上限
|
||||
defined('LOGIN_OK_WINDOW') or define('LOGIN_OK_WINDOW', 1800); // 成功计数窗口:30 分钟
|
||||
defined('LOGIN_OK_LIMIT') or define('LOGIN_OK_LIMIT', 5); // 成功次数上限
|
||||
defined('LOGIN_BLOCK_SECS') or define('LOGIN_BLOCK_SECS', 1800); // 超限后封锁时长:30 分钟
|
||||
|
||||
function _ip_login_load(): array
|
||||
{
|
||||
$file = BASE_PATH . '/storage/login_ip.json';
|
||||
if (!is_file($file)) return false;
|
||||
$data = json_decode(@file_get_contents($file), true) ?: [];
|
||||
$now = time();
|
||||
if (!isset($data[$ip])) return false;
|
||||
return $data[$ip]['count'] >= 8;
|
||||
return is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
|
||||
}
|
||||
function ip_login_register(string $ip): void
|
||||
function _ip_login_save(array $data): void
|
||||
{
|
||||
$file = BASE_PATH . '/storage/login_ip.json';
|
||||
if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
|
||||
$data = is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
|
||||
$now = time();
|
||||
if (!isset($data[$ip]) || ($data[$ip]['time'] + 900) < $now) {
|
||||
$data[$ip] = ['count' => 0, 'time' => $now];
|
||||
}
|
||||
$data[$ip]['count']++;
|
||||
@file_put_contents($file, json_encode($data));
|
||||
}
|
||||
/** 裁剪过期时间戳并按规则重算封锁截止时间(就地修改 $st) */
|
||||
function _ip_login_prune(array &$st, int $now): void
|
||||
{
|
||||
$st['fail'] = array_values(array_filter((array)($st['fail'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_FAIL_WINDOW));
|
||||
$st['ok'] = array_values(array_filter((array)($st['ok'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_OK_WINDOW));
|
||||
if (!isset($st['block_until']) || !is_numeric($st['block_until'])) $st['block_until'] = 0;
|
||||
if ($st['block_until'] <= $now) {
|
||||
if (count($st['fail']) >= LOGIN_FAIL_LIMIT) {
|
||||
// 失败 5 次 / 10 分钟 → 锁 30 分钟
|
||||
$st['block_until'] = $now + LOGIN_BLOCK_SECS;
|
||||
} elseif (count($st['ok']) >= LOGIN_OK_LIMIT) {
|
||||
// 成功 5 次 / 30 分钟 → 锁到最早一次成功滑出窗口
|
||||
$oldest = min($st['ok']);
|
||||
$st['block_until'] = max($now + 60, $oldest + LOGIN_OK_WINDOW);
|
||||
}
|
||||
}
|
||||
}
|
||||
function ip_login_blocked(string $ip): bool
|
||||
{
|
||||
$now = time();
|
||||
$st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
return ($st['block_until'] ?? 0) > $now;
|
||||
}
|
||||
/** 返回剩余封锁秒数(已解封为 0),供 Retry-After 使用 */
|
||||
function ip_login_remaining(string $ip): int
|
||||
{
|
||||
$now = time();
|
||||
$st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
return max(0, (int)($st['block_until'] ?? 0) - $now);
|
||||
}
|
||||
function ip_login_register_fail(string $ip): void
|
||||
{
|
||||
$now = time();
|
||||
$data = _ip_login_load();
|
||||
$st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
$st['fail'][] = $now;
|
||||
_ip_login_prune($st, $now); // 追加后重新评估是否触发封锁
|
||||
$data[$ip] = $st;
|
||||
_ip_login_save($data);
|
||||
}
|
||||
function ip_login_register_success(string $ip): void
|
||||
{
|
||||
$now = time();
|
||||
$data = _ip_login_load();
|
||||
$st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
|
||||
_ip_login_prune($st, $now);
|
||||
$st['fail'] = []; // 成功登录重置失败计数(防爆破计数器归零)
|
||||
$st['ok'][] = $now; // 记录一次成功,纳入「30 分钟 5 次」上限
|
||||
_ip_login_prune($st, $now);
|
||||
$data[$ip] = $st;
|
||||
_ip_login_save($data);
|
||||
}
|
||||
function ip_login_clear(string $ip): void
|
||||
{
|
||||
$file = BASE_PATH . '/storage/login_ip.json';
|
||||
if (!is_file($file)) return;
|
||||
$data = json_decode(@file_get_contents($file), true) ?: [];
|
||||
$data = _ip_login_load();
|
||||
unset($data[$ip]);
|
||||
@file_put_contents($file, json_encode($data));
|
||||
_ip_login_save($data);
|
||||
}
|
||||
|
||||
/* ---------- 通用 IP 级限速(可用于任意提交场景,如联系表单) ---------- */
|
||||
@@ -745,3 +805,45 @@ if (!function_exists('site_url')) {
|
||||
@file_put_contents($file, json_encode($data));
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('page_seo')) {
|
||||
/**
|
||||
* 取页面 SEO(标题/描述/关键词/OG/规范链接/收录开关)。
|
||||
* 优先读 page_seo 表;无记录或字段缺失时退回控制器传入的默认值。
|
||||
* @param string $key page_key(home/products/news/cases/about/contact...)
|
||||
* @param array $default 默认 SEO 数组(title/description/keywords/og_type)
|
||||
* @return array {title,description,keywords,og_type,og_image,canonical,noindex}
|
||||
*/
|
||||
function page_seo(string $key, array $default = []): array
|
||||
{
|
||||
$def = array_merge([
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'keywords' => '',
|
||||
'og_type' => 'website',
|
||||
'og_image' => '',
|
||||
'canonical' => '',
|
||||
'noindex' => 0,
|
||||
], $default);
|
||||
|
||||
try {
|
||||
$row = (new \App\Models\PageSeo())->getByKey($key);
|
||||
} catch (\Throwable $e) {
|
||||
$row = null;
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
return $def;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $row['title'] ?? $def['title'],
|
||||
'description' => $row['description'] ?? $def['description'],
|
||||
'keywords' => $row['keywords'] ?? $def['keywords'],
|
||||
'og_type' => $row['og_type'] ?? $def['og_type'],
|
||||
'og_image' => $row['og_image'] ?? $def['og_image'],
|
||||
'canonical' => $row['canonical'] ?? $def['canonical'],
|
||||
'noindex' => $row['noindex'] ?? $def['noindex'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,21 @@ class Installer
|
||||
private static function ensureColumns($pdo, array &$msgs): void
|
||||
{
|
||||
$map = [
|
||||
'pages' => [
|
||||
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
|
||||
],
|
||||
'products' => [
|
||||
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
|
||||
],
|
||||
'news' => [
|
||||
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
|
||||
],
|
||||
'cases' => [
|
||||
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
|
||||
],
|
||||
'categories' => [
|
||||
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
|
||||
],
|
||||
'admin_users' => [
|
||||
'crm_role' => "VARCHAR(20) DEFAULT 'none'",
|
||||
'psi_role' => "VARCHAR(20) DEFAULT 'none'",
|
||||
|
||||
+3
-2
@@ -16,11 +16,12 @@ class Theme
|
||||
// 站点信息
|
||||
'site_name' => '酷冰甲 · 降温服',
|
||||
'site_slogan' => '科技降温 · 清凉一夏',
|
||||
'site_logo' => '',
|
||||
'site_logo' => 'assets/img/logo.png',
|
||||
'contact_phone' => '400-1783-998',
|
||||
'contact_email' => 'service@coolcoth.com',
|
||||
'contact_email' => 'service@st-joyapparel.com',
|
||||
'contact_address'=> '江苏省苏州市工业园区',
|
||||
'icp' => '',
|
||||
'gongan' => '', // 公安备案号(网安备),如 京公网安备11010802012345号
|
||||
'seo_title' => '酷冰甲降温服 - 科技降温服装定制',
|
||||
'seo_keywords' => '降温服, cooling clothing, 降温工作服, 清凉服定制',
|
||||
'seo_description'=> '酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。',
|
||||
|
||||
Reference in New Issue
Block a user