增加为需要输入密码才能访问,同时支持远程PGSQL

This commit is contained in:
pigracing
2026-03-16 11:54:38 +08:00
parent f7957902e5
commit 3c955251f9
15 changed files with 1281 additions and 24 deletions

View File

@@ -36,6 +36,7 @@
- 注册参数(超时、重试、密码长度等)
- 验证码等待配置
- 数据库管理(备份、清理)
- 支持远程 PostgreSQL
## 快速开始
@@ -67,6 +68,17 @@ python webui.py --host 0.0.0.0 --port 8080
python webui.py --debug
```
### 使用远程 PostgreSQL
通过环境变量指定数据库连接字符串:
```bash
export APP_DATABASE_URL="postgresql://user:password@host:5432/dbname"
python webui.py
```
也支持 `DATABASE_URL`,优先级低于 `APP_DATABASE_URL`
启动后访问 http://127.0.0.1:8000
## 打包为可执行文件
@@ -107,7 +119,7 @@ codex-register-v2/
| 层级 | 技术 |
|------|------|
| Web 框架 | FastAPI + Uvicorn |
| 数据库 | SQLAlchemy + SQLite |
| 数据库 | SQLAlchemy + SQLite / PostgreSQL |
| 模板引擎 | Jinja2 |
| HTTP 客户端 | curl_cffi浏览器指纹模拟 |
| 实时通信 | WebSocketwebsockets 库) |

View File

@@ -13,6 +13,7 @@ dependencies = [
"pydantic-settings>=2.0.0",
"sqlalchemy>=2.0.0",
"aiosqlite>=0.19.0",
"psycopg[binary]>=3.1.18",
"websockets>=16.0",
]

Binary file not shown.

View File

@@ -87,6 +87,13 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
description="Web UI 密钥",
is_secret=True
),
"webui_access_password": SettingDefinition(
db_key="webui.access_password",
default_value="admin123",
category=SettingCategory.WEBUI,
description="Web UI 访问密码",
is_secret=True
),
# 日志配置
"log_level": SettingDefinition(
@@ -434,6 +441,14 @@ def _convert_value(attr_name: str, value: str) -> Any:
return value
def _normalize_database_url(url: str) -> str:
if url.startswith("postgres://"):
return "postgresql+psycopg://" + url[len("postgres://"):]
if url.startswith("postgresql://"):
return "postgresql+psycopg://" + url[len("postgresql://"):]
return url
def _value_to_string(value: Any) -> str:
"""将值转换为数据库存储的字符串"""
if isinstance(value, SecretStr):
@@ -462,7 +477,12 @@ def init_default_settings() -> None:
for attr_name, defn in SETTING_DEFINITIONS.items():
existing = get_setting(db, defn.db_key)
if not existing:
default_value = _value_to_string(defn.default_value)
default_value = defn.default_value
if attr_name == "database_url":
env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
if env_url:
default_value = _normalize_database_url(env_url)
default_value = _value_to_string(default_value)
set_setting(
db,
defn.db_key,
@@ -490,6 +510,9 @@ def _load_settings_from_db() -> Dict[str, Any]:
else:
# 数据库中没有此设置,使用默认值
settings_dict[attr_name] = _convert_value(attr_name, _value_to_string(defn.default_value))
env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
if env_url:
settings_dict["database_url"] = _normalize_database_url(env_url)
return settings_dict
except Exception as e:
print(f"[Settings] 从数据库加载设置失败: {e},使用默认值")
@@ -534,9 +557,14 @@ class Settings(BaseModel):
@field_validator('database_url', mode='before')
@classmethod
def validate_database_url(cls, v):
if isinstance(v, str):
if v.startswith(("postgres://", "postgresql://")):
return _normalize_database_url(v)
if v.startswith(("postgresql+psycopg://", "postgresql+psycopg2://")):
return v
if isinstance(v, str) and v.startswith("sqlite:///"):
return v
if isinstance(v, str) and not v.startswith(("sqlite:///", "postgresql://", "mysql://")):
if isinstance(v, str) and not v.startswith(("sqlite:///", "postgresql://", "postgresql+psycopg://", "postgresql+psycopg2://", "mysql://")):
# 如果是文件路径,转换为 SQLite URL
if os.path.isabs(v) or ":/" not in v:
return f"sqlite:///{v}"
@@ -546,6 +574,7 @@ class Settings(BaseModel):
webui_host: str = "0.0.0.0"
webui_port: int = 8000
webui_secret_key: SecretStr = SecretStr("your-secret-key-change-in-production")
webui_access_password: SecretStr = SecretStr("admin123")
# 日志配置
log_level: str = "INFO"

View File

@@ -393,6 +393,10 @@ def get_data_dir() -> Path:
数据目录 Path 对象
"""
settings = get_settings()
if not settings.database_url.startswith("sqlite"):
data_dir = Path(os.environ.get("APP_DATA_DIR", "data"))
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir
data_dir = Path(settings.database_url).parent
# 如果 database_url 是 SQLite URL提取路径
@@ -563,4 +567,4 @@ class Timer:
return self.elapsed
if self.start_time is not None:
return time.time() - self.start_time
return 0.0
return 0.0

View File

@@ -15,25 +15,37 @@ from .models import Base
logger = logging.getLogger(__name__)
def _build_sqlalchemy_url(database_url: str) -> str:
if database_url.startswith("postgresql://"):
return "postgresql+psycopg://" + database_url[len("postgresql://"):]
if database_url.startswith("postgres://"):
return "postgresql+psycopg://" + database_url[len("postgres://"):]
return database_url
class DatabaseSessionManager:
"""数据库会话管理器"""
def __init__(self, database_url: str = None):
if database_url is None:
# 优先使用 APP_DATA_DIR 环境变量PyInstaller 打包后由 webui.py 设置)
data_dir = os.environ.get('APP_DATA_DIR') or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'data'
)
db_path = os.path.join(data_dir, 'database.db')
# 确保目录存在
os.makedirs(data_dir, exist_ok=True)
database_url = f"sqlite:///{db_path}"
env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
if env_url:
database_url = env_url
else:
# 优先使用 APP_DATA_DIR 环境变量PyInstaller 打包后由 webui.py 设置)
data_dir = os.environ.get('APP_DATA_DIR') or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'data'
)
db_path = os.path.join(data_dir, 'database.db')
# 确保目录存在
os.makedirs(data_dir, exist_ok=True)
database_url = f"sqlite:///{db_path}"
self.database_url = database_url
self.database_url = _build_sqlalchemy_url(database_url)
self.engine = create_engine(
database_url,
connect_args={"check_same_thread": False} if database_url.startswith("sqlite") else {},
self.database_url,
connect_args={"check_same_thread": False} if self.database_url.startswith("sqlite") else {},
echo=False, # 设置为 True 可以查看所有 SQL 语句
pool_pre_ping=True # 连接池预检查
)
@@ -152,4 +164,4 @@ def get_db() -> Generator[Session, None, None]:
try:
yield db
finally:
db.close()
db.close()

View File

@@ -5,14 +5,17 @@ FastAPI 应用主文件
import logging
import sys
from pathlib import Path
import secrets
import hmac
import hashlib
from typing import Optional
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Form
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from ..config.settings import get_settings
from .routes import api_router
@@ -78,24 +81,74 @@ def create_app() -> FastAPI:
# 模板引擎
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
def _auth_token(password: str) -> str:
secret = get_settings().webui_secret_key.get_secret_value().encode("utf-8")
return hmac.new(secret, password.encode("utf-8"), hashlib.sha256).hexdigest()
def _is_authenticated(request: Request) -> bool:
cookie = request.cookies.get("webui_auth")
expected = _auth_token(get_settings().webui_access_password.get_secret_value())
return bool(cookie) and secrets.compare_digest(cookie, expected)
def _redirect_to_login(request: Request) -> RedirectResponse:
return RedirectResponse(url=f"/login?next={request.url.path}", status_code=302)
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: Optional[str] = "/"):
"""登录页面"""
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "", "next": next or "/"}
)
@app.post("/login")
async def login_submit(request: Request, password: str = Form(...), next: Optional[str] = "/"):
"""处理登录提交"""
expected = get_settings().webui_access_password.get_secret_value()
if not secrets.compare_digest(password, expected):
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "密码错误", "next": next or "/"},
status_code=401
)
response = RedirectResponse(url=next or "/", status_code=302)
response.set_cookie("webui_auth", _auth_token(expected), httponly=True, samesite="lax")
return response
@app.get("/logout")
async def logout(request: Request, next: Optional[str] = "/login"):
"""退出登录"""
response = RedirectResponse(url=next or "/login", status_code=302)
response.delete_cookie("webui_auth")
return response
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""首页 - 注册页面"""
if not _is_authenticated(request):
return _redirect_to_login(request)
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
"""账号管理页面"""
if not _is_authenticated(request):
return _redirect_to_login(request)
return templates.TemplateResponse("accounts.html", {"request": request})
@app.get("/email-services", response_class=HTMLResponse)
async def email_services_page(request: Request):
"""邮箱服务管理页面"""
if not _is_authenticated(request):
return _redirect_to_login(request)
return templates.TemplateResponse("email_services.html", {"request": request})
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""设置页面"""
if not _is_authenticated(request):
return _redirect_to_login(request)
return templates.TemplateResponse("settings.html", {"request": request})
@app.on_event("startup")

View File

@@ -52,9 +52,10 @@ class RegistrationSettings(BaseModel):
class WebUISettings(BaseModel):
"""Web UI 设置"""
host: str = "0.0.0.0"
port: int = 8000
debug: bool = False
host: Optional[str] = None
port: Optional[int] = None
debug: Optional[bool] = None
access_password: Optional[str] = None
class AllSettings(BaseModel):
@@ -96,6 +97,7 @@ async def get_all_settings():
"host": settings.webui_host,
"port": settings.webui_port,
"debug": settings.debug,
"has_access_password": bool(settings.webui_access_password and settings.webui_access_password.get_secret_value()),
},
"tempmail": {
"base_url": settings.tempmail_base_url,
@@ -317,6 +319,23 @@ async def update_registration_settings(request: RegistrationSettings):
return {"success": True, "message": "注册设置已更新"}
@router.post("/webui")
async def update_webui_settings(request: WebUISettings):
"""更新 Web UI 设置"""
update_dict = {}
if request.host is not None:
update_dict["webui_host"] = request.host
if request.port is not None:
update_dict["webui_port"] = request.port
if request.debug is not None:
update_dict["debug"] = request.debug
if request.access_password:
update_dict["webui_access_password"] = request.access_password
update_settings(**update_dict)
return {"success": True, "message": "Web UI 设置已更新"}
@router.get("/database")
async def get_database_info():
"""获取数据库信息"""

View File

@@ -47,7 +47,9 @@ const elements = {
// 验证码设置
emailCodeForm: document.getElementById('email-code-form'),
// Outlook 设置
outlookSettingsForm: document.getElementById('outlook-settings-form')
outlookSettingsForm: document.getElementById('outlook-settings-form'),
// Web UI 访问控制
webuiSettingsForm: document.getElementById('webui-settings-form')
};
// 选中的服务 ID
@@ -231,6 +233,10 @@ function initEventListeners() {
if (elements.outlookSettingsForm) {
elements.outlookSettingsForm.addEventListener('submit', handleSaveOutlookSettings);
}
if (elements.webuiSettingsForm) {
elements.webuiSettingsForm.addEventListener('submit', handleSaveWebuiSettings);
}
}
// 加载设置
@@ -269,12 +275,40 @@ async function loadSettings() {
// 加载 Outlook 设置
loadOutlookSettings();
// Web UI 访问密码提示
if (data.webui?.has_access_password) {
const input = document.getElementById('webui-access-password');
if (input) {
input.value = '';
input.placeholder = '已配置,留空保持不变';
}
}
} catch (error) {
console.error('加载设置失败:', error);
toast.error('加载设置失败');
}
}
// 保存 Web UI 设置
async function handleSaveWebuiSettings(e) {
e.preventDefault();
const accessPassword = document.getElementById('webui-access-password').value;
const payload = {
access_password: accessPassword || null
};
try {
await api.post('/settings/webui', payload);
toast.success('Web UI 设置已更新');
document.getElementById('webui-access-password').value = '';
} catch (error) {
console.error('保存 Web UI 设置失败:', error);
toast.error('保存 Web UI 设置失败');
}
}
// 加载邮箱服务
async function loadEmailServices() {
// 检查元素是否存在

View File

@@ -58,6 +58,7 @@
<a href="/accounts" class="nav-link active">账号管理</a>
<a href="/email-services" class="nav-link">邮箱服务</a>
<a href="/settings" class="nav-link">设置</a>
<a href="/logout" class="nav-link">退出</a>
</div>
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">
🌙

View File

@@ -19,6 +19,7 @@
<a href="/accounts" class="nav-link">账号管理</a>
<a href="/email-services" class="nav-link active">邮箱服务</a>
<a href="/settings" class="nav-link">设置</a>
<a href="/logout" class="nav-link">退出</a>
</div>
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">
🌙

View File

@@ -107,6 +107,7 @@
<a href="/accounts" class="nav-link">账号管理</a>
<a href="/email-services" class="nav-link">邮箱服务</a>
<a href="/settings" class="nav-link">设置</a>
<a href="/logout" class="nav-link">退出</a>
</div>
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">
🌙

62
templates/login.html Normal file
View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>访问验证 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔒</text></svg>">
<style>
.login-wrap {
max-width: 420px;
margin: 12vh auto 0;
}
.login-card {
padding: var(--spacing-lg);
}
.login-title {
margin-bottom: var(--spacing-sm);
}
.login-subtitle {
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.login-error {
background: var(--danger-light);
color: var(--danger-color);
border: 1px solid var(--danger-color);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="container">
<div class="login-wrap">
<div class="card login-card">
<div class="card-header">
<h3 class="login-title">🔒 访问验证</h3>
<span class="hint">请输入访问密码继续</span>
</div>
<div class="card-body">
{% if error %}
<div class="login-error">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}">
<div class="form-group">
<label for="password">访问密码</label>
<input type="password" id="password" name="password" autocomplete="current-password" required autofocus>
</div>
<div class="form-actions" style="margin-top: var(--spacing-lg);">
<button type="submit" class="btn btn-primary" style="width: 100%">验证进入</button>
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -19,6 +19,7 @@
<a href="/accounts" class="nav-link">账号管理</a>
<a href="/email-services" class="nav-link">邮箱服务</a>
<a href="/settings" class="nav-link active">设置</a>
<a href="/logout" class="nav-link">退出</a>
</div>
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">
🌙
@@ -35,6 +36,7 @@
<!-- 设置标签页 -->
<div class="tabs">
<button class="tab-btn active" data-tab="proxy">🌐 代理设置</button>
<button class="tab-btn" data-tab="webui">🔒 访问控制</button>
<button class="tab-btn" data-tab="cpa">☁️ CPA上传</button>
<button class="tab-btn" data-tab="outlook">📮 Outlook配置</button>
<button class="tab-btn" data-tab="registration">⚙️ 注册配置</button>
@@ -181,6 +183,27 @@
</div>
</div>
<!-- 访问控制 -->
<div class="tab-content" id="webui-tab">
<div class="card">
<div class="card-header">
<h3>Web UI 访问密码</h3>
<span class="hint">用于访问页面的密码,留空表示不修改</span>
</div>
<div class="card-body">
<form id="webui-settings-form">
<div class="form-group">
<label for="webui-access-password">访问密码</label>
<input type="password" id="webui-access-password" name="access_password" placeholder="留空保持不变" autocomplete="new-password">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存设置</button>
</div>
</form>
</div>
</div>
</div>
<!-- 添加代理模态框 -->
<div class="modal" id="add-proxy-modal">
<div class="modal-content">

1005
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff