Pārlūkot izejas kodu

feat(admin): use dynamic app name, add group label/hint, and improve session security

- Replace hardcoded "BCJD" with APP_NAME constant in page titles and headers
- Fix stray character in db_init.php
- Add group_label and group_hint fields to the edit form in admin panel
- Enhance session cookie security by checking HTTPS status dynamically
caesar 2 mēneši atpakaļ
vecāks
revīzija
3e8ac4c1f4
8 mainītis faili ar 200 papildinājumiem un 24 dzēšanām
  1. 0 0
      cache/.gitkeep
  2. 1 1
      db_init.php
  3. 87 0
      public/404.php
  4. 13 13
      public/admin.php
  5. 2 2
      public/index.php
  6. 13 1
      src/Auth.php
  7. 76 3
      src/LinkManager.php
  8. 8 4
      src/Validator.php

+ 0 - 0
cache/.gitkeep


+ 1 - 1
db_init.php

@@ -1,4 +1,4 @@
-<?php
+<?php
 /**
  * BCJD 导航 - 数据库初始化脚本
  *

+ 87 - 0
public/404.php

@@ -0,0 +1,87 @@
+<?php
+/**
+ * BCJD 导航 - 404 页面
+ */
+
+require_once __DIR__ . '/../init.php';
+
+http_response_code(404);
+?><!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width,initial-scale=1" />
+  <meta name="color-scheme" content="light dark" />
+  <title><?= htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8') ?> - 页面未找到</title>
+  <link rel="stylesheet" href="style.css" />
+  <style>
+    .error-wrap {
+      text-align: center;
+      padding: 80px 20px;
+    }
+    .error-code {
+      font-size: 72px;
+      font-weight: 800;
+      line-height: 1;
+      background: linear-gradient(135deg, var(--danger), var(--primary));
+      -webkit-background-clip: text;
+      -webkit-text-fill-color: transparent;
+      background-clip: text;
+      margin-bottom: 16px;
+    }
+    .error-msg {
+      font-size: 18px;
+      color: var(--muted);
+      margin-bottom: 32px;
+    }
+    .error-link {
+      display: inline-block;
+      padding: 12px 28px;
+      border-radius: 999px;
+      background: var(--primary);
+      color: #fff;
+      text-decoration: none;
+      font-size: 15px;
+      transition: opacity .2s;
+    }
+    .error-link:hover {
+      opacity: .85;
+    }
+  </style>
+</head>
+<body class="index-page">
+  <div class="wrap">
+    <header>
+      <div class="brand">
+        <h1><a href="index.php" style="color:inherit;text-decoration:none"><?= htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8') ?></a></h1>
+        <div class="sub">统一入口 · 快速跳转</div>
+      </div>
+    </header>
+
+    <div class="error-wrap">
+      <div class="error-code">404</div>
+      <div class="error-msg">你访问的页面不存在</div>
+      <a href="index.php" class="error-link">← 返回首页</a>
+    </div>
+
+    <footer>
+      <div class="mono">Updated (UTC+8): <span id="ts">--</span></div>
+    </footer>
+  </div>
+
+  <script>
+    (function(){
+      'use strict';
+      const TZ = "Asia/Shanghai";
+      const fmt = new Intl.DateTimeFormat("zh-CN", {
+        timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit",
+        hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
+      });
+      function tick(){
+        document.getElementById("ts").textContent = fmt.format(new Date());
+      }
+      tick(); setInterval(tick, 1000);
+    })();
+  </script>
+</body>
+</html>

+ 13 - 13
public/admin.php

@@ -18,13 +18,13 @@ $csrfToken = Auth::getCsrfToken();
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width,initial-scale=1" />
   <meta name="color-scheme" content="light dark" />
-  <title>BCJD 导航 - 管理面板</title>
+  <title><?= htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8') ?> - 管理面板</title>
   <link rel="stylesheet" href="style.css" />
 </head>
 <body class="admin-page">
   <div class="wrap">
     <header>
-      <h1>BCJD 导航 · 管理面板</h1>
+      <h1><?= htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8') ?> · 管理面板</h1>
       <div class="header-actions">
         <a href="index.php" class="btn">← 返回首页</a>
         <button id="logoutBtn">退出登录</button>
@@ -160,26 +160,22 @@ $csrfToken = Auth::getCsrfToken();
         </div>
         <div class="form-row">
           <div class="form-group">
-            <label for="editGroup">分组名</label>
+            <label for="editGroup">分组名(英文标识)</label>
             <input type="text" id="editGroup" />
           </div>
           <div class="form-group">
-            <label for="editKeywords">关键词</label>
-            <input type="text" id="editKeywords" />
+            <label for="editGroupLabel">分组显示名称</label>
+            <input type="text" id="editGroupLabel" />
           </div>
         </div>
         <div class="form-row-3">
           <div class="form-group">
-            <label for="editTagLabel">标签</label>
-            <input type="text" id="editTagLabel" />
+            <label for="editKeywords">关键词</label>
+            <input type="text" id="editKeywords" />
           </div>
           <div class="form-group">
