浏览代码

feat: add Caddy support and update security configurations

- Protect db_seed.php in .htaccess by adding it to the FilesMatch pattern
- Update README with Caddy installation and configuration steps, including Caddyfile example and security notes
- Remove inline CSS from template files to streamline styling
caesar 2 月之前
父节点
当前提交
4d8cc85754
共有 12 个文件被更改,包括 985 次插入845 次删除
  1. 1 1
      .htaccess
  2. 54 7
      README.md
  3. 5 2
      config.php
  4. 5 142
      db_init.php
  5. 167 0
      db_seed.php
  6. 4 183
      public/admin.php
  7. 1 1
      public/api.php
  8. 58 0
      public/app.js
  9. 4 502
      public/index.php
  10. 682 0
      public/style.css
  11. 3 6
      src/Auth.php
  12. 1 1
      src/LinkManager.php

+ 1 - 1
.htaccess

@@ -13,7 +13,7 @@
 # ============================================
 # 禁止直接访问敏感文件
 # ============================================
-<FilesMatch "(config\.php|db_init\.php|init\.php)$">
+<FilesMatch "(config\.php|db_init\.php|init\.php|db_seed\.php)$">
     <IfModule mod_authz_core.c>
         Require all denied
     </IfModule>

+ 54 - 7
README.md

@@ -17,9 +17,11 @@ bcjd.net 服务导航页面(PHP + MariaDB 版)
 
 - PHP 8.0+
 - MariaDB 10.3+ / MySQL 5.7+
-- Apache(mod_rewrite + mod_headers)
+- Web 服务器:Apache 或 **Caddy**
 - PDO 扩展(php-mysql)
 
+> 项目附带了 `.htaccess` 安全配置(Apache 专用)。如果你使用 **Caddy**,这些文件不会被读取,请参考下方 Caddyfile 配置来等效实现安全防护。
+
 ### 1. 克隆项目并配置
 
 ```bash
@@ -27,9 +29,11 @@ git clone https://github.com/你的用户名/bcjd-html.git /var/www/html
 # 或上传至服务器后解压
 ```
 
-项目根目录不应作为 Web 入口。**必须将 `public/` 目录设为服务器 DocumentRoot。**
+项目根目录不应作为 Web 入口。**必须将 `public/` 目录设为服务器 DocumentRoot(Apache)或 root 目录(Caddy)。**
+
+### 2. 配置 Web 服务器
 
-### 2. 配置 DocumentRoot
+#### Apache
 
 编辑 Apache 站点配置(如 `/etc/apache2/sites-available/bcjd.conf`):
 
@@ -45,12 +49,37 @@ git clone https://github.com/你的用户名/bcjd-html.git /var/www/html
 </VirtualHost>
 ```
 
-或者使用符号链接:
+#### Caddy
 
-```bash
-# ln -s /var/www/html/bcjd-html/public /var/www/html/你的Web目录
+由于 Caddy 的 `root` 指令会将网站根限制在 `public/` 目录内,项目根目录下的敏感文件(`config.php`、`init.php`、`db_*.php`、`src/` 等)天然无法被直接访问,无需额外配置路径拦截。
+
+编辑 Caddyfile:
+
+```caddy
+bcjd.net {
+    root * /var/www/html/bcjd-html/public
+    php_fastcgi unix//run/php/php8.x-fpm.sock
+    file_server
+
+    # 安全头
+    header {
+        X-Frame-Options "DENY"
+        X-Content-Type-Options "nosniff"
+        X-XSS-Protection "1; mode=block"
+        Referrer-Policy "strict-origin-when-cross-origin"
+    }
+
+    # 禁止浏览目录(默认已禁用,无需配置)
+
+    # 日志(可选)
+    log {
+        output file /var/log/caddy/bcjd.log
+    }
+}
 ```
 
+> 如果 PHP 版本不同,将 `php8.x` 改为实际版本号(如 `php8.2`)。运行 `php -v` 查看版本。
+
 ### 3. 配置数据库
 
 编辑 [`config.php`](config.php),修改以下参数:
@@ -69,7 +98,7 @@ define('DB_PASS', '你的数据库密码');
 脚本会自动创建数据库和表结构,并导入默认链接数据。
 
 **⚠️ 初始化完成后,请删除或重命名 `db_init.php` 文件以保证安全。**
-**⚠️ `.htaccess` 已自动禁止通过 Web 访问 `db_init.php`,如需使用请临时修改规则。**
+**⚠️ Apache 下 `.htaccess` 已自动禁止通过 Web 访问 `db_init.php`;Caddy 下由于 `root` 指令将网站根限制在 `public/` 内,根目录下的 `db_init.php` 天然无法被访问。如需使用请先将文件复制到 `public/` 目录内,用完后立即删除。**
 
 ### 5. 修改默认密码
 
@@ -84,6 +113,7 @@ bcjd-html/                      # 项目根目录(不对外暴露)
 ├── .htaccess                   # 顶层安全防护(禁止访问敏感文件/目录)
 ├── config.php                  # 数据库 & 密码配置
 ├── db_init.php                 # 数据库初始化脚本(用完删除)
+├── db_seed.php                 # 初始链接数据(默认链接在此自定义)
 ├── init.php                    # 系统引导(自动加载 + 安全头 + 时区)
 ├── src/                        # OOP 类库(命名空间:BCJD\)
 │   ├── AutoLoader.php          # PSR-4 自动加载器
@@ -122,3 +152,20 @@ bcjd-html/                      # 项目根目录(不对外暴露)
 | **信息泄露** | PDO 异常记录日志,用户仅见通用错误提示 |
 | **HTTP 安全头** | X-Frame-Options / X-Content-Type-Options / X-XSS-Protection / Referrer-Policy |
 | **目录隔离** | 敏感代码在 Web 根目录之外,无法直接访问 |
+| **文件级防护** | .htaccess 禁止访问 config/init/db_seed 等敏感文件 |
+
+## 自定义链接数据
+
+项目初始包含 9 条默认链接(Monica、Gogs、Docker、Webmin、phpMyAdmin、Mail、三个站点)。
+
+如需自定义默认数据,编辑 [`db_seed.php`](db_seed.php) 中的 `get_seed_links()` 数组即可,不需要修改 `db_init.php`。
+
+```php
+// db_seed.php - 修改后重新运行 db_init.php 即可
+function get_seed_links(): array
+{
+    return [
+        // 在这里添加/修改你的默认链接
+    ];
+}
+```

+ 5 - 2
config.php

