浏览代码

feat: Add login verification and UI interface optimization, support one-click update version.

dqzboy 1 年之前
父节点
当前提交
9364d7bfe9
共有 7 个文件被更改,包括 221 次插入85 次删除
  1. 1 1
      hubcmdui/README.md
  2. 8 3
      hubcmdui/config.json
  3. 3 1
      hubcmdui/package.json
  4. 51 16
      hubcmdui/server.js
  5. 1 1
      hubcmdui/users.json
  6. 125 61
      hubcmdui/web/admin.html
  7. 32 2
      install/DockerProxy_Install.sh

+ 1 - 1
hubcmdui/README.md

@@ -105,7 +105,7 @@ docker logs -f [容器ID或名称]
 
 <table>
     <tr>
-        <td width="50%" align="center"><img src="https://github.com/user-attachments/assets/d2f76296-e329-4941-9292-8d3d43e2bea4"?raw=true"></td>
+        <td width="50%" align="center"><img src="https://github.com/user-attachments/assets/d5d97581-42c1-4a21-9c3a-fddf3bfc67cd"?raw=true"></td>
     </tr>
 </table>
 

+ 8 - 3
hubcmdui/config.json

@@ -1,6 +1,5 @@
 {
   "logo": "",
-  "proxyDomain": "dqzboy.github.io",
   "menuItems": [
     {
       "text": "首页",
@@ -8,10 +7,16 @@
       "newTab": false
     },
     {
-      "text": "项目",
+      "text": "GitHub",
       "link": "https://github.com/dqzboy/Docker-Proxy",
       "newTab": true
     }
   ],
-  "adImages": []
+  "adImages": [
+    {
+      "url": "https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/guanggao.png",
+      "link": "https://www.dqzboy.com"
+    }
+  ],
+  "proxyDomain": "dqzboy.github.io"
 }

+ 3 - 1
hubcmdui/package.json

@@ -1,6 +1,8 @@
 {
   "dependencies": {
+    "bcrypt": "^5.1.1",
     "express": "^4.19.2",
-    "express-session": "^1.18.0"
+    "express-session": "^1.18.0",
+    "morgan": "^1.10.0"
   }
 }

+ 51 - 16
hubcmdui/server.js

@@ -3,6 +3,9 @@ const fs = require('fs').promises;
 const path = require('path');
 const bodyParser = require('body-parser');
 const session = require('express-session');
+const bcrypt = require('bcrypt');
+const crypto = require('crypto');
+const logger = require('morgan'); // 引入 morgan 作为日志工具
 
 const app = express();
 app.use(express.json());
@@ -14,6 +17,7 @@ app.use(session({
   saveUninitialized: true,
   cookie: { secure: false } // 设置为true如果使用HTTPS
 }));