-            <label for="editTagClass">标签样式</label>
-            <select id="editTagClass">
-              <option value="">默认</option>
-              <option value="ok">绿色 (ok)</option>
-              <option value="warn">红色 (warn)</option>
-            </select>
+            <label for="editGroupHint">分组提示文字</label>
+            <input type="text" id="editGroupHint" />
           </div>
           <div class="form-group">
             <label for="editMetaDomain">域名</label>
@@ -336,6 +332,8 @@ $csrfToken = Auth::getCsrfToken();
       $('#editUrl').value = l.url;
       $('#editDesc').value = l.description || '';
       $('#editGroup').value = l.group_name;
+      $('#editGroupLabel').value = l.group_label || '';
+      $('#editGroupHint').value = l.group_hint || '';
       $('#editKeywords').value = l.keywords || '';
       $('#editTagLabel').value = l.tag_label || '';
       $('#editTagClass').value = l.tag_class || '';
@@ -357,6 +355,8 @@ $csrfToken = Auth::getCsrfToken();
           url: $('#editUrl').value,
           description: $('#editDesc').value,
           group_name: $('#editGroup').value,
+          group_label: $('#editGroupLabel').value,
+          group_hint: $('#editGroupHint').value,
           keywords: $('#editKeywords').value,
           tag_label: $('#editTagLabel').value,
           tag_class: $('#editTagClass').value,

+ 2 - 2
public/index.php

@@ -4,7 +4,7 @@
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width,initial-scale=1" />
   <meta name="color-scheme" content="light dark" />
-  <title>BCJD 导航</title>
+  <title><?= htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8') ?></title>
   <link rel="stylesheet" href="style.css" />
 </head>
 
@@ -12,7 +12,7 @@
   <div class="wrap">
     <header>
       <div class="brand">
-        <h1>BCJD 服务导航</h1>
+        <h1><?= htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8') ?></h1>
         <div class="sub">统一入口 · 快速跳转 · 显示时区:<span class="mono">UTC+8</span></div>
       </div>
 

+ 13 - 1
src/Auth.php

@@ -28,7 +28,7 @@ class Auth
         ini_set('session.use_strict_mode', '1');
         ini_set('session.cookie_httponly', '1');
         ini_set('session.cookie_samesite', 'Lax');
-        ini_set('session.cookie_secure', '0'); // 如果使用 HTTPS 可改为 1
+        ini_set('session.cookie_secure', self::isHttps() ? '1' : '0');
         ini_set('session.gc_maxlifetime', (string)SESSION_TIMEOUT);
 
         session_start();
@@ -135,6 +135,17 @@ class Auth
         return '<input type="hidden" name="csrf_token" value="' . self::getCsrfToken() . '">';
     }
 
+    /**
+     * 检测当前请求是否通过 HTTPS
+     */
+    private static function isHttps(): bool
+    {
+        return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
+            || (int)($_SERVER['SERVER_PORT'] ?? 0) === 443
+            || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
+            || (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] === 'on');
+    }
+
     /**
      * 添加安全响应头
      */
@@ -144,6 +155,7 @@ class Auth
         header('X-Content-Type-Options: nosniff');
         header('X-XSS-Protection: 1; mode=block');
         header('Referrer-Policy: strict-origin-when-cross-origin');
