Add password-protected access flow
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
PORT=3000
|
||||
DB_PATH=./mail.db
|
||||
APP_PASSWORD=change-this-password
|
||||
|
||||
46
README.md
46
README.md
@@ -11,6 +11,7 @@
|
||||
- 支持拉取多文件夹邮件,不限于 `INBOX`
|
||||
- 支持按需补拉单封邮件正文,避免全量同步过慢
|
||||
- 支持批量导入导出邮箱信息
|
||||
- 支持访问密码保护,未输入密码无法进入功能页面
|
||||
- 提供 API 用于发邮件、获取邮件列表、获取最新一封邮件
|
||||
|
||||
## 技术栈
|
||||
@@ -57,8 +58,15 @@ cp .env.example .env
|
||||
```env
|
||||
PORT=3000
|
||||
DB_PATH=./mail.db
|
||||
APP_PASSWORD=change-this-password
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `APP_PASSWORD` 用于控制页面和 API 访问
|
||||
- 设置后,必须先在登录页输入正确密码才能进入系统
|
||||
- 如果留空,则不启用密码保护
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
@@ -79,6 +87,12 @@ cp .env.example .env
|
||||
|
||||
如果你不需要额外环境变量,默认配置即可。
|
||||
|
||||
如果启用访问密码,请在 `.env` 中设置:
|
||||
|
||||
```env
|
||||
APP_PASSWORD=your-strong-password
|
||||
```
|
||||
|
||||
### 2. 构建并启动
|
||||
|
||||
```bash
|
||||
@@ -124,6 +138,14 @@ Docker Compose 默认使用命名卷 `mail-sr-data` 持久化 SQLite 数据库
|
||||
|
||||
## Web 使用说明
|
||||
|
||||
### 0. 登录保护
|
||||
|
||||
如果 `.env` 中设置了 `APP_PASSWORD`,打开首页后会先进入登录页。
|
||||
|
||||
- 输入正确密码后,服务端会写入 `HttpOnly Cookie`
|
||||
- 后续页面请求会基于该 Cookie 校验访问权限
|
||||
- 点击左下角退出按钮可清除登录状态
|
||||
|
||||
### 1. 渠道管理
|
||||
|
||||
在设置弹窗中可以新增渠道,填写:
|
||||
@@ -185,12 +207,36 @@ QQ邮箱----demo@qq.com----abcd1234
|
||||
|
||||
## API 文档
|
||||
|
||||
除 `健康检查`、`登录状态`、`登录接口` 外,其余 API 在启用 `APP_PASSWORD` 时都需要先登录。
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
### 登录状态
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/auth/status
|
||||
```
|
||||
|
||||
### 登录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"password": "your-strong-password"
|
||||
}'
|
||||
```
|
||||
|
||||
### 退出登录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/auth/logout
|
||||
```
|
||||
|
||||
### 查询渠道
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
environment:
|
||||
PORT: 3000
|
||||
DB_PATH: /data/mail.db
|
||||
APP_PASSWORD: ${APP_PASSWORD}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
|
||||
@@ -4,8 +4,14 @@ const state = {
|
||||
selectedAccountId: null,
|
||||
messages: [],
|
||||
selectedMessageUid: null,
|
||||
authenticated: false,
|
||||
};
|
||||
|
||||
const appShell = document.getElementById('app-shell');
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const channelTree = document.getElementById('channel-tree');
|
||||
const channelForm = document.getElementById('channel-form');
|
||||
const accountForm = document.getElementById('account-form');
|
||||
@@ -35,10 +41,12 @@ boot();
|
||||
|
||||
async function boot() {
|
||||
bindEvents();
|
||||
await reloadBaseData();
|
||||
await restoreSession();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
loginForm.addEventListener('submit', onLogin);
|
||||
logoutBtn.addEventListener('click', onLogout);
|
||||
composeBtn.addEventListener('click', () => openModal(composeModal));
|
||||
settingsBtn.addEventListener('click', () => openModal(settingsModal));
|
||||
closeCompose.addEventListener('click', () => closeModal(composeModal));
|
||||
@@ -56,6 +64,56 @@ function bindEvents() {
|
||||
copyExportBtn.addEventListener('click', onCopyExportAccounts);
|
||||
}
|
||||
|
||||
async function restoreSession() {
|
||||
const result = await request('/api/auth/status', {}, false);
|
||||
state.authenticated = Boolean(result.authenticated);
|
||||
updateAuthView();
|
||||
|
||||
if (state.authenticated) {
|
||||
await reloadBaseData();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAuthView() {
|
||||
loginScreen.classList.toggle('hidden', state.authenticated);
|
||||
appShell.classList.toggle('hidden', !state.authenticated);
|
||||
}
|
||||
|
||||
async function onLogin(event) {
|
||||
event.preventDefault();
|
||||
const payload = Object.fromEntries(new FormData(loginForm).entries());
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = '验证中...';
|
||||
|
||||
try {
|
||||
await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}, false);
|
||||
state.authenticated = true;
|
||||
updateAuthView();
|
||||
loginForm.reset();
|
||||
await reloadBaseData();
|
||||
showToast('登录成功');
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = '进入系统';
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await request('/api/auth/logout', { method: 'POST' }, false);
|
||||
state.authenticated = false;
|
||||
state.selectedAccountId = null;
|
||||
state.selectedMessageUid = null;
|
||||
state.messages = [];
|
||||
syncSelectionState();
|
||||
renderChannelTree();
|
||||
renderMessages();
|
||||
updateAuthView();
|
||||
showToast('已退出登录');
|
||||
}
|
||||
|
||||
function openModal(modal) {
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
@@ -441,8 +499,9 @@ async function copyText(value) {
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
async function request(url, options = {}, useToast = true) {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
@@ -452,7 +511,9 @@ async function request(url, options = {}) {
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
showToast(data.error || '请求失败', true);
|
||||
if (useToast) {
|
||||
showToast(data.error || '请求失败', true);
|
||||
}
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
|
||||
@@ -10,7 +10,29 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div id="login-screen" class="login-screen hidden">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="logo">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2 8L12 14L22 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Mail SR</span>
|
||||
</div>
|
||||
<p>请输入访问密码后进入系统</p>
|
||||
</div>
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="form-row compact">
|
||||
<label for="login-password">访问密码</label>
|
||||
<input id="login-password" name="password" type="password" placeholder="请输入访问密码" required>
|
||||
</div>
|
||||
<button type="submit" class="compose-btn" id="login-btn">进入系统</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app-shell" class="app-container hidden">
|
||||
<!-- 左侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -38,6 +60,13 @@
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button id="logout-btn" class="icon-btn" title="退出登录">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="settings-btn" class="icon-btn" title="设置">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
|
||||
@@ -37,6 +37,47 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(59, 130, 246, 0.18), transparent 32%),
|
||||
linear-gradient(180deg, #0b1220 0%, #0f172a 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md), 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
margin-top: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
|
||||
92
server.js
92
server.js
@@ -1,5 +1,6 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
@@ -9,11 +10,56 @@ const { sendMail, fetchLatestEmails, fetchMessageDetail } = require('./mailServi
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const appPassword = String(process.env.APP_PASSWORD || '').trim();
|
||||
const authCookieName = 'mail_sr_auth';
|
||||
const authCookieValue = appPassword
|
||||
? crypto.createHash('sha256').update(appPassword).digest('hex')
|
||||
: '';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.get('/api/auth/status', (req, res) => {
|
||||
res.json({ ok: true, authenticated: isAuthenticated(req) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
if (!appPassword) {
|
||||
return res.json({ ok: true, authenticated: true });
|
||||
}
|
||||
|
||||
const password = String(req.body.password || '');
|
||||
if (password !== appPassword) {
|
||||
return res.status(401).json({ error: '密码错误' });
|
||||
}
|
||||
|
||||
res.setHeader('Set-Cookie', serializeAuthCookie(authCookieValue));
|
||||
res.json({ ok: true, authenticated: true });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (_req, res) => {
|
||||
res.setHeader('Set-Cookie', serializeAuthCookie('', { maxAge: 0 }));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (!appPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path === '/auth/status' || req.path === '/auth/login' || req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!isAuthenticated(req)) {
|
||||
return res.status(401).json({ error: '请先输入访问密码' });
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
const getChannelStmt = db.prepare('SELECT * FROM channels WHERE id = ?');
|
||||
const getAccountStmt = db.prepare('SELECT * FROM accounts WHERE id = ?');
|
||||
const listChannelsStmt = db.prepare(`
|
||||
@@ -367,6 +413,52 @@ function serializeAccount(account) {
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthenticated(req) {
|
||||
if (!appPassword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
return cookies[authCookieName] === authCookieValue;
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
return String(cookieHeader || '')
|
||||
.split(';')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((result, item) => {
|
||||
const separatorIndex = item.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const key = item.slice(0, separatorIndex).trim();
|
||||
const value = decodeURIComponent(item.slice(separatorIndex + 1).trim());
|
||||
result[key] = value;
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function serializeAuthCookie(value, options = {}) {
|
||||
const parts = [
|
||||
`${authCookieName}=${encodeURIComponent(value)}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
];
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
parts.push('Secure');
|
||||
}
|
||||
|
||||
if (typeof options.maxAge === 'number') {
|
||||
parts.push(`Max-Age=${options.maxAge}`);
|
||||
}
|
||||
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function sanitizeChannelPayload(body) {
|
||||
const data = {
|
||||
name: String(body.name || '').trim(),
|
||||
|
||||
Reference in New Issue
Block a user