feat: 添加安全模块 + Dockerfile添加curl支持健康检查
主要更新: - 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等) - Dockerfile 添加 curl 以支持 Docker 健康检查 - 前端页面更新 (管理后台、用户端) - 数据库迁移和 schema 更新 - 新增 kdocs 上传服务 - 添加安全相关测试用例 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -754,9 +754,6 @@
|
||||
<div id="tab-pending" class="tab-content active">
|
||||
<h3 style="margin-bottom: 15px; font-size: 16px;">用户注册审核</h3>
|
||||
<div id="pendingUsersList"></div>
|
||||
|
||||
<h3 style="margin-top: 30px; margin-bottom: 15px; font-size: 16px;">密码重置审核</h3>
|
||||
<div id="passwordResetsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 所有用户 -->
|
||||
@@ -811,7 +808,7 @@
|
||||
<label>截图最大并发数</label>
|
||||
<input type="number" id="maxScreenshotConcurrent" min="1" value="3" style="max-width: 200px;">
|
||||
<div style="font-size: 12px; color: #666; margin-top: 5px;">
|
||||
说明:同时进行截图的最大数量。每个浏览器约占用200MB内存。
|
||||
说明:同时进行截图的最大数量。wkhtmltoimage 资源占用较低,可按需提高。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -825,7 +822,7 @@
|
||||
启用定时任务
|
||||
</label>
|
||||
<div style="font-size: 12px; color: #666; margin-top: 5px;">
|
||||
开启后,系统将在指定时间自动执行所有账号的浏览任务(不包含截图)
|
||||
开启后,系统将在指定时间自动执行所有账号的浏览任务,是否截图由下方开关决定。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -882,6 +879,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="scheduleScreenshotGroup" style="display: none;">
|
||||
<label style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="enableScreenshot" style="width: auto; max-width: none;">
|
||||
定时任务截图
|
||||
</label>
|
||||
<div style="font-size: 12px; color: #666; margin-top: 5px;">
|
||||
开启后,定时任务执行时会生成截图。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scheduleActions" style="margin-top: 15px; display: flex; gap: 10px;">
|
||||
<button class="btn btn-primary" onclick="updateSchedule()">保存定时任务配置</button>
|
||||
<button class="btn btn-success" onclick="executeScheduleNow()" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
|
||||
@@ -1226,6 +1233,18 @@
|
||||
<label>公告内容</label>
|
||||
<textarea id="announcementContent" rows="5" placeholder="请输入公告内容(将以弹窗形式展示)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>公告图片(可选)</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<button class="btn btn-secondary" onclick="triggerAnnouncementImageUpload()">+ 上传图片</button>
|
||||
<button class="btn" onclick="clearAnnouncementImage()" style="background: #eee;">移除</button>
|
||||
<input type="file" id="announcementImageFile" accept="image/*" style="display: none;" onchange="uploadAnnouncementImageFile()">
|
||||
<input type="text" id="announcementImageUrl" placeholder="上传后自动填充" readonly style="flex: 1; min-width: 220px;">
|
||||
</div>
|
||||
<div id="announcementImagePreview" style="display: none; margin-top: 8px;">
|
||||
<img id="announcementImagePreviewImg" src="" alt="公告图片预览" style="max-width: 260px; max-height: 160px; border-radius: 8px; border: 1px solid #e5e7eb; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button class="btn btn-primary" onclick="createAnnouncement(true)">发布并启用</button>
|
||||
<button class="btn btn-secondary" onclick="createAnnouncement(false)">保存但不启用</button>
|
||||
@@ -1536,7 +1555,6 @@
|
||||
loadAnnouncements();
|
||||
loadSystemConfig();
|
||||
loadProxyConfig();
|
||||
loadPasswordResets(); // 修复: 初始化时也加载密码重置申请
|
||||
loadFeedbacks(); // 加载反馈统计更新徽章
|
||||
|
||||
// 恢复上次的标签页
|
||||
@@ -1626,6 +1644,7 @@
|
||||
<th style="width: 70px;">ID</th>
|
||||
<th>标题</th>
|
||||
<th style="width: 90px;">状态</th>
|
||||
<th style="width: 70px;">图片</th>
|
||||
<th style="width: 170px;">创建时间</th>
|
||||
<th style="width: 220px;">操作</th>
|
||||
</tr>
|
||||
@@ -1640,6 +1659,7 @@
|
||||
${a.is_active ? '启用' : '停用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${a.image_url ? '有图' : '-'}</td>
|
||||
<td>${a.created_at || '-'}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@@ -1664,17 +1684,82 @@
|
||||
const content = document.getElementById('announcementContent');
|
||||
if (title) title.value = '';
|
||||
if (content) content.value = '';
|
||||
clearAnnouncementImage();
|
||||
}
|
||||
|
||||
function triggerAnnouncementImageUpload() {
|
||||
const input = document.getElementById('announcementImageFile');
|
||||
if (input) input.click();
|
||||
}
|
||||
|
||||
async function uploadAnnouncementImageFile() {
|
||||
const input = document.getElementById('announcementImageFile');
|
||||
const urlInput = document.getElementById('announcementImageUrl');
|
||||
const file = input?.files?.[0];
|
||||
if (!file || !urlInput) return;
|
||||
|
||||
if (file.type && !String(file.type).startsWith('image/')) {
|
||||
showNotification('请选择图片文件', 'error');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const response = await fetch('/yuyx/api/announcements/upload_image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
showNotification(data?.error || '上传失败', 'error');
|
||||
return;
|
||||
}
|
||||
urlInput.value = data.url || '';
|
||||
updateAnnouncementImagePreview();
|
||||
showNotification('上传成功', 'success');
|
||||
} catch (e) {
|
||||
showNotification('上传失败', 'error');
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearAnnouncementImage() {
|
||||
const imageUrl = document.getElementById('announcementImageUrl');
|
||||
const imageFile = document.getElementById('announcementImageFile');
|
||||
if (imageUrl) imageUrl.value = '';
|
||||
if (imageFile) imageFile.value = '';
|
||||
updateAnnouncementImagePreview();
|
||||
}
|
||||
|
||||
function updateAnnouncementImagePreview() {
|
||||
const imageUrl = document.getElementById('announcementImageUrl');
|
||||
const previewWrap = document.getElementById('announcementImagePreview');
|
||||
const previewImg = document.getElementById('announcementImagePreviewImg');
|
||||
if (!imageUrl || !previewWrap || !previewImg) return;
|
||||
const url = String(imageUrl.value || '').trim();
|
||||
if (url) {
|
||||
previewImg.src = url;
|
||||
previewWrap.style.display = 'block';
|
||||
} else {
|
||||
previewImg.removeAttribute('src');
|
||||
previewWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function viewAnnouncement(id) {
|
||||
const announcement = announcements.find(a => a.id === id);
|
||||
if (!announcement) return;
|
||||
alert(`标题:${announcement.title || ''}\n\n内容:\n${announcement.content || ''}`);
|
||||
const imageLine = announcement.image_url ? `\n图片:${announcement.image_url}` : '';
|
||||
alert(`标题:${announcement.title || ''}${imageLine}\n\n内容:\n${announcement.content || ''}`);
|
||||
}
|
||||
|
||||
async function createAnnouncement(isActive) {
|
||||
const title = (document.getElementById('announcementTitle')?.value || '').trim();
|
||||
const content = (document.getElementById('announcementContent')?.value || '').trim();
|
||||
const image_url = (document.getElementById('announcementImageUrl')?.value || '').trim();
|
||||
if (!title || !content) {
|
||||
showNotification('标题和内容不能为空', 'error');
|
||||
return;
|
||||
@@ -1684,7 +1769,7 @@
|
||||
const response = await fetch('/yuyx/api/announcements', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, content, is_active: !!isActive })
|
||||
body: JSON.stringify({ title, content, image_url, is_active: !!isActive })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
@@ -2048,8 +2133,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showNotification('密码至少6个字符', 'error');
|
||||
if (newPassword.length < 8) {
|
||||
showNotification('密码长度至少8位', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/[a-zA-Z]/.test(newPassword) || !/\d/.test(newPassword)) {
|
||||
showNotification('密码必须包含字母和数字', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2107,6 +2197,8 @@
|
||||
document.getElementById('scheduleEnabled').checked = config.schedule_enabled === 1;
|
||||
document.getElementById('scheduleTime').value = config.schedule_time || '02:00';
|
||||
document.getElementById('scheduleBrowseType').value = config.schedule_browse_type || '应读';
|
||||
var enableScreenshot = config.enable_screenshot;
|
||||
document.getElementById('enableScreenshot').checked = enableScreenshot === 1 || enableScreenshot === true || enableScreenshot === undefined;
|
||||
|
||||
// 加载星期选择
|
||||
const weekdays = config.schedule_weekdays || '1,2,3,4,5,6,7';
|
||||
@@ -2132,15 +2224,18 @@
|
||||
const timeGroup = document.getElementById('scheduleTimeGroup');
|
||||
const browseTypeGroup = document.getElementById('scheduleBrowseTypeGroup');
|
||||
const weekdaysGroup = document.getElementById('scheduleWeekdaysGroup');
|
||||
const screenshotGroup = document.getElementById('scheduleScreenshotGroup');
|
||||
|
||||
if (enabled) {
|
||||
timeGroup.style.display = 'block';
|
||||
browseTypeGroup.style.display = 'block';
|
||||
weekdaysGroup.style.display = 'block';
|
||||
screenshotGroup.style.display = 'block';
|
||||
} else {
|
||||
timeGroup.style.display = 'none';
|
||||
browseTypeGroup.style.display = 'none';
|
||||
weekdaysGroup.style.display = 'none';
|
||||
screenshotGroup.style.display = 'none';
|
||||
}
|
||||
// 保存按钮始终显示,无论是开启还是关闭定时任务
|
||||
}
|
||||
@@ -2313,6 +2408,7 @@
|
||||
const enabled = document.getElementById('scheduleEnabled').checked;
|
||||
const time = document.getElementById('scheduleTime').value;
|
||||
const browseType = document.getElementById('scheduleBrowseType').value;
|
||||
const enableScreenshot = document.getElementById('enableScreenshot').checked;
|
||||
|
||||
// 获取选中的星期
|
||||
const selectedWeekdays = [];
|
||||
@@ -2330,7 +2426,7 @@
|
||||
const weekdayDisplay = selectedWeekdays.map(d => weekdayNames[parseInt(d)]).join('、');
|
||||
|
||||
const message = enabled
|
||||
? `确定启用定时任务吗?\n\n执行时间: 每天 ${time}\n执行日期: ${weekdayDisplay}\n浏览类型: ${browseType}\n\n系统将自动执行所有账号的浏览任务(不包含截图)`
|
||||
? `确定启用定时任务吗?\n\n执行时间: 每天 ${time}\n执行日期: ${weekdayDisplay}\n浏览类型: ${browseType}\n截图: ${enableScreenshot ? '截图' : '不截图'}\n\n系统将自动执行所有账号的浏览任务`
|
||||
: `确定关闭定时任务吗?`;
|
||||
|
||||
if (!confirm(message)) return;
|
||||
@@ -2343,7 +2439,8 @@
|
||||
schedule_enabled: enabled ? 1 : 0,
|
||||
schedule_time: time,
|
||||
schedule_browse_type: browseType,
|
||||
schedule_weekdays: weekdaysStr
|
||||
schedule_weekdays: weekdaysStr,
|
||||
enable_screenshot: enableScreenshot ? 1 : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2771,119 +2868,21 @@
|
||||
} else if (tabName === 'logs') {
|
||||
loadLogUserOptions();
|
||||
loadTaskLogs();
|
||||
} else if (tabName === 'pending') {
|
||||
loadPasswordResets();
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 密码重置功能 ====================
|
||||
// 管理员直接重置用户密码
|
||||
async function resetUserPassword(userId) {
|
||||
const newPassword = prompt('请输入新密码(至少8位且包含字母和数字):');
|
||||
if (!newPassword) return;
|
||||
|
||||
let passwordResets = [];
|
||||
|
||||
// 加载密码重置申请列表
|
||||
async function loadPasswordResets() {
|
||||
try {
|
||||
const response = await fetch('/yuyx/api/password_resets');
|
||||
if (response.ok) {
|
||||
passwordResets = await response.json();
|
||||
renderPasswordResets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载密码重置申请失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染密码重置申请列表
|
||||
function renderPasswordResets() {
|
||||
const container = document.getElementById('passwordResetsList');
|
||||
|
||||
if (passwordResets.length === 0) {
|
||||
container.innerHTML = '<div class="empty-message">暂无密码重置申请</div>';
|
||||
if (newPassword.length < 8) {
|
||||
showNotification('密码长度至少8位', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>申请ID</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>申请时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${passwordResets.map(reset => `
|
||||
<tr>
|
||||
<td>${reset.id}</td>
|
||||
<td><strong>${escapeHtml(reset.username)}</strong></td>
|
||||
<td>${escapeHtml(reset.email || '-')}</td>
|
||||
<td>${escapeHtml(reset.created_at)}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-small btn-success" onclick="approvePasswordReset(${reset.id})">批准</button>
|
||||
<button class="btn btn-small btn-danger" onclick="rejectPasswordReset(${reset.id})">拒绝</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 批准密码重置申请
|
||||
async function approvePasswordReset(requestId) {
|
||||
if (!confirm('确定批准该密码重置申请吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/yuyx/api/password_resets/${requestId}/approve`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
showNotification('密码重置申请已批准', 'success');
|
||||
loadPasswordResets();
|
||||
} else {
|
||||
showNotification('批准失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('批准失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝密码重置申请
|
||||
async function rejectPasswordReset(requestId) {
|
||||
if (!confirm('确定拒绝该密码重置申请吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/yuyx/api/password_resets/${requestId}/reject`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
showNotification('密码重置申请已拒绝', 'success');
|
||||
loadPasswordResets();
|
||||
} else {
|
||||
showNotification('拒绝失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('拒绝失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员直接重置用户密码
|
||||
async function resetUserPassword(userId) {
|
||||
const newPassword = prompt('请输入新密码(至少6位):');
|
||||
if (!newPassword) return;
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showNotification('密码长度至少6位', 'error');
|
||||
if (!/[a-zA-Z]/.test(newPassword) || !/\d/.test(newPassword)) {
|
||||
showNotification('密码必须包含字母和数字', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user