+        header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; form-action 'self'");
     }
 
     /**

+ 76 - 3
src/LinkManager.php

@@ -13,10 +13,61 @@ namespace BCJD;
 class LinkManager
 {
     private Database $db;
+    private string $cacheDir;
+    private int $cacheTtl = 60; // 缓存有效期(秒)
 
     public function __construct()
     {
         $this->db = Database::instance();
+        $this->cacheDir = __DIR__ . '/../cache';
+    }
+
+    /**
+     * 获取缓存文件路径
+     */
+    private function cachePath(string $key): string
+    {
+        return $this->cacheDir . '/links_' . md5($key) . '.json';
+    }
+
+    /**
+     * 读取缓存
+     */
+    private function cacheGet(string $key): ?array
+    {
+        $path = $this->cachePath($key);
+        if (!file_exists($path)) {
+            return null;
+        }
+        if (time() - filemtime($path) > $this->cacheTtl) {
+            @unlink($path);
+            return null;
+        }
+        $data = @file_get_contents($path);
+        if ($data === false) {
+            return null;
+        }
+        $decoded = json_decode($data, true);
+        return is_array($decoded) ? $decoded : null;
+    }
+
+    /**
+     * 写入缓存
+     */
+    private function cacheSet(string $key, array $data): void
+    {
+        $path = $this->cachePath($key);
+        @file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE), LOCK_EX);
+    }
+
+    /**
+     * 清除所有链接缓存
+     */
+    private function clearCache(): void
+    {
+        foreach (glob($this->cacheDir . '/links_*.json') as $file) {
+            @unlink($file);
+        }
     }
 
     /**
@@ -25,6 +76,13 @@ class LinkManager
     public function getLinks(): array
     {
         $isLoggedIn = Auth::isLoggedIn();
+        $cacheKey = $isLoggedIn ? 'auth' : 'guest';
+
+        // 尝试读取缓存
+        $cached = $this->cacheGet($cacheKey);
+        if ($cached !== null) {
+            return $cached;
+        }
 
         if ($isLoggedIn) {
             $rows = $this->db->fetchAll(
@@ -36,7 +94,12 @@ class LinkManager
             );
         }
 
-        return $this->groupLinks($rows);
+        $result = $this->groupLinks($rows);
+
+        // 写入缓存
+        $this->cacheSet($cacheKey, $result);
+
+        return $result;
     }
 
     /**
@@ -85,14 +148,16 @@ class LinkManager
 
         $this->db->execute(
             "INSERT INTO links 
-             (title, url, description, icon_svg, group_name, keywords, tag_label, tag_class, meta_domain, is_private, sort_order)
-             VALUES (:title, :url, :description, :icon_svg, :group_name, :keywords, :tag_label, :tag_class, :meta_domain, :is_private, :sort_order)",
+             (title, url, description, icon_svg, group_name, group_label, group_hint, keywords, tag_label, tag_class, meta_domain, is_private, sort_order)
+             VALUES (:title, :url, :description, :icon_svg, :group_name, :group_label, :group_hint, :keywords, :tag_label, :tag_class, :meta_domain, :is_private, :sort_order)",
             [
                 'title'       => $title,
                 'url'         => $url,
                 'description' => Validator::truncate($data['description'] ?? '', 255),
                 'icon_svg'    => Validator::sanitizeSvg($data['icon_svg'] ?? ''),
                 'group_name'  => Validator::validateGroupName($data['group_name'] ?? 'default'),
+                'group_label' => Validator::truncate($data['group_label'] ?? '', 50),
+                'group_hint'  => Validator::truncate($data['group_hint'] ?? '', 100),
                 'keywords'    => Validator::sanitizeKeywords($data['keywords'] ?? ''),
                 'tag_label'   => Validator::truncate($data['tag_label'] ?? '', 30),
                 'tag_class'   => Validator::validateTagClass($data['tag_class'] ?? ''),
@@ -102,6 +167,8 @@ class LinkManager
             ]
         );
 
+        $this->clearCache();
+
         return (int)$this->db->lastInsertId();
     }
 
@@ -124,6 +191,8 @@ class LinkManager
         if ($affected === 0) {
             Response::error('链接不存在', 404);
         }
+
+        $this->clearCache();
     }
 
     /**
@@ -144,6 +213,8 @@ class LinkManager
             'description' => fn($v) => Validator::truncate($v, 255),
             'icon_svg'    => fn($v) => Validator::sanitizeSvg($v ?? ''),
             'group_name'  => fn($v) => Validator::validateGroupName($v ?? 'default'),
+            'group_label' => fn($v) => Validator::truncate($v ?? '', 50),
+            'group_hint'  => fn($v) => Validator::truncate($v ?? '', 100),
             'keywords'    => fn($v) => Validator::sanitizeKeywords($v ?? ''),
             'tag_label'   => fn($v) => Validator::truncate($v ?? '', 30),
             'tag_class'   => fn($v) => Validator::validateTagClass($v ?? ''),
@@ -167,6 +238,8 @@ class LinkManager
 
         $sql = "UPDATE links SET " . implode(', ', $updates) . " WHERE id = :id";
         $this->db->execute($sql, $params);
+
+        $this->clearCache();
     }
 
     /**

+ 8 - 4
src/Validator.php

@@ -42,10 +42,14 @@ class Validator
      */
     public static function sanitizeSvg(string $svg): string
     {
-        // 移除 script/on* 等危险内容
-        $svg = strip_tags($svg, '<svg><path><circle><rect><line><polyline><polygon><ellipse><g><defs><use><text><tspan>');
-        // 移除 on* 事件属性
-        $svg = preg_replace('/\bon\w+\s*=\s*(["\']).*?\1/i', '', $svg);
+        // 只允许纯 SVG 标签(移除 script/style/foreignObject 等危险元素)
+        $svg = strip_tags($svg, '<svg><path><circle><rect><line><polyline><polygon><ellipse><g><defs><use><text><tspan><stop><linearGradient><radialGradient><filter><feGaussianBlur><feOffset><feColorMatrix><feBlend><feMerge><feMergeNode>');
+        // 移除 on* 事件属性(如 onclick, onload, onerror)
+        $svg = preg_replace('/\bon\w+\s*=\s*(["\']).*?\1/is', '', $svg);
+        // 移除 href 属性(防止通过 href="javascript:..." 执行脚本)
+        $svg = preg_replace('/\bhref\s*=\s*(["\']).*?\1/i', '', $svg);
+        // 移除 xlink:href(同上)
+        $svg = preg_replace('/\bxlink:href\s*=\s*(["\']).*?\1/i', '', $svg);
         return $svg;
     }