+app.use(logger('dev')); // 使用 morgan 记录请求日志
 
 app.get('/admin', (req, res) => {
   res.sendFile(path.join(__dirname, 'web', 'admin.html'));
@@ -32,7 +36,7 @@ async function readConfig() {
       return {
         logo: '',
         menuItems: [],
-        adImage: { url: '', link: '' }
+        adImages: []
       };
     }
     console.log('Config read successfully');
@@ -43,7 +47,7 @@ async function readConfig() {
       return {
         logo: '',
         menuItems: [],
-        adImage: { url: '', link: '' }
+        adImages: []
       };
     }
     throw error;
@@ -68,9 +72,10 @@ async function readUsers() {
     return JSON.parse(data);
   } catch (error) {
     if (error.code === 'ENOENT') {
-      return {
-        users: [{ username: 'root', password: 'admin' }]
-      };
+      console.warn('Users file does not exist, creating default user');
+      const defaultUser = { username: 'root', password: bcrypt.hashSync('admin', 10) };
+      await writeUsers([defaultUser]);
+      return { users: [defaultUser] };
     }
     throw error;
   }
@@ -78,19 +83,35 @@ async function readUsers() {
 
 // 写入用户
 async function writeUsers(users) {
-  await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
+  await fs.writeFile(USERS_FILE, JSON.stringify({ users }, null, 2), 'utf8');
 }
 
 // 登录验证
 app.post('/api/login', async (req, res) => {
-  const { username, password } = req.body;
+  const { username, password, captcha } = req.body;
+  console.log(`Received login request for user: ${username}`); // 打印登录请求的用户名
+
+  if (req.session.captcha !== parseInt(captcha)) {
+    console.log(`Captcha verification failed for user: ${username}`); // 打印验证码验证失败
+    return res.status(401).json({ error: '验证码错误' });
+  }
+
   const users = await readUsers();
-  const user = users.users.find(u => u.username === username && u.password === password);
-  if (user) {
+  const user = users.users.find(u => u.username === username);
+
+  if (!user) {
+    console.log(`User ${username} not found`); // 打印用户未找到
+    return res.status(401).json({ error: '用户名或密码错误' });
+  }
+
+  console.log(`User ${username} found, comparing passwords`); // 打印用户找到,开始比较密码
+  if (bcrypt.compareSync(password, user.password)) {
+    console.log(`User ${username} logged in successfully`); // 打印登录成功
     req.session.user = user;
     res.json({ success: true });
   } else {
-    res.status(401).json({ error: 'Invalid credentials' });
+    console.log(`Login failed for user: ${username}, password mismatch`); // 打印密码不匹配
+    res.status(401).json({ error: '用户名或密码错误' });
   }
 });
 
@@ -100,11 +121,14 @@ app.post('/api/change-password', async (req, res) => {
     return res.status(401).json({ error: 'Not logged in' });
   }
   const { currentPassword, newPassword } = req.body;
+  if (!/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/.test(newPassword)) {
+    return res.status(400).json({ error: 'Password must be 8-16 characters long and contain at least one letter and one number' });
+  }
   const users = await readUsers();
   const user = users.users.find(u => u.username === req.session.user.username);
-  if (user && user.password === currentPassword) {
-    user.password = newPassword;
-    await writeUsers(users);
+  if (user && bcrypt.compareSync(currentPassword, user.password)) {
+    user.password = bcrypt.hashSync(newPassword, 10);
+    await writeUsers(users.users);
     res.json({ success: true });
   } else {
     res.status(401).json({ error: 'Invalid current password' });
@@ -133,10 +157,12 @@ app.get('/api/config', async (req, res) => {
 // API 端点:保存配置
 app.post('/api/config', requireLogin, async (req, res) => {
   try {
-      await writeConfig(req.body);
-      res.json({ success: true });
+    const currentConfig = await readConfig();
+    const newConfig = { ...currentConfig, ...req.body };
+    await writeConfig(newConfig);
+    res.json({ success: true });
   } catch (error) {
-      res.status(500).json({ error: 'Failed to save config' });
+    res.status(500).json({ error: 'Failed to save config' });
   }
 });
 
@@ -149,6 +175,15 @@ app.get('/api/check-session', (req, res) => {
   }
 });
 
+// API 端点:生成验证码
+app.get('/api/captcha', (req, res) => {
+  const num1 = Math.floor(Math.random() * 10);
+  const num2 = Math.floor(Math.random() * 10);
+  const captcha = `${num1} + ${num2} = ?`;
+  req.session.captcha = num1 + num2;
+  res.json({ captcha });
+});
+
 // 启动服务器
 const PORT = process.env.PORT || 3000;
 app.listen(PORT, () => {

+ 1 - 1
hubcmdui/users.json

@@ -2,7 +2,7 @@
   "users": [
     {
       "username": "root",
-      "password": "admin"
+      "password": "$2b$10$wKdemJNjB1I6IpOycHWjwO2MgDFj3QC6KLSMxZE6rHIofuSf.BX/m"
     }
   ]
 }

+ 125 - 61
hubcmdui/web/admin.html

@@ -157,18 +157,24 @@
             font-size: 24px;
             color: #0366d6;
         }
+        .password-hint {
+            color: gray;
+            font-size: 12px;
+            margin-top: 5px;
+        }
     </style>
 </head>
 <body>
     <div class="container hidden" id="adminContainer">
         <h1 class="admin-title">Docker 镜像代理加速 - 管理面板</h1>
-        <p></h1>配置添加或修改后,点击【保存更改】保存配置</p>
         <form id="adminForm">
             <label for="logoUrl">Logo URL: (可选)</label>
             <input type="url" id="logoUrl" name="logoUrl">
+            <button type="button" onclick="saveLogo()">保存 Logo</button>
             
             <label for="proxyDomain">Docker镜像代理地址: (必填)</label>
             <input type="text" id="proxyDomain" name="proxyDomain" required>
+            <button type="button" onclick="saveProxyDomain()">保存代理地址</button>
             
             <h2 class="menu-label">菜单项管理</h2>
             <table id="menuTable">
@@ -200,19 +206,19 @@
                 </tbody>
             </table>
             <button type="button" class="add-btn" onclick="showNewAdRow()">添加广告</button>
-            
-            <button type="submit">保存更改</button>
-        </form>
 
-        <!-- 修改密码的独立表单 -->
-        <div id="passwordChangeForm" style="margin-top: 20px;">
-            <h2 class="menu-label">修改密码</h2>
-            <label for="currentPassword">当前密码</label>
-            <input type="password" id="currentPassword" name="currentPassword">
-            <label for="newPassword">新密码</label>
-            <input type="password" id="newPassword" name="newPassword">
-            <button type="button" onclick="changePassword()">修改密码</button>
-        </div>
+            <!-- 修改密码的独立表单 -->
+            <div id="passwordChangeForm" style="margin-top: 20px;">
+                <h2 class="menu-label">修改密码</h2>
+                <label for="currentPassword">当前密码</label>
+                <input type="password" id="currentPassword" name="currentPassword">
+                <label for="newPassword">新密码</label>
+                <span class="password-hint" id="passwordHint">密码必须包含至少一个字母和一个数字,长度在8到16个字符之间</span>
+                <input type="password" id="newPassword" name="newPassword" oninput="checkPasswordStrength()">
+                <span id="passwordStrength" style="color: red;"></span>
+                <button type="button" onclick="changePassword()">修改密码</button>
+            </div>
+        </form>
     </div>
 
     <div class="login-modal" id="loginModal">
@@ -222,6 +228,11 @@
             <input type="text" id="username" name="username" required>
             <label for="password">密码</label>
             <input type="password" id="password" name="password" required>
+            <label for="captcha">验证码</label>
+            <div style="display: flex; align-items: center;">
+                <input type="text" id="captcha" name="captcha" required style="flex: 1;">
+                <span id="captchaText" onclick="refreshCaptcha()" style="margin-left: 10px; cursor: pointer;">点击刷新验证码</span>
+            </div>
             <button type="button" onclick="login()">登录</button>
         </div>
     </div>
@@ -264,7 +275,8 @@
                         if (text) {
                             const rowIndex = row.getAttribute('data-index');
                             menuItems[rowIndex] = { text, link, newTab };
-                            renderMenuItems();
+                            saveMenuItem(rowIndex, { text, link, newTab });
+                            renderMenuItems(); // 重新渲染菜单项
                         } else {
                             alert('请填写菜单项文本');
                         }
@@ -290,13 +302,11 @@
                         <td>
                             <button type="button" class="action-btn edit-btn">编辑</button>
                             <button type="button" class="action-btn delete-btn">删除</button>
-                            <span class="drag-handle" style="cursor: move;">☰</span>
                         </td>
                     </tr>
                 `;
                 tbody.innerHTML += row;
             });
-            setupDragAndDrop();
             setupEditButtons();
             setupDeleteButtons();
         }
@@ -308,8 +318,7 @@
                 button.addEventListener('click', () => {
                     const row = button.closest('tr');
                     const index = row.getAttribute('data-index');
-                    menuItems.splice(index, 1);
-                    renderMenuItems();
+                    deleteMenuItem(index);
                 });
             });
         }
@@ -347,8 +356,10 @@
             const newTab = newTabSelect.value === 'true';
 
             if (text) {
-                menuItems.push({ text, link, newTab }); // 确保新菜单项被添加到 menuItems 数组中
-                renderMenuItems();
+                const newItem = { text, link, newTab };
+                menuItems.push(newItem);
+                renderMenuItems(); // 先更新页面
+                saveMenuItem(menuItems.length - 1, newItem);
                 cancelNewMenuItem();
             } else {
                 alert('请填写菜单项文本');
@@ -402,8 +413,10 @@
             const url = document.getElementById('newAdUrl').value || '';
             const link = document.getElementById('newAdLink').value || '';
 
-            adImages.push({ url, link });
-            renderAdItems();
+            const newAd = { url, link };
+            adImages.push(newAd);
+            renderAdItems(); // 先更新页面
+            saveAd(adImages.length - 1, newAd);
             cancelNewAd();
         }
 
@@ -452,7 +465,8 @@
                         const link = linkInput.value || '';
 
                         adImages[editingIndex] = { url, link };
-                        renderAdItems();
+                        renderAdItems(); // 重新渲染广告项
+                        saveAd(editingIndex, { url, link });
                         editingIndex = -1;
                     }
                 });
@@ -465,33 +479,76 @@
                 button.addEventListener('click', () => {
                     const row = button.closest('tr');
                     const index = row.getAttribute('data-index');
-                    adImages.splice(index, 1);
-                    renderAdItems();
+                    deleteAd(index);
                 });
             });
         }
 
-        async function saveConfig() {
-            const config = {
-                logo: document.getElementById('logoUrl').value,
-                proxyDomain: document.getElementById('proxyDomain').value,
-                menuItems: menuItems,
-                adImages: adImages
-            };
+        async function saveLogo() {
+            const logoUrl = document.getElementById('logoUrl').value;
+            if (!logoUrl) {
+                alert('Logo URL 不可为空');
+                return;
+            }
+            try {
+                await saveConfig({ logo: logoUrl });
+                alert('Logo 保存成功');
+            } catch (error) {
+                alert('Logo 保存失败: ' + error.message);
+            }
+        }
+
+        async function saveProxyDomain() {
+            const proxyDomain = document.getElementById('proxyDomain').value;
+            if (!proxyDomain) {
+                alert('Docker镜像代理地址不可为空');
+                return;
+            }
+            try {
+                await saveConfig({ proxyDomain });
+                alert('代理地址保存成功');
+            } catch (error) {
+                alert('代理地址保存失败: ' + error.message);
+            }
+        }
+
+        async function saveMenuItem(index, item) {
+            const config = { menuItems: menuItems };
+            config.menuItems[index] = item;
+            await saveConfig(config);
+        }
+
+        async function deleteMenuItem(index) {
+            menuItems.splice(index, 1);
+            renderMenuItems(); // 先更新页面
+            await saveConfig({ menuItems: menuItems });
+        }
+
+        async function saveAd(index, ad) {
+            const config = { adImages: adImages };
+            config.adImages[index] = ad;
+            await saveConfig(config);
+        }
+
+        async function deleteAd(index) {
+            adImages.splice(index, 1);
+            renderAdItems(); // 先更新页面
+            await saveConfig({ adImages: adImages });
+        }
 
+        async function saveConfig(partialConfig) {
             try {
                 const response = await fetch('/api/config', {
                     method: 'POST',
                     headers: { 'Content-Type': 'application/json' },
-                    body: JSON.stringify(config)
+                    body: JSON.stringify(partialConfig)
                 });
-                if (response.ok) {
-                    alert('配置已保存');
-                } else {
+                if (!response.ok) {
                     throw new Error('保存失败');
                 }
             } catch (error) {
-                alert('保存失败: ' + error.message);
+                console.error('保存失败: ' + error.message);
+                throw error;
             }
         }      
 
@@ -512,11 +569,12 @@
         async function login() {
             const username = document.getElementById('username').value;
             const password = document.getElementById('password').value;
+            const captcha = document.getElementById('captcha').value;
             try {
                 const response = await fetch('/api/login', {
                     method: 'POST',
                     headers: { 'Content-Type': 'application/json' },
-                    body: JSON.stringify({ username, password })
+                    body: JSON.stringify({ username, password, captcha })
                 });
                 if (response.ok) {
                     isLoggedIn = true;
@@ -525,7 +583,8 @@
                     document.getElementById('adminContainer').classList.remove('hidden');
                     loadConfig();
                 } else {
-                    alert('登录失败');
+                    const errorData = await response.json();
+                    alert(errorData.error);
                 }
             } catch (error) {
                 alert('登录失败: ' + error.message);
@@ -539,6 +598,10 @@
                 alert('请填写当前密码和新密码');
                 return;
             }
+            if (!/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/.test(newPassword)) {
+                alert('密码必须包含至少一个字母和一个数字,长度在8到16个字符之间');
+                return;
+            }
             try {
                 const response = await fetch('/api/change-password', {
                     method: 'POST',
@@ -555,6 +618,16 @@
             }
         }
 
+        function checkPasswordStrength() {
+            const newPassword = document.getElementById('newPassword');
+            const passwordHint = document.getElementById('passwordHint');
+            if (!/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/.test(newPassword.value)) {
+                passwordHint.style.display = 'block';
+            } else {
+                passwordHint.style.display = 'none';
+            }
+        }
+
         // 页面加载时检查登录状态
         window.onload = async function() {
         try {
@@ -567,39 +640,20 @@
                 loadConfig();
             } else {
                 document.getElementById('loginModal').style.display = 'block';
+                refreshCaptcha();
             }
             } else {
             localStorage.removeItem('isLoggedIn');
             document.getElementById('loginModal').style.display = 'block';
+            refreshCaptcha();
             }
           } catch (error) {
             localStorage.removeItem('isLoggedIn');
             document.getElementById('loginModal').style.display = 'block';
+            refreshCaptcha();
           }
         };
 
-        // 表单提交事件监听器
-        document.getElementById('adminForm').addEventListener('submit', async function(e) {
-            e.preventDefault();
-            await saveConfig();
-        });
-
-        function setupDragAndDrop() {
-            const drake = dragula([document.getElementById('menuTableBody')], {
-                moves: function (el, container, handle) {
-                    return handle.classList.contains('drag-handle');
-                }
-            });
-
-            drake.on('drop', (el, target, source, sibling) => {
-                const newIndex = Array.from(target.children).indexOf(el);
-                const oldIndex = el.getAttribute('data-index');
-                const movedItem = menuItems.splice(oldIndex, 1)[0];
-                menuItems.splice(newIndex, 0, movedItem);
-                renderMenuItems();
-            });
-        }
-
         function updateAdImage(adImages) {
             const adContainer = document.getElementById('adContainer');
             adContainer.innerHTML = '';
@@ -630,6 +684,16 @@
                 }, 5000); // 每5秒切换一次广告
             }
         }
+
+        async function refreshCaptcha() {
+            try {
+                const response = await fetch('/api/captcha');
+                const data = await response.json();
+                document.getElementById('captchaText').textContent = data.captcha;
+            } catch (error) {
+                console.error('刷新验证码失败:', error);
+            }
+        }
     </script>
 </body>
 </html>

+ 32 - 2
install/DockerProxy_Install.sh

@@ -1844,6 +1844,31 @@ INSTALL_HUBCMDUI() {
     fi
 }
 
+
+UPDATE_HUBCMDUI() {
+    if [ -d "${CMDUI_DIR}" ]; then
+        if [ -f "${CMDUI_DIR}/${DOCKER_COMPOSE_FILE}" ]; then
+            INFO "正在更新HubCMD-UI容器"
+            docker-compose -f "${CMDUI_DIR}/${DOCKER_COMPOSE_FILE}" pull
+            if [ $? -ne 0 ]; then
+                WARN "HubCMD-UI ${LIGHT_YELLOW}镜像拉取失败${RESET},请稍后重试!"
+                HUBCMDUI
+            fi
+            docker-compose -f "${CMDUI_DIR}/${DOCKER_COMPOSE_FILE}" up -d --force-recreate
+            if [ $? -ne 0 ]; then
+                WARN "HubCMD-UI ${LIGHT_YELLOW}服务启动失败${RESET},请稍后重试!"
+                HUBCMDUI
+            else
+                INFO "HubCMD-UI ${LIGHT_GREEN}服务更新并启动完成${RESET}"
+            fi
+        else
+            WARN "${LIGHT_YELLOW}文件${CMDUI_DIR}/${DOCKER_COMPOSE_FILE} 不存在,无法进行更新操作!${RESET}"
+        fi
+    else
+        WARN "${LIGHT_YELLOW}目录 ${CMDUI_DIR} 不存在,无法进行更新操作!${RESET}"
+    fi
+}
+
 UNINSTALL_HUBCMDUI() {
 WARN "${LIGHT_RED}注意:${RESET} ${LIGHT_YELLOW}请执行删除之前确定是否需要备份配置文件${RESET}"
 while true; do
@@ -1865,7 +1890,8 @@ done
 SEPARATOR "HubCMD-UI管理"
 echo -e "1) ${BOLD}${LIGHT_GREEN}安装${RESET}HubCMD-UI"
 echo -e "2) ${BOLD}${LIGHT_YELLOW}卸载${RESET}HubCMD-UI"
-echo -e "3) ${BOLD}返回${LIGHT_RED}主菜单${RESET}"
+echo -e "3) ${BOLD}${LIGHT_CYAN}更新${RESET}HubCMD-UI"
+echo -e "4) ${BOLD}返回${LIGHT_RED}主菜单${RESET}"
 echo -e "0) ${BOLD}退出脚本${RESET}"
 echo "---------------------------------------------------------------"
 read -e -p "$(INFO "输入${LIGHT_CYAN}对应数字${RESET}并按${LIGHT_GREEN}Enter${RESET}键 > ")"  cmdui_choice
@@ -1880,13 +1906,17 @@ case $cmdui_choice in
         HUBCMDUI
         ;;
     3)
+        UPDATE_HUBCMDUI
+        HUBCMDUI
+        ;;
+    4)
         main_menu
         ;;
     0)
         exit 1
         ;;
     *)
-        WARN "输入了无效的选择。请重新${LIGHT_GREEN}选择0-8${RESET}的选项."
+        WARN "输入了无效的选择。请重新${LIGHT_GREEN}选择0-4${RESET}的选项."
         HUBCMDUI
         ;;
 esac