Harden auth, CSRF, and email log UX

This commit is contained in:
2025-12-26 19:05:20 +08:00
parent 3214cbbd91
commit f90b0a4f11
47 changed files with 583 additions and 198 deletions

View File

@@ -1510,6 +1510,23 @@
.replace(/'/g, ''');
}
function getCsrfToken() {
const match = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const method = String(init.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(init.headers || {});
const token = getCsrfToken();
if (token) headers.set('X-CSRF-Token', token);
init = { ...init, headers };
}
return originalFetch(input, init);
};
// 页面加载时初始化
window.addEventListener('load', () => {
loadStats();
@@ -1853,11 +1870,11 @@
<tr>
<td>${user.id}</td>
<td>
<div><strong>${user.username}</strong> ${getVipBadge(user)}</div>
<div><strong>${escapeHtml(user.username)}</strong> ${getVipBadge(user)}</div>
${getVipExpire(user)}
</td>
<td>${user.email || '-'}</td>
<td>${user.created_at}</td>
<td>${escapeHtml(user.email || '-')}</td>
<td>${escapeHtml(user.created_at)}</td>
<td>
<div class="action-buttons">
<button class="btn btn-small btn-success" onclick="approveUser(${user.id})">通过</button>
@@ -1903,9 +1920,9 @@
<tr>
<td>${user.id}</td>
<td>
<div><strong>${user.username}</strong> ${getVipBadge(user)}</div>
<div><strong>${escapeHtml(user.username)}</strong> ${getVipBadge(user)}</div>
${getVipExpire(user)}
${user.email ? '<div class="user-info">'+user.email+'</div>' : ''}
${user.email ? '<div class="user-info">'+escapeHtml(user.email)+'</div>' : ''}
</td>
<td>
<span class="status-badge status-${user.status}">
@@ -1913,8 +1930,8 @@
</span>
</td>
<td>
${user.created_at}
${user.approved_at ? '<div class="user-info">审核:'+user.approved_at+'</div>' : ''}
${escapeHtml(user.created_at)}
${user.approved_at ? '<div class="user-info">审核:'+escapeHtml(user.approved_at)+'</div>' : ''}
</td>
<td>
<div class="action-buttons">
@@ -2481,7 +2498,8 @@
runningList.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">暂无运行中的任务</div>';
} else {
runningList.innerHTML = data.running.map(task => {
const source = sourceMap[task.source] || {text: task.source, color: '#666'};
const sourceKey = String(task.source || '');
const source = sourceMap[sourceKey] || {text: escapeHtml(sourceKey), color: '#666'};
// 状态颜色映射
const statusColorMap = {
'初始化': '#6c757d',
@@ -2491,6 +2509,11 @@
'正在截图': '#17a2b8'
};
const statusColor = statusColorMap[task.detail_status] || '#666';
const safeUser = escapeHtml(task.user_username || '');
const safeAccount = escapeHtml(task.username || '');
const safeBrowse = escapeHtml(task.browse_type || '');
const safeDetail = escapeHtml(task.detail_status || '');
const safeElapsed = escapeHtml(task.elapsed_display || '');
// 进度显示
const progressText = task.progress_items > 0 || task.progress_attachments > 0
? `(${task.progress_items}/${task.progress_attachments})`
@@ -2499,18 +2522,18 @@
<div style="flex: 1;">
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 5px;">
<span style="color: ${source.color}; font-weight: 500; font-size: 12px;">[${source.text}]</span>
<span style="color: #333;">${task.user_username}</span>
<span style="color: #333;">${safeUser}</span>
<span style="color: #666;">→</span>
<span style="color: #007bff; font-weight: 500;">${task.username}</span>
<span style="background: #e9ecef; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #666;">${task.browse_type}</span>
<span style="color: #007bff; font-weight: 500;">${safeAccount}</span>
<span style="background: #e9ecef; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #666;">${safeBrowse}</span>
</div>
<div style="margin-top: 4px; display: flex; align-items: center; gap: 8px;">
<span style="color: ${statusColor}; font-weight: 500; font-size: 12px;">● ${task.detail_status}</span>
<span style="color: ${statusColor}; font-weight: 500; font-size: 12px;">● ${safeDetail}</span>
${progressText ? `<span style="color: #999; font-size: 11px;">内容/附件: ${progressText}</span>` : ''}
</div>
</div>
<div style="text-align: right; min-width: 70px;">
<div style="color: #28a745; font-weight: 500;">${task.elapsed_display}</div>
<div style="color: #28a745; font-weight: 500;">${safeElapsed}</div>
</div>
</div>`;
}).join('');
@@ -2522,22 +2545,28 @@
queuingList.innerHTML = '<div style="color: #999; text-align: center; padding: 10px;">暂无排队中的任务</div>';
} else {
queuingList.innerHTML = data.queuing.map(task => {
const source = sourceMap[task.source] || {text: task.source, color: '#666'};
const sourceKey = String(task.source || '');
const source = sourceMap[sourceKey] || {text: escapeHtml(sourceKey), color: '#666'};
const safeUser = escapeHtml(task.user_username || '');
const safeAccount = escapeHtml(task.username || '');
const safeBrowse = escapeHtml(task.browse_type || '');
const safeDetail = escapeHtml(task.detail_status || '等待资源');
const safeElapsed = escapeHtml(task.elapsed_display || '');
return `<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #fff8e6; border-radius: 5px; margin-bottom: 5px; border-left: 3px solid #fd7e14;">
<div style="flex: 1;">
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 5px;">
<span style="color: ${source.color}; font-weight: 500; font-size: 12px;">[${source.text}]</span>
<span style="color: #333;">${task.user_username}</span>
<span style="color: #333;">${safeUser}</span>
<span style="color: #666;">→</span>
<span style="color: #007bff; font-weight: 500;">${task.username}</span>
<span style="background: #ffeeba; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #856404;">${task.browse_type}</span>
<span style="color: #007bff; font-weight: 500;">${safeAccount}</span>
<span style="background: #ffeeba; padding: 2px 6px; border-radius: 3px; font-size: 11px; color: #856404;">${safeBrowse}</span>
</div>
<div style="margin-top: 4px;">
<span style="color: #fd7e14; font-size: 12px;">● ${task.detail_status || '等待资源'}</span>
<span style="color: #fd7e14; font-size: 12px;">● ${safeDetail}</span>
</div>
</div>
<div style="text-align: right; min-width: 80px;">
<div style="color: #fd7e14; font-weight: 500;">等待 ${task.elapsed_display}</div>
<div style="color: #fd7e14; font-weight: 500;">等待 ${safeElapsed}</div>
</div>
</div>`;
}).join('');
@@ -2563,7 +2592,7 @@
const select = document.getElementById('logUserFilter');
select.innerHTML = '<option value="">全部</option>';
users.forEach(user => {
select.innerHTML += `<option value="${user.id}">${user.username}</option>`;
select.innerHTML += `<option value="${user.id}">${escapeHtml(user.username)}</option>`;
});
}
} catch (error) {
@@ -2653,18 +2682,24 @@
'immediate': {text: '即时', color: '#fd7e14'},
'resumed': {text: '恢复', color: '#6c757d'}
};
const sourceInfo = sourceMap[log.source] || {text: log.source || '手动', color: '#28a745'};
const sourceKey = log.source || 'manual';
const sourceInfo = sourceMap[sourceKey] || {text: escapeHtml(sourceKey), color: '#28a745'};
const safeCreatedAt = escapeHtml(log.created_at || '');
const safeUser = escapeHtml(log.user_username || 'N/A');
const safeAccount = escapeHtml(log.username || '');
const safeBrowse = escapeHtml(log.browse_type || '');
const safeError = escapeHtml(log.error_message || '-');
row.innerHTML = `
<td>${log.created_at}</td>
<td>${safeCreatedAt}</td>
<td><span style="color: ${sourceInfo.color}; font-weight: 500;">${sourceInfo.text}</span></td>
<td>${log.user_username || 'N/A'}</td>
<td>${log.username}</td>
<td>${log.browse_type}</td>
<td>${safeUser}</td>
<td>${safeAccount}</td>
<td>${safeBrowse}</td>
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
<td>${log.total_items} / ${log.total_attachments}</td>
<td style="color: #2F80ED; font-weight: 500;">${formatDuration(log.duration)}</td>
<td style="color: #dc3545; font-size: 11px;">${log.error_message || '-'}</td>
<td style="color: #dc3545; font-size: 11px;">${safeError}</td>
`;
tbody.appendChild(row);
});
@@ -2782,9 +2817,9 @@
${passwordResets.map(reset => `
<tr>
<td>${reset.id}</td>
<td><strong>${reset.username}</strong></td>
<td>${reset.email || '-'}</td>
<td>${reset.created_at}</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>
@@ -2935,13 +2970,13 @@
feedbacksList.forEach(fb => {
html += '<tr>';
html += '<td>' + fb.id + '</td>';
html += '<td><strong>' + (fb.username || 'N/A') + '</strong></td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + (fb.title||'') + '">' + (fb.title||'') + '</td>';
html += '<td style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + (fb.description||'') + '">' + (fb.description||'') + '</td>';
html += '<td>' + (fb.contact || '-') + '</td>';
html += '<td><strong>' + escapeHtml(fb.username || 'N/A') + '</strong></td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + escapeHtml(fb.title || '') + '">' + escapeHtml(fb.title || '') + '</td>';
html += '<td style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + escapeHtml(fb.description || '') + '">' + escapeHtml(fb.description || '') + '</td>';
html += '<td>' + escapeHtml(fb.contact || '-') + '</td>';
html += '<td>' + getStatusBadge(fb.status) + '</td>';
html += '<td>' + fb.created_at + '</td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + (fb.admin_reply || '') + '">' + (fb.admin_reply || '-') + '</td>';
html += '<td>' + escapeHtml(fb.created_at) + '</td>';
html += '<td style="max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="' + escapeHtml(fb.admin_reply || '') + '">' + escapeHtml(fb.admin_reply || '-') + '</td>';
html += '<td><div class="action-buttons">';
if (fb.status !== 'closed') {
html += '<button class="btn btn-small btn-primary" onclick="replyFeedback(' + fb.id + ')">回复</button>';
@@ -3393,20 +3428,24 @@
};
let html = '<div class="table-container"><table style="width: 100%; font-size: 12px;"><thead><tr>';
html += '<th>时间</th><th>收件人</th><th>类型</th><th>主题</th><th>状态</th><th>错误</th>';
html += '<th>时间</th><th>收件人</th><th>来源用户</th><th>类型</th><th>主题</th><th>状态</th><th>错误</th>';
html += '</tr></thead><tbody>';
logs.forEach(log => {
const statusClass = log.status === 'success' ? 'color: #27ae60;' : 'color: #e74c3c;';
const statusText = log.status === 'success' ? '成功' : '失败';
const userLabel = log.username
? `${log.username} (#${log.user_id})`
: (log.user_id ? `用户#${log.user_id}` : '系统');
html += '<tr>';
html += `<td style="white-space: nowrap;">${log.created_at}</td>`;
html += `<td>${log.email_to}</td>`;
html += `<td>${typeMap[log.email_type] || log.email_type}</td>`;
html += `<td style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${log.subject}">${log.subject}</td>`;
html += `<td style="white-space: nowrap;">${escapeHtml(log.created_at)}</td>`;
html += `<td>${escapeHtml(log.email_to)}</td>`;
html += `<td>${escapeHtml(userLabel)}</td>`;
html += `<td>${escapeHtml(typeMap[log.email_type] || log.email_type)}</td>`;
html += `<td style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(log.subject)}">${escapeHtml(log.subject)}</td>`;
html += `<td style="${statusClass} font-weight: bold;">${statusText}</td>`;
html += `<td style="max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #999;" title="${log.error_message || ''}">${log.error_message || '-'}</td>`;
html += `<td style="max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #999;" title="${escapeHtml(log.error_message || '')}">${escapeHtml(log.error_message || '-')}</td>`;
html += '</tr>';
});