@@ -22,5 +22,8 @@ define('DB_CHARSET', 'utf8mb4');
 define('SITE_PASSWORD_HASH', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi');
 
 // ---- 应用配置 ----
-define('APP_NAME',       'BCJD 导航');
-define('SESSION_TIMEOUT', 1800); // Session 超时(秒)
+define('APP_NAME',        'BCJD 导航');
+define('SESSION_TIMEOUT',  1800); // Session 超时(秒)
+define('LOGIN_DELAY',      500);  // 登录失败延迟(毫秒,防暴力破解)
+define('BCRYPT_COST',      10);   // bcrypt 哈希成本因子
+define('CORS_ORIGIN',      '*');  // CORS 允许的来源(可改为具体域名如 'https://example.com')

+ 5 - 142
db_init.php

@@ -1,4 +1,4 @@
-<?php
+<?php
 /**
  * BCJD 导航 - 数据库初始化脚本
  *
@@ -53,152 +53,15 @@ try {
     if ($count > 0) {
         echo "⚠️ links 表已有 {$count} 条记录,跳过数据导入<br>\n";
     } else {
+        require_once __DIR__ . '/db_seed.php';
+
+        $links = get_seed_links();
+
         $stmt = $pdo->prepare("
             INSERT INTO `links` (`title`, `url`, `description`, `icon_svg`, `group_name`, `group_label`, `group_hint`, `keywords`, `tag_label`, `tag_class`, `meta_domain`, `sort_order`, `is_private`)
             VALUES (:title, :url, :description, :icon_svg, :group_name, :group_label, :group_hint, :keywords, :tag_label, :tag_class, :meta_domain, :sort_order, :is_private)
         ");
 
-        $links = [
-            // ===== 应用 - 公开 =====
-            [
-                'title'       => 'Monica',
-                'url'         => 'https://monica.bcjd.net',
-                'description' => '个人关系/联系人管理类应用入口。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 21s-7-4.4-9-9.2C1.3 8 3.4 5 6.8 5c1.8 0 3 .9 3.7 1.8C11.2 5.9 12.4 5 14.2 5 17.6 5 19.7 8 21 11.8 19 16.6 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/></svg>',
-                'group_name'  => 'apps',
-                'group_label' => '应用',
-                'group_hint'  => '常用入口',
-                'keywords'    => 'monica crm 联系人 个人 管理',
-                'tag_label'   => 'App',
-                'tag_class'   => 'ok',
-                'meta_domain' => 'monica.bcjd.net',
-                'sort_order'  => 1,
-                'is_private'  => 0,
-            ],
-            [
-                'title'       => 'Gogs',
-                'url'         => 'https://gogs.bcjd.net',
-                'description' => 'Git 仓库服务入口(代码托管/协作)。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M7 7h10v10H7V7Z" stroke="currentColor" stroke-width="1.7"/><path d="M9 9h6M9 12h6M9 15h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
-                'group_name'  => 'apps',
-                'group_label' => '应用',
-                'group_hint'  => '常用入口',
-                'keywords'    => 'gogs git 代码 仓库 dev',
-                'tag_label'   => 'Dev',
-                'tag_class'   => 'ok',
-                'meta_domain' => 'gogs.bcjd.net',
-                'sort_order'  => 2,
-                'is_private'  => 0,
-            ],
-            [
-                'title'       => 'Docker',
-                'url'         => 'https://docker.bcjd.net',
-                'description' => '容器管理入口(例如 Portainer)。建议仅管理员使用。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 7.5 12 3l8 4.5v9L12 21l-8-4.5v-9Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 3v18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" opacity=".75"/><path d="M4 7.5 12 12l8-4.5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" opacity=".75"/></svg>',
-                'group_name'  => 'apps',
-                'group_label' => '应用',
-                'group_hint'  => '常用入口',
-                'keywords'    => 'docker portainer 容器 运维 管理',
-                'tag_label'   => 'Ops',
-                'tag_class'   => 'ok',
-                'meta_domain' => 'docker.bcjd.net',
-                'sort_order'  => 3,
-                'is_private'  => 1,
-            ],
-            // ===== 运维/管理 - 私有 =====
-            [
-                'title'       => 'Webmin',
-                'url'         => 'https://webmin.bcjd.net',
-                'description' => '服务器管理面板入口。请确认账号安全与访问来源。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 2l8 4v6c0 5-3.5 9.4-8 10-4.5-.6-8-5-8-10V6l8-4Z" stroke="currentColor" stroke-width="1.7"/></svg>',
-                'group_name'  => 'ops',
-                'group_label' => '运维 / 管理',
-                'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
-                'keywords'    => 'webmin 运维 管理 面板 linux',
-                'tag_label'   => 'Admin',
-                'tag_class'   => 'warn',
-                'meta_domain' => 'webmin.bcjd.net',
-                'sort_order'  => 10,
-                'is_private'  => 1,
-            ],
-            [
-                'title'       => 'phpMyAdmin',
-                'url'         => 'https://phpmyadmin.bcjd.net',
-                'description' => '数据库管理入口。请避免在不可信网络下使用。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M6 7c0 2.2 2.7 4 6 4s6-1.8 6-4-2.7-4-6-4-6 1.8-6 4Z" stroke="currentColor" stroke-width="1.7"/><path d="M6 7v10c0 2.2 2.7 4 6 4s6-1.8 6-4V7" stroke="currentColor" stroke-width="1.7"/></svg>',
-                'group_name'  => 'ops',
-                'group_label' => '运维 / 管理',
-                'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
-                'keywords'    => 'phpmyadmin mysql mariadb 数据库 管理',
-                'tag_label'   => 'DB',
-                'tag_class'   => 'warn',
-                'meta_domain' => 'phpmyadmin.bcjd.net',
-                'sort_order'  => 11,
-                'is_private'  => 1,
-            ],
-            [
-                'title'       => 'Mail',
-                'url'         => 'https://mail.bcjd.net',
-                'description' => '邮箱系统入口(WebMail/邮件访问)。建议注意账号与设备安全。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16v10H4V7Z" stroke="currentColor" stroke-width="1.7"/><path d="M4 8l8 6 8-6" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>',
-                'group_name'  => 'ops',
-                'group_label' => '运维 / 管理',
-                'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
-                'keywords'    => 'mail 邮箱 邮件 webmail 收件箱 管理 运维 smtp imap',
-                'tag_label'   => 'Mail',
-                'tag_class'   => 'warn',
-                'meta_domain' => 'mail.bcjd.net',
-                'sort_order'  => 12,
-                'is_private'  => 1,
-            ],
-            // ===== 站点 - 公开 =====
-            [
-                'title'       => 'www.bcjd.net',
-                'url'         => 'https://www.bcjd.net',
-                'description' => '网站主页/内容入口。你可以把此导航页部署在这里。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1v-9.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>',
-                'group_name'  => 'sites',
-                'group_label' => '站点',
-                'group_hint'  => '对外主页 / 内容入口',
-                'keywords'    => 'bcjd 主站 www 首页',
-                'tag_label'   => 'Web',
-                'tag_class'   => 'ok',
-                'meta_domain' => '主站入口',
-                'sort_order'  => 20,
-                'is_private'  => 0,
-            ],
-            [
-                'title'       => 'www.qyqy.org',
-                'url'         => 'https://www.qyqy.org',
-                'description' => 'qyqy.org 的 www 入口。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 6h16v12H4V6Z" stroke="currentColor" stroke-width="1.7"/><path d="M7 9h10M7 12h8M7 15h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
-                'group_name'  => 'sites',
-                'group_label' => '站点',
-                'group_hint'  => '对外主页 / 内容入口',
-                'keywords'    => 'qyqy 网站 www',
-                'tag_label'   => 'Web',
-                'tag_class'   => '',
-                'meta_domain' => '站点入口',
-                'sort_order'  => 21,
-                'is_private'  => 0,
-            ],
-            [
-                'title'       => 'www.mahaoyun.com',
-                'url'         => 'https://www.mahaoyun.com',
-                'description' => 'mahaoyun.com 的 www 入口。',
-                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 3l9 6-9 6-9-6 9-6Z" stroke="currentColor" stroke-width="1.7"/><path d="M3 9v8l9 6 9-6V9" stroke="currentColor" stroke-width="1.7"/></svg>',
-                'group_name'  => 'sites',
-                'group_label' => '站点',
-                'group_hint'  => '对外主页 / 内容入口',
-                'keywords'    => 'mahaoyun 网站 www',
-                'tag_label'   => 'Web',
-                'tag_class'   => '',
-                'meta_domain' => '站点入口',
-                'sort_order'  => 22,
-                'is_private'  => 0,
-            ],
-        ];
-
         foreach ($links as $link) {
             $stmt->execute($link);
         }

+ 167 - 0
db_seed.php

@@ -0,0 +1,167 @@
+<?php
+/**
+ * BCJD 导航 - 初始数据(种子数据)
+ *
+ * 此文件由 db_init.php 自动调用,在数据库表创建后导入默认链接数据。
+ * 如果需要自定义初始链接,修改此文件即可。
+ *
+ * 每条记录字段说明:
+ *   title       - 链接标题
+ *   url         - 链接地址
+ *   description - 描述文字
+ *   icon_svg    - SVG 图标代码
+ *   group_name  - 分组名称(英文标识)
+ *   group_label - 分组显示标签
+ *   group_hint  - 分组提示文字(可含 HTML)
+ *   keywords    - 搜索关键词(空格分隔)
+ *   tag_label   - 标签文字
+ *   tag_class   - 标签样式类(ok / warn / 空)
+ *   meta_domain - 域名/元信息
+ *   sort_order  - 排序序号
+ *   is_private  - 是否私有(1=需登录, 0=公开)
+ *
+ * @return array
+ */
+function get_seed_links(): array
+{
+    return [
+        // ===== 应用 - 公开 =====
+        [
+            'title'       => 'Monica',
+            'url'         => 'https://monica.bcjd.net',
+            'description' => '个人关系/联系人管理类应用入口。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 21s-7-4.4-9-9.2C1.3 8 3.4 5 6.8 5c1.8 0 3 .9 3.7 1.8C11.2 5.9 12.4 5 14.2 5 17.6 5 19.7 8 21 11.8 19 16.6 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/></svg>',
+            'group_name'  => 'apps',
+            'group_label' => '应用',
+            'group_hint'  => '常用入口',
+            'keywords'    => 'monica crm 联系人 个人 管理',
+            'tag_label'   => 'App',
+            'tag_class'   => 'ok',
+            'meta_domain' => 'monica.bcjd.net',
+            'sort_order'  => 1,
+            'is_private'  => 0,
+        ],
+        [
+            'title'       => 'Gogs',
+            'url'         => 'https://gogs.bcjd.net',
+            'description' => 'Git 仓库服务入口(代码托管/协作)。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M7 7h10v10H7V7Z" stroke="currentColor" stroke-width="1.7"/><path d="M9 9h6M9 12h6M9 15h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
+            'group_name'  => 'apps',
+            'group_label' => '应用',
+            'group_hint'  => '常用入口',
+            'keywords'    => 'gogs git 代码 仓库 dev',
+            'tag_label'   => 'Dev',
+            'tag_class'   => 'ok',
+            'meta_domain' => 'gogs.bcjd.net',
+            'sort_order'  => 2,
+            'is_private'  => 0,
+        ],
+        [
+            'title'       => 'Docker',
+            'url'         => 'https://docker.bcjd.net',
+            'description' => '容器管理入口(例如 Portainer)。建议仅管理员使用。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 7.5 12 3l8 4.5v9L12 21l-8-4.5v-9Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 3v18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" opacity=".75"/><path d="M4 7.5 12 12l8-4.5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" opacity=".75"/></svg>',
+            'group_name'  => 'apps',
+            'group_label' => '应用',
+            'group_hint'  => '常用入口',
+            'keywords'    => 'docker portainer 容器 运维 管理',
+            'tag_label'   => 'Ops',
+            'tag_class'   => 'ok',
+            'meta_domain' => 'docker.bcjd.net',
+            'sort_order'  => 3,
+            'is_private'  => 1,
+        ],
+        // ===== 运维/管理 - 私有 =====
+        [
+            'title'       => 'Webmin',
+            'url'         => 'https://webmin.bcjd.net',
+            'description' => '服务器管理面板入口。请确认账号安全与访问来源。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 2l8 4v6c0 5-3.5 9.4-8 10-4.5-.6-8-5-8-10V6l8-4Z" stroke="currentColor" stroke-width="1.7"/></svg>',
+            'group_name'  => 'ops',
+            'group_label' => '运维 / 管理',
+            'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
+            'keywords'    => 'webmin 运维 管理 面板 linux',
+            'tag_label'   => 'Admin',
+            'tag_class'   => 'warn',
+            'meta_domain' => 'webmin.bcjd.net',
+            'sort_order'  => 10,
+            'is_private'  => 1,
+        ],
+        [
+            'title'       => 'phpMyAdmin',
+            'url'         => 'https://phpmyadmin.bcjd.net',
+            'description' => '数据库管理入口。请避免在不可信网络下使用。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M6 7c0 2.2 2.7 4 6 4s6-1.8 6-4-2.7-4-6-4-6 1.8-6 4Z" stroke="currentColor" stroke-width="1.7"/><path d="M6 7v10c0 2.2 2.7 4 6 4s6-1.8 6-4V7" stroke="currentColor" stroke-width="1.7"/></svg>',
+            'group_name'  => 'ops',
+            'group_label' => '运维 / 管理',
+            'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
+            'keywords'    => 'phpmyadmin mysql mariadb 数据库 管理',
+            'tag_label'   => 'DB',
+            'tag_class'   => 'warn',
+            'meta_domain' => 'phpmyadmin.bcjd.net',
+            'sort_order'  => 11,
+            'is_private'  => 1,
+        ],
+        [
+            'title'       => 'Mail',
+            'url'         => 'https://mail.bcjd.net',
+            'description' => '邮箱系统入口(WebMail/邮件访问)。建议注意账号与设备安全。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16v10H4V7Z" stroke="currentColor" stroke-width="1.7"/><path d="M4 8l8 6 8-6" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>',
+            'group_name'  => 'ops',
+            'group_label' => '运维 / 管理',
+            'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
+            'keywords'    => 'mail 邮箱 邮件 webmail 收件箱 管理 运维 smtp imap',
+            'tag_label'   => 'Mail',
+            'tag_class'   => 'warn',
+            'meta_domain' => 'mail.bcjd.net',
+            'sort_order'  => 12,
+            'is_private'  => 1,
+        ],
+        // ===== 站点 - 公开 =====
+        [
+            'title'       => 'www.bcjd.net',
+            'url'         => 'https://www.bcjd.net',
+            'description' => '网站主页/内容入口。你可以把此导航页部署在这里。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1v-9.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>',
+            'group_name'  => 'sites',
+            'group_label' => '站点',
+            'group_hint'  => '对外主页 / 内容入口',
+            'keywords'    => 'bcjd 主站 www 首页',
+            'tag_label'   => 'Web',
+            'tag_class'   => 'ok',
+            'meta_domain' => '主站入口',
+            'sort_order'  => 20,
+            'is_private'  => 0,
+        ],
+        [
+            'title'       => 'www.qyqy.org',
+            'url'         => 'https://www.qyqy.org',
+            'description' => 'qyqy.org 的 www 入口。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 6h16v12H4V6Z" stroke="currentColor" stroke-width="1.7"/><path d="M7 9h10M7 12h8M7 15h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
+            'group_name'  => 'sites',
+            'group_label' => '站点',
+            'group_hint'  => '对外主页 / 内容入口',
+            'keywords'    => 'qyqy 网站 www',
+            'tag_label'   => 'Web',
+            'tag_class'   => '',
+            'meta_domain' => '站点入口',
+            'sort_order'  => 21,
+            'is_private'  => 0,
+        ],
+        [
+            'title'       => 'www.mahaoyun.com',
+            'url'         => 'https://www.mahaoyun.com',
+            'description' => 'mahaoyun.com 的 www 入口。',
+            'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 3l9 6-9 6-9-6 9-6Z" stroke="currentColor" stroke-width="1.7"/><path d="M3 9v8l9 6 9-6V9" stroke="currentColor" stroke-width="1.7"/></svg>',
+            'group_name'  => 'sites',
+            'group_label' => '站点',
+            'group_hint'  => '对外主页 / 内容入口',
+            'keywords'    => 'mahaoyun 网站 www',
+            'tag_label'   => 'Web',
+            'tag_class'   => '',
+            'meta_domain' => '站点入口',
+            'sort_order'  => 22,
+            'is_private'  => 0,
+        ],
+    ];
+}

+ 4 - 183
public/admin.php

@@ -19,180 +19,9 @@ $csrfToken = Auth::getCsrfToken();
   <meta name="viewport" content="width=device-width,initial-scale=1" />
   <meta name="color-scheme" content="light dark" />
   <title>BCJD 导航 - 管理面板</title>
-  <style>
-    :root{
-      --bg: #0b1020;
-      --panel: rgba(255,255,255,.06);
-      --panel-2: rgba(255,255,255,.10);
-      --text: rgba(255,255,255,.92);
-      --muted: rgba(255,255,255,.65);
-      --border: rgba(255,255,255,.12);
-      --shadow: 0 10px 30px rgba(0,0,0,.35);
-      --accent: #6ea8ff;
-      --accent2: #7cf7d4;
-      --danger: #ff6b6b;
-      --ok: #3ddc97;
-      --max: 1120px;
-      --radius: 18px;
-      --radius-sm: 12px;
-      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
-      --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif;
-    }
-    html[data-theme="light"]{
-      --bg: #f6f7fb;
-      --panel: rgba(0,0,0,.04);
-      --panel-2: rgba(0,0,0,.06);
-      --text: rgba(0,0,0,.88);
-      --muted: rgba(0,0,0,.58);
-      --border: rgba(0,0,0,.10);
-      --shadow: 0 10px 30px rgba(0,0,0,.12);
-      --accent: #245bdb;
-      --accent2: #0aa37f;
-      --danger: #d64545;
-      --ok: #0a7f5f;
-    }
-    @media (prefers-color-scheme: light){
-      html:not([data-theme="dark"]){
-        --bg: #f6f7fb;
-        --panel: rgba(0,0,0,.04);
-        --panel-2: rgba(0,0,0,.06);
-        --text: rgba(0,0,0,.88);
-        --muted: rgba(0,0,0,.58);
-        --border: rgba(0,0,0,.10);
-        --shadow: 0 10px 30px rgba(0,0,0,.12);
-        --accent: #245bdb;
-        --accent2: #0aa37f;
-        --danger: #d64545;
-        --ok: #0a7f5f;
-      }
-    }
-    *{ box-sizing:border-box; }
-    body{
-      margin:0;
-      min-height:100vh;
-      font-family: var(--sans);
-      color: var(--text);
-      background: var(--bg);
-    }
-    a{ color:var(--accent); text-decoration:none; }
-    a:hover{ text-decoration:underline; }
-    .wrap{
-      max-width: var(--max);
-      margin: 0 auto;
-      padding: 30px 18px 44px;
-    }
-    header{
-      display:flex;
-      justify-content:space-between;
-      align-items:center;
-      gap:18px;
-      margin-bottom: 24px;
-    }
-    header h1{ margin:0; font-size:22px; font-weight:800; }
-    .header-actions{ display:flex; gap:10px; align-items:center; }
-
-    button, .btn{
-      height: 40px; display:inline-flex; align-items:center; justify-content:center;
-      padding: 0 16px; border-radius:999px; border:1px solid var(--border);
-      background: var(--panel); box-shadow: var(--shadow); color: var(--text);
-      cursor:pointer; font-family: var(--sans); font-size:13px; font-weight:600;
-      transition: background .15s; white-space:nowrap;
-    }
-    button:hover, .btn:hover{ background: var(--panel-2); border-color: rgba(110,168,255,.28); }
-    .btn-primary{ background: var(--accent); border-color: var(--accent); color: #fff; }
-    .btn-primary:hover{ opacity:.88; border-color:var(--accent); background:var(--accent); }
-    .btn-danger{ background: rgba(255,107,107,.15); border-color: rgba(255,107,107,.35); color: var(--danger); }
-    .btn-danger:hover{ background: rgba(255,107,107,.25); }
-    .btn-sm{ height:34px; padding:0 12px; font-size:12px; }
-
-    .tabs{
-      display:flex; gap:4px; margin-bottom: 24px;
-      border-bottom:1px solid var(--border); padding-bottom:0;
-    }
-    .tab{
-      padding:10px 18px; border-radius:12px 12px 0 0; cursor:pointer;
-      font-size:14px; font-weight:600; color: var(--muted);
-      border:1px solid transparent; border-bottom:none; transition: all .12s; background:transparent;
-    }
-    .tab:hover{ color:var(--text); background:var(--panel); }
-    .tab.active{ color:var(--accent); border-color:var(--border); background:var(--panel); }
-    .tab-content{ display:none; }
-    .tab-content.active{ display:block; }
-
-    .link-item{
-      display:flex; align-items:center; gap:12px; padding:12px 14px;
-      border-radius:var(--radius-sm); border:1px solid var(--border);
-      background:var(--panel); margin-bottom:8px; transition:background .12s;
-    }
-    .link-item:hover{ background:var(--panel-2); }
-    .link-item .info{ flex:1; min-width:0; }
-    .link-item .info .title{ font-weight:700; font-size:14px; }
-    .link-item .info .url{
-      font-size:12px; color:var(--muted); font-family:var(--mono);
-      white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
-    }
-    .link-item .badge{
-      font-size:11px; padding:3px 8px; border-radius:999px;
-      border:1px solid var(--border); background:var(--panel-2); color:var(--muted); white-space:nowrap;
-    }
-    .link-item .badge.private{
-      border-color:rgba(255,107,107,.35); background:rgba(255,107,107,.08); color:var(--danger);
-    }
-    .link-item .item-actions{ display:flex; gap:6px; flex-shrink:0; }
-
-    .form-group{ margin-bottom:16px; }
-    .form-group label{
-      display:block; font-size:13px; font-weight:600; margin-bottom:6px; color:var(--muted);
-    }
-    .form-group input, .form-group select, .form-group textarea{
-      width:100%; height:42px; padding:0 14px; border-radius:999px;
-      border:1px solid var(--border); background:var(--panel); color:var(--text);
-      font-size:14px; font-family:var(--sans); outline:none; transition:border-color .15s;
-    }
-    .form-group textarea{
-      height:auto; min-height:60px; padding:10px 14px; border-radius:var(--radius-sm);
-      resize:vertical; font-family:var(--mono); font-size:13px;
-    }
-    .form-group input:focus, .form-group select:focus, .form-group textarea:focus{ border-color:var(--accent); }
-    .form-group select option{ background:var(--bg); color:var(--text); }
-    .form-row{ display:grid; grid-template-columns:1fr 1fr; gap:14px; }
-    .form-row-3{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:14px; }
-    .checkbox-row{
-      display:flex; align-items:center; gap:10px; margin-bottom:16px;
-    }
-    .checkbox-row input[type="checkbox"]{ width:18px; height:18px; accent-color:var(--accent); }
-
-    .toast{
-      display:none; position:fixed; bottom:30px; left:50%; transform:translateX(-50%);
-      padding:12px 24px; border-radius:999px; background:var(--panel-2);
-      border:1px solid var(--border); backdrop-filter:blur(10px); box-shadow:var(--shadow);
-      font-size:14px; z-index:999;
-    }
-    .toast.show{ display:block; }
-    .toast.success{ border-color:rgba(61,220,151,.35); color:var(--ok); }
-    .toast.error{ border-color:rgba(255,107,107,.35); color:var(--danger); }
-
-    .modal-overlay{
-      display:none; position:fixed; inset:0; background:rgba(0,0,0,.55);
-      backdrop-filter:blur(4px); z-index:1000; align-items:center; justify-content:center;
-    }
-    .modal-overlay.active{ display:flex; }
-    .modal-box{
-      width:520px; max-width:92vw; max-height:90vh; overflow-y:auto;
-      padding:28px; border-radius:var(--radius); border:1px solid var(--border);
-      background:var(--bg); box-shadow:0 20px 60px rgba(0,0,0,.5);
-    }
-    .modal-box h2{ margin:0 0 18px; font-size:20px; font-weight:800; }
-    .modal-box .btn-row{ display:flex; gap:10px; margin-top:20px; justify-content:flex-end; }
-
-    @media (max-width: 720px){
-      .form-row, .form-row-3{ grid-template-columns:1fr; }
-      .link-item{ flex-wrap:wrap; }
-      .link-item .item-actions{ width:100%; justify-content:flex-end; }
-    }
-  </style>
+  <link rel="stylesheet" href="style.css" />
 </head>
-<body>
+<body class="admin-page">
   <div class="wrap">
     <header>
       <h1>BCJD 导航 · 管理面板</h1>
@@ -376,24 +205,16 @@ $csrfToken = Auth::getCsrfToken();
   <!-- Toast -->
   <div class="toast" id="toast"></div>
 
+  <script src="app.js"></script>
   <script>
     (function(){
     'use strict';
 
     const $ = (s) => document.querySelector(s);
     const $$ = (s) => [...document.querySelectorAll(s)];
-    const esc = (s) => (s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
 
     function getCsrf(){ return $('#csrfToken').value; }
 
-    function toast(msg, type = 'success'){
-      const t = $('#toast');
-      t.textContent = msg;
-      t.className = 'toast show ' + type;
-      clearTimeout(t._timer);
-      t._timer = setTimeout(() => t.classList.remove('show'), 3000);
-    }
-
     // ===== API =====
     async function apiGet(action){
       const r = await fetch(`api.php?action=${action}`);
@@ -602,4 +423,4 @@ $csrfToken = Auth::getCsrfToken();
     })();
   </script>
 </body>
-</html>
+</html>

+ 1 - 1
public/api.php

@@ -23,7 +23,7 @@ use BCJD\LinkManager;
 use BCJD\Response;
 
 // ---- CORS 头 ----
-header('Access-Control-Allow-Origin: *');
+header('Access-Control-Allow-Origin: ' . CORS_ORIGIN);
 header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
 header('Access-Control-Allow-Headers: Content-Type, X-CSRF-Token');
 

+ 58 - 0
public/app.js

@@ -0,0 +1,58 @@
+/* ============================================
+   BCJD 导航 - 公共 JavaScript
+   ============================================ */
+
+'use strict';
+
+/**
+ * HTML 转义(XSS 防护)
+ */
+function esc(s) {
+  return (s || '').replace(/[&<>"']/g, function(m) {
+    return {'&': '&', '<': '<', '>': '>', '"': '"', "'": '&#039;'}[m];
+  });
+}
+
+/**
+ * API GET 请求
+ */
+async function apiGet(action) {
+  const r = await fetch('api.php?action=' + action);
+  if (!r.ok) {
+    const e = await r.json();
+    throw new Error(e.error || '请求失败');
+  }
+  return r.json();
+}
+
+/**
+ * API POST 请求(可附带 CSRF Token)
+ */
+async function apiPost(action, data, csrfToken) {
+  const fd = new URLSearchParams();
+  if (csrfToken) fd.append('csrf_token', csrfToken);
+  for (const [k, v] of Object.entries(data)) fd.append(k, v);
+  const r = await fetch('api.php?action=' + action, {
+    method: 'POST',
+    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+    body: fd.toString(),
+  });
+  if (!r.ok) {
+    const e = await r.json();
+    throw new Error(e.error || '请求失败');
+  }
+  return r.json();
+}
+
+/**
+ * Toast 通知
+ */
+function toast(msg, type, toastEl) {
+  if (!toastEl) return;
+  toastEl.textContent = msg;
+  toastEl.className = 'toast show ' + (type || 'success');
+  clearTimeout(toastEl._timer);
+  toastEl._timer = setTimeout(function() {
+    toastEl.classList.remove('show');
+  }, 3000);
+}

+ 4 - 502
public/index.php

@@ -5,505 +5,10 @@
   <meta name="viewport" content="width=device-width,initial-scale=1" />
   <meta name="color-scheme" content="light dark" />
   <title>BCJD 导航</title>
-  <style>
-    :root{
-      --bg: #0b1020;
-      --panel: rgba(255,255,255,.06);
-      --panel-2: rgba(255,255,255,.10);
-      --text: rgba(255,255,255,.92);
-      --muted: rgba(255,255,255,.65);
-      --border: rgba(255,255,255,.12);
-      --shadow: 0 10px 30px rgba(0,0,0,.35);
-      --accent: #6ea8ff;
-      --accent2: #7cf7d4;
-      --danger: #ff6b6b;
-      --ok: #3ddc97;
-      --max: 1120px;
-      --radius: 18px;
-      --radius-sm: 12px;
-      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
-      --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif;
-    }
-
-    html[data-theme="light"]{
-      --bg: #f6f7fb;
-      --panel: rgba(0,0,0,.04);
-      --panel-2: rgba(0,0,0,.06);
-      --text: rgba(0,0,0,.88);
-      --muted: rgba(0,0,0,.58);
-      --border: rgba(0,0,0,.10);
-      --shadow: 0 10px 30px rgba(0,0,0,.12);
-      --accent: #245bdb;
-      --accent2: #0aa37f;
-      --danger: #d64545;
-      --ok: #0a7f5f;
-    }
-
-    @media (prefers-color-scheme: light){
-      html:not([data-theme="dark"]){
-        --bg: #f6f7fb;
-        --panel: rgba(0,0,0,.04);
-        --panel-2: rgba(0,0,0,.06);
-        --text: rgba(0,0,0,.88);
-        --muted: rgba(0,0,0,.58);
-        --border: rgba(0,0,0,.10);
-        --shadow: 0 10px 30px rgba(0,0,0,.12);
-        --accent: #245bdb;
-        --accent2: #0aa37f;
-        --danger: #d64545;
-        --ok: #0a7f5f;
-      }
-    }
-
-    *{ box-sizing:border-box; }
-    html{ scroll-behavior:smooth; }
-    body{
-      margin:0;
-      min-height:100vh;
-      font-family: var(--sans);
-      color: var(--text);
-      background:
-        radial-gradient(1000px 520px at 12% 12%, rgba(110,168,255,.18), transparent 55%),
-        radial-gradient(900px 460px at 88% 20%, rgba(124,247,212,.14), transparent 55%),
-        radial-gradient(860px 520px at 35% 100%, rgba(255,107,107,.08), transparent 55%),
-        var(--bg);
-    }
-
-    a{ color:inherit; text-decoration:none; }
-
-    .wrap{
-      max-width: var(--max);
-      margin: 0 auto;
-      padding: 30px 18px 44px;
-    }
-
-    header{
-      display:flex;
-      justify-content:space-between;
-      align-items:flex-start;
-      gap:18px;
-      margin-bottom: 18px;
-    }
-
-    .brand{
-      display:flex;
-      flex-direction:column;
-      gap:6px;
-    }
-    .brand h1{
-      margin:0;
-      font-size: 24px;
-      font-weight: 800;
-      letter-spacing:.2px;
-    }
-    .brand .sub{
-      font-size:13px;
-      color: var(--muted);
-      line-height:1.45;
-    }
-
-    .toolbar{
-      display:flex;
-      align-items:center;
-      justify-content:flex-end;
-      gap:10px;
-      flex-wrap:wrap;
-    }
-
-    .pill,
-    button{
-      height: 42px;
-      display:inline-flex;
-      align-items:center;
-      justify-content:center;
-      border-radius:999px;
-      border:1px solid var(--border);
-      background: var(--panel);
-      box-shadow: var(--shadow);
-      backdrop-filter: blur(10px);
-      color: var(--text);
-    }
-
-    .pill{
-      padding: 0 14px;
-      font-family: var(--mono);
-      white-space:nowrap;
-    }
-
-    button{
-      padding: 0 14px;
-      cursor:pointer;
-      transition: transform .06s ease, background .15s ease, border-color .15s ease;
-      font-family: var(--sans);
-      font-size:13px;
-    }
-    button:hover{
-      background: var(--panel-2);
-      border-color: rgba(110,168,255,.28);
-    }
-    button:active{ transform: translateY(1px); }
-
-    .search{
-      display:flex;
-      align-items:center;
-      gap:10px;
-      min-width: min(560px, 100%);
-      margin-bottom: 22px;
-      padding: 13px 14px;
-      border-radius: 999px;
-      border: 1px solid var(--border);
-      background: var(--panel);
-      backdrop-filter: blur(10px);
-      box-shadow: var(--shadow);
-    }
-    .search input{
-      width:100%;
-      border:0;
-      outline:none;
-      background: transparent;
-      color: var(--text);
-      font-size:14px;
-      font-family: var(--sans);
-    }
-    .search input::placeholder{ color: var(--muted); }
-    .search kbd{
-      padding: 3px 8px;
-      border-radius: 8px;
-      border: 1px solid var(--border);
-      background: var(--panel-2);
-      color: var(--muted);
-      font-size:12px;
-      font-family: var(--mono);
-    }
-
-    .board{
-      display:flex;
-      flex-direction:column;
-      gap: 22px;
-    }
-
-    .section-block{
-      display:flex;
-      flex-direction:column;
-      gap: 12px;
-    }
-
-    .section{
-      display:flex;
-      align-items:flex-end;
-      justify-content:space-between;
-      gap:10px;
-      padding: 0 2px;
-    }
-    .section h2{
-      margin:0;
-      font-size: 13px;
-      font-weight: 800;
-      letter-spacing: .9px;
-      text-transform: uppercase;
-      color: var(--muted);
-    }
-    .section .hint{
-      font-size: 12px;
-      color: var(--muted);
-    }
-
-    .grid{
-      display:grid;
-      grid-template-columns: repeat(12, 1fr);
-      gap: 14px;
-    }
-
-    .card{
-      grid-column: span 4;
-      min-height: 138px;
-      padding: 14px 14px 12px;
-      border-radius: var(--radius);
-      border: 1px solid var(--border);
-      background:
-        linear-gradient(180deg, rgba(255,255,255,.06), transparent 26%),
-        linear-gradient(180deg, var(--panel), transparent 120%);
-      box-shadow: var(--shadow);
-      backdrop-filter: blur(10px);
-      display:flex;
-      flex-direction:column;
-      gap:10px;
-      position:relative;
-      overflow:hidden;
-      transition: transform .10s ease, border-color .15s ease, background .15s ease;
-    }
-    .card::before{
-      content:"";
-      position:absolute;
-      top:0; left:0; right:0;
-      height: 1px;
-      background: linear-gradient(90deg, transparent, rgba(255,255,255,.32), transparent);
-      opacity:.75;
-      pointer-events:none;
-    }
-    .card:hover{
-      transform: translateY(-2px);
-      border-color: rgba(110,168,255,.35);
-      background:
-        linear-gradient(180deg, rgba(255,255,255,.08), transparent 26%),
-        linear-gradient(180deg, var(--panel-2), transparent 130%);
-    }
-
-    .top{
-      display:flex;
-      align-items:flex-start;
-      justify-content:space-between;
-      gap:10px;
-    }
-
-    .title{
-      display:flex;
-      align-items:center;
-      gap:10px;
-      min-width:0;
-    }
-
-    .icon{
-      width:40px;
-      height:40px;
-      flex:0 0 auto;
-      display:grid;
-      place-items:center;
-      border-radius: 13px;
-      border: 1px solid var(--border);
-      background:
-        radial-gradient(18px 18px at 30% 30%, rgba(124,247,212,.35), transparent 60%),
-        radial-gradient(18px 18px at 70% 70%, rgba(110,168,255,.32), transparent 60%),
-        rgba(255,255,255,.06);
-    }
-    .icon svg{
-      width:20px;
-      height:20px;
-      opacity:.92;
-    }
-
-    .name{
-      font-size:15px;
-      font-weight:780;
-      white-space:nowrap;
-      overflow:hidden;
-      text-overflow:ellipsis;
-    }
-    .meta{
-      margin-top:2px;
-      color: var(--muted);
-      font-size:11px;
-      font-family: var(--mono);
-      white-space:nowrap;
-      overflow:hidden;
-      text-overflow:ellipsis;
-    }
-
-    .tags{
-      display:flex;
-      flex-wrap:wrap;
-      gap:8px;
-    }
-
-    .tag{
-      padding: 4px 8px;
-      border-radius:999px;
-      border:1px solid var(--border);
-      background: var(--panel-2);
-      color: var(--muted);
-      font-size:11px;
-      line-height:1;
-    }
-    .tag.ok{
-      color: var(--ok);
-      border-color: rgba(61,220,151,.35);
-      background: rgba(61,220,151,.10);
-    }
-    .tag.warn{
-      color: var(--danger);
-      border-color: rgba(255,107,107,.35);
-      background: rgba(255,107,107,.08);
-    }
-
-    .desc{
-      color: var(--muted);
-      font-size:13px;
-      line-height:1.4;
-      display:-webkit-box;
-      -webkit-line-clamp:2;
-      -webkit-box-orient:vertical;
-      overflow:hidden;
-      min-height: 36px;
-    }
-
-    .actions{
-      margin-top:auto;
-      display:flex;
-      align-items:center;
-      justify-content:space-between;
-      gap:8px;
-    }
-
-    .go{
-      display:inline-flex;
-      align-items:center;
-      gap:8px;
-      padding: 9px 12px;
-      border-radius: var(--radius-sm);
-      border: 1px solid rgba(110,168,255,.35);
-      background: rgba(110,168,255,.14);
-      font-size:13px;
-      font-weight:730;
-    }
-    .go:hover{ background: rgba(110,168,255,.20); }
-
-    .small{
-      max-width:48%;
-      white-space:nowrap;
-      overflow:hidden;
-      text-overflow:ellipsis;
-      text-align:right;
-      color: var(--muted);
-      font-size:12px;
-      font-family: var(--mono);
-    }
-
-    .empty{
-      display:none;
-      padding: 18px 16px;
-      border-radius: 16px;
-      border: 1px dashed var(--border);
-      background: var(--panel);
-      color: var(--muted);
-      text-align:center;
-      box-shadow: var(--shadow);
-    }
-
-    footer{
-      margin-top: 24px;
-      padding-top: 14px;
-      border-top: 1px solid var(--border);
-      display:flex;
-      justify-content:space-between;
-      gap:12px;
-      flex-wrap:wrap;
-      color: var(--muted);
-      font-size:12px;
-    }
-
-    .mono{ font-family: var(--mono); }
-
-    @media (max-width: 980px){
-      .card{ grid-column: span 6; }
-      .search{ min-width: 100%; }
-    }
-
-    @media (max-width: 640px){
-      header{
-        flex-direction:column;
-        align-items:stretch;
-      }
-      .toolbar{
-        justify-content:flex-start;
-      }
-      .card{ grid-column: span 12; }
-      .small{ display:none; }
-    }
-
-    /* ===== 登录模态框 ===== */
-    .modal-overlay{
-      display:none;
-      position:fixed;
-      inset:0;
-      background:rgba(0,0,0,.55);
-      backdrop-filter:blur(4px);
-      z-index:1000;
-      align-items:center;
-      justify-content:center;
-    }
-    .modal-overlay.active{ display:flex; }
-
-    .modal-box{
-      width:380px;
-      max-width:92vw;
-      padding:32px 28px 28px;
-      border-radius:var(--radius);
-      border:1px solid var(--border);
-      background:var(--bg);
-      box-shadow:0 20px 60px rgba(0,0,0,.5);
-    }
-    .modal-box h2{
-      margin:0 0 6px;
-      font-size:20px;
-      font-weight:800;
-    }
-    .modal-box p{
-      margin:0 0 20px;
-      color:var(--muted);
-      font-size:14px;
-    }
-    .modal-box label{
-      display:block;
-      font-size:13px;
-      font-weight:600;
-      margin-bottom:6px;
-      color:var(--muted);
-    }
-    .modal-box input[type="password"]{
-      width:100%;
-      height:44px;
-      padding:0 14px;
-      border-radius:999px;
-      border:1px solid var(--border);
-      background:var(--panel);
-      color:var(--text);
-      font-size:15px;
-      font-family:var(--sans);
-      outline:none;
-      transition:border-color .15s;
-    }
-    .modal-box input[type="password"]:focus{
-      border-color:var(--accent);
-    }
-    .modal-box .btn-row{
-      display:flex;
-      gap:10px;
-      margin-top:20px;
-    }
-    .modal-box .btn-row button{
-      flex:1;
-      height:44px;
-      font-size:14px;
-      font-weight:600;
-    }
-    .modal-box .btn-primary{
-      background:var(--accent);
-      border-color:var(--accent);
-      color:#fff;
-    }
-    .modal-box .btn-primary:hover{
-      opacity:.88;
-      border-color:var(--accent);
-    }
-    .modal-box .modal-error{
-      display:none;
-      margin-top:12px;
-      padding:10px 14px;
-      border-radius:var(--radius-sm);
-      background:rgba(255,107,107,.12);
-      border:1px solid rgba(255,107,107,.3);
-      color:var(--danger);
-      font-size:13px;
-    }
-
-    /* ===== 私有链接标识 ===== */
-    .card.private{
-      border-left:3px solid var(--danger);
-    }
-  </style>
+  <link rel="stylesheet" href="style.css" />
 </head>
 
-<body>
+<body class="index-page">
   <div class="wrap">
     <header>
       <div class="brand">
@@ -554,6 +59,7 @@
     </div>
   </div>
 
+  <script src="app.js"></script>
   <script>
     (function(){
     'use strict';
@@ -696,10 +202,6 @@
       applyFilter();
     }
 
-    function esc(s){ return (s || '').replace(/[&<>"']/g, function(m){
-      return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[m];
-    });}
-
     // ===== 获取并渲染链接 =====
     async function loadLinks(){
       loading.style.display = '';
@@ -791,4 +293,4 @@
     })();
   </script>
 </body>
-</html>
+</html>

+ 682 - 0
public/style.css

@@ -0,0 +1,682 @@
+/* ============================================
+   BCJD 导航 - 公共样式
+   ============================================ */
+
+:root {
+  --bg: #0b1020;
+  --panel: rgba(255,255,255,.06);
+  --panel-2: rgba(255,255,255,.10);
+  --text: rgba(255,255,255,.92);
+  --muted: rgba(255,255,255,.65);
+  --border: rgba(255,255,255,.12);
+  --shadow: 0 10px 30px rgba(0,0,0,.35);
+  --accent: #6ea8ff;
+  --accent2: #7cf7d4;
+  --danger: #ff6b6b;
+  --ok: #3ddc97;
+  --max: 1120px;
+  --radius: 18px;
+  --radius-sm: 12px;
+  --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif;
+}
+
+html[data-theme="light"] {
+  --bg: #f6f7fb;
+  --panel: rgba(0,0,0,.04);
+  --panel-2: rgba(0,0,0,.06);
+  --text: rgba(0,0,0,.88);
+  --muted: rgba(0,0,0,.58);
+  --border: rgba(0,0,0,.10);
+  --shadow: 0 10px 30px rgba(0,0,0,.12);
+  --accent: #245bdb;
+  --accent2: #0aa37f;
+  --danger: #d64545;
+  --ok: #0a7f5f;
+}
+
+@media (prefers-color-scheme: light) {
+  html:not([data-theme="dark"]) {
+    --bg: #f6f7fb;
+    --panel: rgba(0,0,0,.04);
+    --panel-2: rgba(0,0,0,.06);
+    --text: rgba(0,0,0,.88);
+    --muted: rgba(0,0,0,.58);
+    --border: rgba(0,0,0,.10);
+    --shadow: 0 10px 30px rgba(0,0,0,.12);
+    --accent: #245bdb;
+    --accent2: #0aa37f;
+    --danger: #d64545;
+    --ok: #0a7f5f;
+  }
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  font-family: var(--sans);
+  color: var(--text);
+  background: var(--bg);
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+.wrap {
+  max-width: var(--max);
+  margin: 0 auto;
+  padding: 30px 18px 44px;
+}
+
+/* ===== 按钮通用样式 ===== */
+button, .btn {
+  height: 40px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 16px;
+  border-radius: 999px;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  box-shadow: var(--shadow);
+  color: var(--text);
+  cursor: pointer;
+  font-family: var(--sans);
+  font-size: 13px;
+  font-weight: 600;
+  transition: background .15s;
+  white-space: nowrap;
+}
+button:hover, .btn:hover {
+  background: var(--panel-2);
+  border-color: rgba(110,168,255,.28);
+}
+button:active { transform: translateY(1px); }
+
+.btn-primary {
+  background: var(--accent);
+  border-color: var(--accent);
+  color: #fff;
+}
+.btn-primary:hover {
+  opacity: .88;
+  border-color: var(--accent);
+  background: var(--accent);
+}
+
+.btn-danger {
+  background: rgba(255,107,107,.15);
+  border-color: rgba(255,107,107,.35);
+  color: var(--danger);
+}
+.btn-danger:hover { background: rgba(255,107,107,.25); }
+
+.btn-sm {
+  height: 34px;
+  padding: 0 12px;
+  font-size: 12px;
+}
+
+/* ===== 模态框通用 ===== */
+.modal-overlay {
+  display: none;
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,.55);
+  backdrop-filter: blur(4px);
+  z-index: 1000;
+  align-items: center;
+  justify-content: center;
+}
+.modal-overlay.active { display: flex; }
+
+.modal-box {
+  max-width: 92vw;
+  max-height: 90vh;
+  overflow-y: auto;
+  padding: 28px;
+  border-radius: var(--radius);
+  border: 1px solid var(--border);
+  background: var(--bg);
+  box-shadow: 0 20px 60px rgba(0,0,0,.5);
+}
+.modal-box h2 {
+  margin: 0 0 6px;
+  font-size: 20px;
+  font-weight: 800;
+}
+.modal-box .btn-row {
+  display: flex;
+  gap: 10px;
+  margin-top: 20px;
+}
+.modal-box .btn-row button {
+  flex: 1;
+  height: 44px;
+  font-size: 14px;
+  font-weight: 600;
+}
+.modal-box .modal-error {
+  display: none;
+  margin-top: 12px;
+  padding: 10px 14px;
+  border-radius: var(--radius-sm);
+  background: rgba(255,107,107,.12);
+  border: 1px solid rgba(255,107,107,.3);
+  color: var(--danger);
+  font-size: 13px;
+}
+
+/* ===== Toast 通知 ===== */
+.toast {
+  display: none;
+  position: fixed;
+  bottom: 30px;
+  left: 50%;
+  transform: translateX(-50%);
+  padding: 12px 24px;
+  border-radius: 999px;
+  background: var(--panel-2);
+  border: 1px solid var(--border);
+  backdrop-filter: blur(10px);
+  box-shadow: var(--shadow);
+  font-size: 14px;
+  z-index: 999;
+}
+.toast.show { display: block; }
+.toast.success { border-color: rgba(61,220,151,.35); color: var(--ok); }
+.toast.error { border-color: rgba(255,107,107,.35); color: var(--danger); }
+
+/* ===== 表单通用 ===== */
+.form-group { margin-bottom: 16px; }
+.form-group label {
+  display: block;
+  font-size: 13px;
+  font-weight: 600;
+  margin-bottom: 6px;
+  color: var(--muted);
+}
+.form-group input,
+.form-group select,
+.form-group textarea {
+  width: 100%;
+  height: 42px;
+  padding: 0 14px;
+  border-radius: 999px;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  color: var(--text);
+  font-size: 14px;
+  font-family: var(--sans);
+  outline: none;
+  transition: border-color .15s;
+}
+.form-group textarea {
+  height: auto;
+  min-height: 60px;
+  padding: 10px 14px;
+  border-radius: var(--radius-sm);
+  resize: vertical;
+  font-family: var(--mono);
+  font-size: 13px;
+}
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus { border-color: var(--accent); }
+.form-group select option { background: var(--bg); color: var(--text); }
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
+
+.checkbox-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 16px;
+}
+.checkbox-row input[type="checkbox"] {
+  width: 18px;
+  height: 18px;
+  accent-color: var(--accent);
+}
+
+/* ===== 前台首页专用样式 ===== */
+body.index-page {
+  background:
+    radial-gradient(1000px 520px at 12% 12%, rgba(110,168,255,.18), transparent 55%),
+    radial-gradient(900px 460px at 88% 20%, rgba(124,247,212,.14), transparent 55%),
+    radial-gradient(860px 520px at 35% 100%, rgba(255,107,107,.08), transparent 55%),
+    var(--bg);
+}
+.index-page a { color: inherit; text-decoration: none; }
+
+.index-page header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 18px;
+  margin-bottom: 18px;
+}
+.index-page .brand {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+.index-page .brand h1 {
+  margin: 0;
+  font-size: 24px;
+  font-weight: 800;
+  letter-spacing: .2px;
+}
+.index-page .brand .sub {
+  font-size: 13px;
+  color: var(--muted);
+  line-height: 1.45;
+}
+.index-page .toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.index-page .pill {
+  height: 42px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 14px;
+  border-radius: 999px;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(10px);
+  color: var(--text);
+  font-family: var(--mono);
+  white-space: nowrap;
+}
+
+.index-page .search {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: min(560px, 100%);
+  margin-bottom: 22px;
+  padding: 13px 14px;
+  border-radius: 999px;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  backdrop-filter: blur(10px);
+  box-shadow: var(--shadow);
+}
+.index-page .search input {
+  width: 100%;
+  border: 0;
+  outline: none;
+  background: transparent;
+  color: var(--text);
+  font-size: 14px;
+  font-family: var(--sans);
+}
+.index-page .search input::placeholder { color: var(--muted); }
+.index-page .search kbd {
+  padding: 3px 8px;
+  border-radius: 8px;
+  border: 1px solid var(--border);
+  background: var(--panel-2);
+  color: var(--muted);
+  font-size: 12px;
+  font-family: var(--mono);
+}
+
+.index-page .board {
+  display: flex;
+  flex-direction: column;
+  gap: 22px;
+}
+.index-page .section-block {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.index-page .section {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 10px;
+  padding: 0 2px;
+}
+.index-page .section h2 {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 800;
+  letter-spacing: .9px;
+  text-transform: uppercase;
+  color: var(--muted);
+}
+.index-page .section .hint {
+  font-size: 12px;
+  color: var(--muted);
+}
+
+.index-page .grid {
+  display: grid;
+  grid-template-columns: repeat(12, 1fr);
+  gap: 14px;
+}
+
+.index-page .card {
+  grid-column: span 4;
+  min-height: 138px;
+  padding: 14px 14px 12px;
+  border-radius: var(--radius);
+  border: 1px solid var(--border);
+  background:
+    linear-gradient(180deg, rgba(255,255,255,.06), transparent 26%),
+    linear-gradient(180deg, var(--panel), transparent 120%);
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(10px);
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  position: relative;
+  overflow: hidden;
+  transition: transform .10s ease, border-color .15s ease, background .15s ease;
+}
+.index-page .card::before {
+  content: "";
+  position: absolute;
+  top: 0; left: 0; right: 0;
+  height: 1px;
+  background: linear-gradient(90deg, transparent, rgba(255,255,255,.32), transparent);
+  opacity: .75;
+  pointer-events: none;
+}
+.index-page .card:hover {
+  transform: translateY(-2px);
+  border-color: rgba(110,168,255,.35);
+  background:
+    linear-gradient(180deg, rgba(255,255,255,.08), transparent 26%),
+    linear-gradient(180deg, var(--panel-2), transparent 130%);
+}
+.index-page .card.private { border-left: 3px solid var(--danger); }
+
+.index-page .card .top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 10px;
+}
+.index-page .card .title {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
+}
+.index-page .card .icon {
+  width: 40px;
+  height: 40px;
+  flex: 0 0 auto;
+  display: grid;
+  place-items: center;
+  border-radius: 13px;
+  border: 1px solid var(--border);
+  background:
+    radial-gradient(18px 18px at 30% 30%, rgba(124,247,212,.35), transparent 60%),
+    radial-gradient(18px 18px at 70% 70%, rgba(110,168,255,.32), transparent 60%),
+    rgba(255,255,255,.06);
+}
+.index-page .card .icon svg {
+  width: 20px;
+  height: 20px;
+  opacity: .92;
+}
+.index-page .card .name {
+  font-size: 15px;
+  font-weight: 780;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.index-page .card .meta {
+  margin-top: 2px;
+  color: var(--muted);
+  font-size: 11px;
+  font-family: var(--mono);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.index-page .card .tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.index-page .card .tag {
+  padding: 4px 8px;
+  border-radius: 999px;
+  border: 1px solid var(--border);
+  background: var(--panel-2);
+  color: var(--muted);
+  font-size: 11px;
+  line-height: 1;
+}
+.index-page .card .tag.ok {
+  color: var(--ok);
+  border-color: rgba(61,220,151,.35);
+  background: rgba(61,220,151,.10);
+}
+.index-page .card .tag.warn {
+  color: var(--danger);
+  border-color: rgba(255,107,107,.35);
+  background: rgba(255,107,107,.08);
+}
+.index-page .card .desc {
+  color: var(--muted);
+  font-size: 13px;
+  line-height: 1.4;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  min-height: 36px;
+}
+.index-page .card .actions {
+  margin-top: auto;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+.index-page .card .go {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 9px 12px;
+  border-radius: var(--radius-sm);
+  border: 1px solid rgba(110,168,255,.35);
+  background: rgba(110,168,255,.14);
+  font-size: 13px;
+  font-weight: 730;
+}
+.index-page .card .go:hover { background: rgba(110,168,255,.20); }
+.index-page .card .small {
+  max-width: 48%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-align: right;
+  color: var(--muted);
+  font-size: 12px;
+  font-family: var(--mono);
+}
+
+.index-page .empty {
+  display: none;
+  padding: 18px 16px;
+  border-radius: 16px;
+  border: 1px dashed var(--border);
+  background: var(--panel);
+  color: var(--muted);
+  text-align: center;
+}
+
+.index-page footer {
+  margin-top: 24px;
+  padding-top: 14px;
+  border-top: 1px solid var(--border);
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  flex-wrap: wrap;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.mono { font-family: var(--mono); }
+
+@media (max-width: 980px) {
+  .index-page .card { grid-column: span 6; }
+  .index-page .search { min-width: 100%; }
+}
+@media (max-width: 640px) {
+  .index-page header { flex-direction: column; align-items: stretch; }
+  .index-page .toolbar { justify-content: flex-start; }
+  .index-page .card { grid-column: span 12; }
+  .index-page .card .small { display: none; }
+}
+
+/* ===== 管理面板专用样式 ===== */
+.admin-page header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 18px;
+  margin-bottom: 24px;
+}
+.admin-page header h1 {
+  margin: 0;
+  font-size: 22px;
+  font-weight: 800;
+}
+.admin-page .header-actions {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+}
+
+.admin-page .tabs {
+  display: flex;
+  gap: 4px;
+  margin-bottom: 24px;
+  border-bottom: 1px solid var(--border);
+  padding-bottom: 0;
+}
+.admin-page .tab {
+  padding: 10px 18px;
+  border-radius: 12px 12px 0 0;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--muted);
+  border: 1px solid transparent;
+  border-bottom: none;
+  transition: all .12s;
+  background: transparent;
+}
+.admin-page .tab:hover { color: var(--text); background: var(--panel); }
+.admin-page .tab.active {
+  color: var(--accent);
+  border-color: var(--border);
+  background: var(--panel);
+}
+.admin-page .tab-content { display: none; }
+.admin-page .tab-content.active { display: block; }
+
+.admin-page .link-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 14px;
+  border-radius: var(--radius-sm);
+  border: 1px solid var(--border);
+  background: var(--panel);
+  margin-bottom: 8px;
+  transition: background .12s;
+}
+.admin-page .link-item:hover { background: var(--panel-2); }
+.admin-page .link-item .info { flex: 1; min-width: 0; }
+.admin-page .link-item .info .title { font-weight: 700; font-size: 14px; }
+.admin-page .link-item .info .url {
+  font-size: 12px;
+  color: var(--muted);
+  font-family: var(--mono);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.admin-page .link-item .badge {
+  font-size: 11px;
+  padding: 3px 8px;
+  border-radius: 999px;
+  border: 1px solid var(--border);
+  background: var(--panel-2);
+  color: var(--muted);
+  white-space: nowrap;
+}
+.admin-page .link-item .badge.private {
+  border-color: rgba(255,107,107,.35);
+  background: rgba(255,107,107,.08);
+  color: var(--danger);
+}
+.admin-page .link-item .item-actions {
+  display: flex;
+  gap: 6px;
+  flex-shrink: 0;
+}
+
+.admin-page .modal-box { width: 520px; }
+.admin-page .modal-box .btn-row { justify-content: flex-end; }
+
+@media (max-width: 720px) {
+  .admin-page .form-row,
+  .admin-page .form-row-3 { grid-template-columns: 1fr; }
+  .admin-page .link-item { flex-wrap: wrap; }
+  .admin-page .link-item .item-actions { width: 100%; justify-content: flex-end; }
+}
+
+/* ===== 登录模态框(前台首页) ===== */
+.index-page .modal-box {
+  width: 380px;
+  padding: 32px 28px 28px;
+}
+.index-page .modal-box p {
+  margin: 0 0 20px;
+  color: var(--muted);
+  font-size: 14px;
+}
+.index-page .modal-box label {
+  display: block;
+  font-size: 13px;
+  font-weight: 600;
+  margin-bottom: 6px;
+  color: var(--muted);
+}
+.index-page .modal-box input[type="password"] {
+  width: 100%;
+  height: 44px;
+  padding: 0 14px;
+  border-radius: 999px;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  color: var(--text);
+  font-size: 15px;
+  font-family: var(--sans);
+  outline: none;
+  transition: border-color .15s;
+}
+.index-page .modal-box input[type="password"]:focus { border-color: var(--accent); }

+ 3 - 6
src/Auth.php

@@ -14,9 +14,6 @@ namespace BCJD;
  */
 class Auth
 {
-    private const SESSION_TIMEOUT = 1800; // 30 分钟
-    private const LOGIN_DELAY     = 500;  // 毫秒
-
     /**
      * 初始化 Session 安全配置
      */
@@ -32,13 +29,13 @@ class Auth
         ini_set('session.cookie_httponly', '1');
         ini_set('session.cookie_samesite', 'Lax');
         ini_set('session.cookie_secure', '0'); // 如果使用 HTTPS 可改为 1
-        ini_set('session.gc_maxlifetime', (string)self::SESSION_TIMEOUT);
+        ini_set('session.gc_maxlifetime', (string)SESSION_TIMEOUT);
 
         session_start();
 
         // Session 超时检查
         if (!empty($_SESSION['_last_activity'])) {
-            if (time() - $_SESSION['_last_activity'] > self::SESSION_TIMEOUT) {
+            if (time() - $_SESSION['_last_activity'] > SESSION_TIMEOUT) {
                 self::destroy();
                 session_start();
             }
@@ -52,7 +49,7 @@ class Auth
     public static function verifyPassword(string $password): bool
     {
         // 登录失败延迟(防止暴力破解)
-        usleep(self::LOGIN_DELAY * 1000);
+        usleep(LOGIN_DELAY * 1000);
         return password_verify($password, SITE_PASSWORD_HASH);
     }
 

+ 1 - 1
src/LinkManager.php

@@ -189,7 +189,7 @@ class LinkManager
             Response::error('两次输入的新密码不一致');
         }
 
-        $hash = password_hash($newPw, PASSWORD_BCRYPT, ['cost' => 12]);
+        $hash = password_hash($newPw, PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
         $configPath = __DIR__ . '/../config.php';
 
         $content = file_get_contents($configPath);