From 3c955251f98e9dec0706cd02882804c16892b56e Mon Sep 17 00:00:00 2001 From: pigracing Date: Mon, 16 Mar 2026 11:54:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=BA=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=AF=86=E7=A0=81=E6=89=8D=E8=83=BD=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=EF=BC=8C=E5=90=8C=E6=97=B6=E6=94=AF=E6=8C=81=E8=BF=9C?= =?UTF-8?q?=E7=A8=8BPGSQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- pyproject.toml | 1 + requirements.txt | Bin 472 -> 496 bytes src/config/settings.py | 33 +- src/core/utils.py | 6 +- src/database/session.py | 38 +- src/web/app.py | 59 +- src/web/routes/settings.py | 25 +- static/js/settings.js | 36 +- templates/accounts.html | 1 + templates/email_services.html | 1 + templates/index.html | 1 + templates/login.html | 62 ++ templates/settings.html | 23 + uv.lock | 1005 +++++++++++++++++++++++++++++++++ 15 files changed, 1281 insertions(+), 24 deletions(-) create mode 100644 templates/login.html create mode 100644 uv.lock diff --git a/README.md b/README.md index 9a4fdbf..ba20fe6 100644 --- a/README.md +++ b/README.md @@ -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(浏览器指纹模拟) | | 实时通信 | WebSocket(websockets 库) | diff --git a/pyproject.toml b/pyproject.toml index 27adf61..9cc8a29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/requirements.txt b/requirements.txt index a0006cbd96dabecec3c489bc937fba17254097f5..2f55eb6a9c613e0a36e141189762383e5b0e253b 100644 GIT binary patch delta 118 zcmcb?{DFCbiX<09GD8{=W-{0@*fJP0=mD`AkTjU+s5&u2VPcoU#5G0}A1F;`VKkX+ z#V9j5g3(}d7o*DL4U8&ATnq&a#SE1|Bl3Z)bcSe#B%ncg42cXyK+#yRk;Y&nLB?7z Ha4`S?r^p%7 delta 93 zcmeyse1my{iYzY!7eg{b8W3hO*fH2L7&7Pqu^EswVBno-tIEhbF;NjnG=hkQ#z5k- hGLZOc%E&uelTj8(x-uF9$x=q3(#bO!RarnL0sx0u64(F$ diff --git a/src/config/settings.py b/src/config/settings.py index 910b417..3988a9c 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -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" diff --git a/src/core/utils.py b/src/core/utils.py index b176571..80ab61f 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -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 \ No newline at end of file + return 0.0 diff --git a/src/database/session.py b/src/database/session.py index 5ea6d31..0b8eb53 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -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() \ No newline at end of file + db.close() diff --git a/src/web/app.py b/src/web/app.py index 350a1fd..72bfc1a 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -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") diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 077ae17..24faf03 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -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(): """获取数据库信息""" diff --git a/static/js/settings.js b/static/js/settings.js index bf7e042..a136093 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -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() { // 检查元素是否存在 diff --git a/templates/accounts.html b/templates/accounts.html index 1a625fa..528782d 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -58,6 +58,7 @@ 账号管理 邮箱服务 设置 + 退出 + + + + + + + + diff --git a/templates/settings.html b/templates/settings.html index 1c2bdac..0c59ad4 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -19,6 +19,7 @@ 账号管理 邮箱服务 设置 + 退出 + @@ -181,6 +183,27 @@ + +
+
+
+

Web UI 访问密码

+ 用于访问页面的密码,留空表示不修改 +
+
+
+
+ + +
+
+ +
+
+
+
+
+