diff --git a/README.md b/README.md
index 4b6a1a8..44742d4 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,7 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco
- 单个 admin 用户
- server-side session
+- runtime config 持久化
这部分现在也使用 Alembic 管理:
@@ -169,8 +170,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 认证模型:`username/password`
- 会话模型:server-side session + cookie
-- 当前受保护页面:`/admin`
-- 当前公开页面:`/`、`/login`
+- 当前主要受保护页面:`/config`
+- 当前公开页面:`/login`
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
安全实现的当前边界:
@@ -193,6 +194,31 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
+当前前端已经收敛为两条主路径:
+
+- `/login`
+- `/config`
+
+无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`。
+
+## Config 持久化
+
+当前 config 页面已经不再把修改写回 `.env`。
+
+当前原则是:
+
+- `.env` 只负责 bootstrap / fallback
+- app 启动先从 `.env` 读取数据库地址等基础配置
+- 请求期读取配置时,优先使用 app DB 中的 `app_config` 表
+- 如果数据库里没有对应值,再 fallback 到 `.env`
+
+这意味着:
+
+- location / poo / app DB 地址仍然属于 bootstrap 范畴
+- 运行时可编辑配置主要通过 `app_config` 表持久化
+- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
+- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储
+
## 运行测试
```bash
diff --git a/alembic_app/env.py b/alembic_app/env.py
index 66c93f3..c20c54e 100644
--- a/alembic_app/env.py
+++ b/alembic_app/env.py
@@ -5,6 +5,7 @@ from sqlalchemy import engine_from_config, pool
from app.auth_db import AuthBase
from app.config import get_settings
+from app.models.config import AppConfigEntry # noqa: F401
from app.models.auth import AuthSession, AuthUser # noqa: F401
config = context.config
diff --git a/alembic_app/versions/20260420_04_app_config_table.py b/alembic_app/versions/20260420_04_app_config_table.py
new file mode 100644
index 0000000..f2bc687
--- /dev/null
+++ b/alembic_app/versions/20260420_04_app_config_table.py
@@ -0,0 +1,34 @@
+"""app config table
+
+Revision ID: 20260420_04_app_config_table
+Revises: 20260420_03_app_auth_baseline
+Create Date: 2026-04-20 00:00:01.000000
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260420_04_app_config_table"
+down_revision: Union[str, None] = "20260420_03_app_auth_baseline"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "app_config",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("key", sa.String(length=255), nullable=False),
+ sa.Column("value", sa.String(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_app_config_key"), "app_config", ["key"], unique=True)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_app_config_key"), table_name="app_config")
+ op.drop_table("app_config")
diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py
index b1280f3..9c479ee 100644
--- a/app/api/routes/auth.py
+++ b/app/api/routes/auth.py
@@ -18,6 +18,7 @@ from app.services.auth import (
revoke_session,
validate_csrf_token,
)
+from app.services.config_page import build_config_sections
logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
@@ -33,7 +34,7 @@ def login_page(
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is not None:
- return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
+ return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
@@ -79,7 +80,7 @@ def login_submit(
)
auth_session, raw_token = create_session(session, user=user, settings=settings)
- response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
+ response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
response.set_cookie(
key=settings.auth_session_cookie_name,
@@ -94,7 +95,7 @@ def login_submit(
return response
-@router.post("/admin/change-password", response_class=HTMLResponse)
+@router.post("/config/change-password", response_class=HTMLResponse)
def change_password_submit(
request: Request,
current_password: str = Form(),
@@ -110,9 +111,10 @@ def change_password_submit(
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
logger.warning("Rejected password change attempt due to CSRF validation failure")
- return _render_admin_page(
+ return _render_config_page(
request,
settings=settings,
+ auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="invalid password change request",
@@ -132,16 +134,17 @@ def change_password_submit(
current_auth.user.username,
exc,
)
- return _render_admin_page(
+ return _render_config_page(
request,
settings=settings,
+ auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="password change failed",
)
logger.info("Password updated for user '%s'", current_auth.user.username)
- return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
+ return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
@@ -200,17 +203,18 @@ def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token:
)
-def _render_admin_page(
+def _render_config_page(
request: Request,
*,
settings: Settings,
+ auth_db_session: Session,
current_auth: AuthenticatedSession,
status_code: int,
password_change_error: str | None,
) -> HTMLResponse:
return templates.TemplateResponse(
request,
- "admin.html",
+ "config.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
@@ -218,6 +222,9 @@ def _render_admin_page(
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
+ "config_error": None,
+ "config_saved": False,
+ "config_sections": build_config_sections(auth_db_session, settings),
},
status_code=status_code,
)
diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py
index 9fc4c25..1ed940f 100644
--- a/app/api/routes/pages.py
+++ b/app/api/routes/pages.py
@@ -1,30 +1,45 @@
+import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
-from app.config import Settings
-from app.dependencies import get_app_settings, get_current_auth_session
+from app.config import Settings, get_settings
+from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
from app.services.auth import AuthenticatedSession
+from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
+from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"])
+logger = logging.getLogger(__name__)
@router.get("/", response_class=HTMLResponse)
-def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse:
- context = {
- "app_name": settings.app_name,
- "app_env": settings.app_env,
- "notion_status": "Legacy scope, removed from the Python rewrite target.",
- }
- return templates.TemplateResponse(request, "home.html", context)
+def home(
+ request: Request,
+ current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
+) -> RedirectResponse:
+ if current_auth is None:
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+ return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin", response_class=HTMLResponse)
-def admin_page(
+def admin_redirect(
request: Request,
+ current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
+) -> RedirectResponse:
+ if current_auth is None:
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+ return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@router.get("/config", response_class=HTMLResponse)
+def config_page(
+ request: Request,
+ auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
@@ -38,5 +53,66 @@ def admin_page(
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
+ "config_error": None,
+ "config_saved": request.query_params.get("saved") == "1",
+ "config_sections": build_config_sections(auth_db_session, settings),
}
- return templates.TemplateResponse(request, "admin.html", context)
+ return templates.TemplateResponse(request, "config.html", context)
+
+
+@router.post("/config", response_class=HTMLResponse)
+async def config_submit(
+ request: Request,
+ auth_db_session: Session = Depends(get_auth_db),
+ settings: Settings = Depends(get_app_settings),
+ current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
+) -> Response:
+ if current_auth is None:
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+
+ form = await request.form()
+ csrf_token = form.get("csrf_token")
+ if csrf_token != current_auth.session.csrf_token:
+ logger.warning("Rejected config update due to CSRF validation failure")
+ context = {
+ "app_name": settings.app_name,
+ "app_env": settings.app_env,
+ "current_username": current_auth.user.username,
+ "csrf_token": current_auth.session.csrf_token,
+ "force_password_change": current_auth.user.force_password_change,
+ "password_change_error": None,
+ "config_error": "invalid config update request",
+ "config_saved": False,
+ "config_sections": build_config_sections(auth_db_session, settings),
+ }
+ return templates.TemplateResponse(
+ request,
+ "config.html",
+ context,
+ status_code=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ save_config_updates(auth_db_session, dict(form), settings)
+ except ConfigSaveError:
+ logger.warning("Rejected config update due to invalid submitted values")
+ refreshed_settings = build_runtime_settings(auth_db_session, get_settings())
+ context = {
+ "app_name": refreshed_settings.app_name,
+ "app_env": refreshed_settings.app_env,
+ "current_username": current_auth.user.username,
+ "csrf_token": current_auth.session.csrf_token,
+ "force_password_change": current_auth.user.force_password_change,
+ "password_change_error": None,
+ "config_error": "invalid config submission",
+ "config_saved": False,
+ "config_sections": build_config_sections(auth_db_session, refreshed_settings),
+ }
+ return templates.TemplateResponse(
+ request,
+ "config.html",
+ context,
+ status_code=status.HTTP_400_BAD_REQUEST,
+ )
+
+ return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
diff --git a/app/dependencies.py b/app/dependencies.py
index e035990..8b567e5 100644
--- a/app/dependencies.py
+++ b/app/dependencies.py
@@ -9,16 +9,17 @@ from app.db import get_db_session
from app.integrations.homeassistant import HomeAssistantClient
from app.poo_db import get_poo_db_session
from app.services.auth import AuthenticatedSession, get_authenticated_session
-
-
-def get_app_settings() -> Settings:
- return get_settings()
+from app.services.config_page import build_runtime_settings
def get_auth_db() -> Generator[Session, None, None]:
yield from get_auth_db_session()
+def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
+ return build_runtime_settings(session, get_settings())
+
+
def get_db() -> Generator[Session, None, None]:
yield from get_db_session()
@@ -27,8 +28,8 @@ def get_poo_db() -> Generator[Session, None, None]:
yield from get_poo_db_session()
-def get_homeassistant_client() -> HomeAssistantClient:
- return HomeAssistantClient(get_settings())
+def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
+ return HomeAssistantClient(settings)
def get_current_auth_session(
diff --git a/app/main.py b/app/main.py
index 60af9be..f0ce7b2 100644
--- a/app/main.py
+++ b/app/main.py
@@ -14,6 +14,7 @@ from app.api.routes.location import router as location_router
from app.api.routes.poo import router as poo_router
from app.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema
+from app.services.config_page import seed_missing_config_from_bootstrap
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
@@ -25,6 +26,7 @@ def ensure_auth_db_ready() -> None:
try:
validate_app_runtime_db(get_settings().app_database_url)
initialize_auth_schema(session, get_settings())
+ seed_missing_config_from_bootstrap(session, get_settings())
except AppDatabaseAdoptionError as exc:
raise RuntimeError(str(exc)) from exc
except AuthBootstrapError as exc:
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 76f3041..d8889cc 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,6 +1,7 @@
"""SQLAlchemy models package."""
from app.models.auth import AuthSession, AuthUser
+from app.models.config import AppConfigEntry
from app.models.location import Location
-__all__ = ["AuthSession", "AuthUser", "Location"]
+__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
diff --git a/app/models/config.py b/app/models/config.py
new file mode 100644
index 0000000..31c0dff
--- /dev/null
+++ b/app/models/config.py
@@ -0,0 +1,15 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.auth_db import AuthBase
+
+
+class AppConfigEntry(AuthBase):
+ __tablename__ = "app_config"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
+ value: Mapped[str] = mapped_column(String, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
diff --git a/app/services/config_page.py b/app/services/config_page.py
new file mode 100644
index 0000000..7cdbf28
--- /dev/null
+++ b/app/services/config_page.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.auth_db import reset_auth_db_caches
+from app.config import Settings, get_settings
+from app.models.config import AppConfigEntry
+
+
+@dataclass(frozen=True, slots=True)
+class ConfigField:
+ section: str
+ env_name: str
+ setting_attr: str
+ label: str
+ secret: bool = False
+ input_type: str = "text"
+
+
+CONFIG_FIELDS: tuple[ConfigField, ...] = (
+ ConfigField("System", "APP_NAME", "app_name", "App Name"),
+ ConfigField("System", "APP_ENV", "app_env", "App Env"),
+ ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
+ ConfigField("System", "APP_HOST", "app_host", "App Host"),
+ ConfigField("System", "APP_PORT", "app_port", "App Port"),
+ ConfigField(
+ "Authentication",
+ "AUTH_SESSION_COOKIE_NAME",
+ "auth_session_cookie_name",
+ "Session Cookie Name",
+ ),
+ ConfigField("Authentication", "AUTH_SESSION_TTL_HOURS", "auth_session_ttl_hours", "Session TTL Hours"),
+ ConfigField(
+ "Authentication",
+ "AUTH_COOKIE_SECURE_OVERRIDE",
+ "auth_cookie_secure_override",
+ "Cookie Secure Override",
+ ),
+ ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
+ ConfigField(
+ "Poo",
+ "POO_SENSOR_ENTITY_NAME",
+ "poo_sensor_entity_name",
+ "Poo Sensor Entity Name",
+ ),
+ ConfigField(
+ "Poo",
+ "POO_SENSOR_FRIENDLY_NAME",
+ "poo_sensor_friendly_name",
+ "Poo Sensor Friendly Name",
+ ),
+ ConfigField("TickTick", "TICKTICK_CLIENT_ID", "ticktick_client_id", "TickTick Client ID"),
+ ConfigField(
+ "TickTick",
+ "TICKTICK_CLIENT_SECRET",
+ "ticktick_client_secret",
+ "TickTick Client Secret",
+ secret=True,
+ ),
+ ConfigField(
+ "TickTick",
+ "TICKTICK_REDIRECT_URI",
+ "ticktick_redirect_uri",
+ "TickTick Redirect URI",
+ ),
+ ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True),
+ ConfigField(
+ "Home Assistant",
+ "HOME_ASSISTANT_BASE_URL",
+ "home_assistant_base_url",
+ "Home Assistant Base URL",
+ ),
+ ConfigField(
+ "Home Assistant",
+ "HOME_ASSISTANT_AUTH_TOKEN",
+ "home_assistant_auth_token",
+ "Home Assistant Auth Token",
+ secret=True,
+ ),
+ ConfigField(
+ "Home Assistant",
+ "HOME_ASSISTANT_TIMEOUT_SECONDS",
+ "home_assistant_timeout_seconds",
+ "Home Assistant Timeout Seconds",
+ ),
+ ConfigField(
+ "Home Assistant",
+ "HOME_ASSISTANT_ACTION_TASK_PROJECT_ID",
+ "home_assistant_action_task_project_id",
+ "Home Assistant Action Task Project ID",
+ ),
+)
+
+
+class ConfigSaveError(ValueError):
+ """Raised when the submitted config payload is invalid."""
+
+
+def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None:
+ current_values = _read_config_values(session)
+ missing_values: dict[str, str] = {}
+
+ for field in CONFIG_FIELDS:
+ if field.env_name in current_values:
+ continue
+ missing_values[field.env_name] = _stringify(getattr(bootstrap_settings, field.setting_attr))
+
+ if not missing_values:
+ return
+
+ _persist_config_values(session, {**current_values, **missing_values})
+
+
+def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
+ overrides = _read_config_values(session)
+ if not overrides:
+ return bootstrap_settings
+
+ payload = _settings_payload(bootstrap_settings)
+ for field in CONFIG_FIELDS:
+ if field.env_name in overrides:
+ payload[field.setting_attr] = overrides[field.env_name]
+
+ return Settings(_env_file=None, **payload)
+
+
+def build_config_sections(session: Session, bootstrap_settings: Settings) -> list[dict[str, Any]]:
+ runtime_settings = build_runtime_settings(session, bootstrap_settings)
+ persisted_values = _read_config_values(session)
+ sections: list[dict[str, Any]] = []
+ current_section: dict[str, Any] | None = None
+
+ for field in CONFIG_FIELDS:
+ if current_section is None or current_section["name"] != field.section:
+ current_section = {"name": field.section, "fields": []}
+ sections.append(current_section)
+
+ current_section["fields"].append(
+ {
+ "env_name": field.env_name,
+ "label": field.label,
+ "value": "" if field.secret else _stringify(getattr(runtime_settings, field.setting_attr)),
+ "secret": field.secret,
+ "input_type": "password" if field.secret else field.input_type,
+ "configured": field.env_name in persisted_values
+ or bool(_stringify(getattr(bootstrap_settings, field.setting_attr))),
+ }
+ )
+
+ return sections
+
+
+def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_settings: Settings) -> None:
+ current_values = _read_config_values(session)
+ merged_values = dict(current_values)
+
+ for field in CONFIG_FIELDS:
+ submitted_value = form_data.get(field.env_name, "")
+ if field.secret:
+ if submitted_value:
+ merged_values[field.env_name] = submitted_value
+ else:
+ merged_values[field.env_name] = submitted_value
+
+ _validate_config_values(merged_values, bootstrap_settings)
+ _persist_config_values(session, merged_values)
+ get_settings.cache_clear()
+ reset_auth_db_caches()
+
+
+def _read_config_values(session: Session) -> dict[str, str]:
+ rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all()
+ return {row.key: row.value for row in rows}
+
+
+def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None:
+ payload = _settings_payload(bootstrap_settings)
+ for field in CONFIG_FIELDS:
+ if field.env_name in config_values:
+ payload[field.setting_attr] = config_values[field.env_name]
+
+ try:
+ Settings(_env_file=None, **payload)
+ except Exception as exc:
+ raise ConfigSaveError("invalid config submission") from exc
+
+
+def _persist_config_values(session: Session, config_values: dict[str, str]) -> None:
+ existing_entries = {
+ row.key: row
+ for row in session.execute(select(AppConfigEntry)).scalars().all()
+ }
+ now = datetime.now(UTC)
+
+ for env_name, value in config_values.items():
+ entry = existing_entries.get(env_name)
+ if entry is None:
+ session.add(AppConfigEntry(key=env_name, value=value, updated_at=now))
+ else:
+ entry.value = value
+ entry.updated_at = now
+
+ session.commit()
+
+
+def _stringify(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bool):
+ return str(value).lower()
+ return str(value)
+
+
+def _settings_payload(settings: Settings) -> dict[str, Any]:
+ return {
+ "app_name": settings.app_name,
+ "app_env": settings.app_env,
+ "app_debug": settings.app_debug,
+ "app_host": settings.app_host,
+ "app_port": settings.app_port,
+ "app_database_url": settings.app_database_url,
+ "location_database_url": settings.location_database_url,
+ "poo_database_url": settings.poo_database_url,
+ "ticktick_client_id": settings.ticktick_client_id,
+ "ticktick_client_secret": settings.ticktick_client_secret,
+ "ticktick_redirect_uri": settings.ticktick_redirect_uri,
+ "ticktick_token": settings.ticktick_token,
+ "home_assistant_base_url": settings.home_assistant_base_url,
+ "home_assistant_auth_token": settings.home_assistant_auth_token,
+ "home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
+ "home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
+ "poo_webhook_id": settings.poo_webhook_id,
+ "poo_sensor_entity_name": settings.poo_sensor_entity_name,
+ "poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
+ "auth_bootstrap_username": settings.auth_bootstrap_username,
+ "auth_bootstrap_password": settings.auth_bootstrap_password,
+ "auth_session_cookie_name": settings.auth_session_cookie_name,
+ "auth_session_ttl_hours": settings.auth_session_ttl_hours,
+ "auth_cookie_secure_override": settings.auth_cookie_secure_override,
+ }
diff --git a/app/static/styles.css b/app/static/styles.css
index 986181b..f981700 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -61,6 +61,11 @@ h1 {
margin: 0;
}
+.single-column {
+ grid-template-columns: minmax(180px, 320px);
+ margin-bottom: 24px;
+}
+
.meta div {
padding: 16px;
border-radius: 16px;
@@ -136,6 +141,47 @@ button:hover {
color: #8b2a2a;
}
+.notice {
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: rgba(45, 106, 79, 0.08);
+ border: 1px solid rgba(45, 106, 79, 0.14);
+ color: var(--accent);
+}
+
+.config-block + .config-block {
+ margin-top: 28px;
+}
+
+.config-block h2 {
+ margin: 0 0 16px;
+ font-size: 1.25rem;
+}
+
+.config-form {
+ display: grid;
+ gap: 20px;
+}
+
+.config-section {
+ margin: 0;
+ padding: 18px;
+ border: 1px solid rgba(31, 41, 51, 0.08);
+ border-radius: 16px;
+ display: grid;
+ gap: 14px;
+}
+
+.config-section legend {
+ padding: 0 8px;
+ color: var(--accent);
+}
+
+.config-form label small {
+ color: var(--muted);
+}
+
@media (max-width: 640px) {
.shell {
margin: 24px auto;
diff --git a/app/templates/admin.html b/app/templates/admin.html
deleted file mode 100644
index 0f82101..0000000
--- a/app/templates/admin.html
+++ /dev/null
@@ -1,64 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}Admin · {{ app_name }}{% endblock %}
-
-{% block content %}
- Protected Area
- 首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。
-
- 你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。
- ConfigurationAdmin
- {% if force_password_change %}
- Config
+
+ {% if force_password_change %}
+ Change Password
+
+ Config
+
+
Authentication
- 这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。 + 登录成功后会进入受保护的 config 页面。
{% if error_message %} diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 1e415e9..7c1c5db 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -41,6 +41,7 @@ - Pydantic schemas - `services/` - 业务服务层 + - 当前已迁入 config page 的 DB 持久化逻辑 - `integrations/` - 外部系统适配层 - 当前已迁入 Home Assistant outbound adapter diff --git a/docs/auth.md b/docs/auth.md index e178cad..d1cd0cd 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -34,6 +34,7 @@ - `auth_users` - `auth_sessions` +- `app_config` 当前没有把 auth 数据和 `location` / `poo` DB 混放。 @@ -44,6 +45,14 @@ 当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。 +`app_config` 现在承接运行时配置持久化。 + +其中: + +- `.env` 负责 bootstrap / fallback +- `app_config` 表负责运行时配置覆盖 +- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config` + ## 首次启动与 bootstrap 如果 auth DB 中还没有任何用户,应用启动时会要求: @@ -89,8 +98,9 @@ 当前这轮只保护了页面入口: -- `GET /admin` -- `POST /admin/change-password` +- `GET /config` +- `POST /config` +- `POST /config/change-password` - `POST /logout` 相关流程: @@ -98,7 +108,7 @@ - `GET /login` - `POST /login` -未登录访问 `/admin` 时会被重定向到 `/login`。 +未登录访问 `/config` 时会被重定向到 `/login`。 ## 下一步不在本轮内 diff --git a/openapi/openapi.json b/openapi/openapi.json index 03c50e9..2e87da7 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -87,6 +87,47 @@ } } }, + "/config/change-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Change Password Submit", + "operationId": "change_password_submit_config_change_password_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_change_password_submit_config_change_password_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/logout": { "post": { "tags": [ @@ -152,8 +193,48 @@ "tags": [ "pages" ], - "summary": "Admin Page", - "operationId": "admin_page_admin_get", + "summary": "Admin Redirect", + "operationId": "admin_redirect_admin_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/config": { + "get": { + "tags": [ + "pages" + ], + "summary": "Config Page", + "operationId": "config_page_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "pages" + ], + "summary": "Config Submit", + "operationId": "config_submit_config_post", "responses": { "200": { "description": "Successful Response", @@ -247,6 +328,34 @@ }, "components": { "schemas": { + "Body_change_password_submit_config_change_password_post": { + "properties": { + "current_password": { + "type": "string", + "title": "Current Password" + }, + "new_password": { + "type": "string", + "title": "New Password" + }, + "confirm_password": { + "type": "string", + "title": "Confirm Password" + }, + "csrf_token": { + "type": "string", + "title": "Csrf Token" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password", + "confirm_password", + "csrf_token" + ], + "title": "Body_change_password_submit_config_change_password_post" + }, "Body_login_submit_login_post": { "properties": { "username": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 4939152..9c8f4cb 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -55,6 +55,31 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /config/change-password: + post: + tags: + - auth + summary: Change Password Submit + operationId: change_password_submit_config_change_password_post + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Body_change_password_submit_config_change_password_post' + required: true + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /logout: post: tags: @@ -96,8 +121,33 @@ paths: get: tags: - pages - summary: Admin Page - operationId: admin_page_admin_get + summary: Admin Redirect + operationId: admin_redirect_admin_get + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + /config: + get: + tags: + - pages + summary: Config Page + operationId: config_page_config_get + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + post: + tags: + - pages + summary: Config Submit + operationId: config_submit_config_post responses: '200': description: Successful Response @@ -155,6 +205,27 @@ paths: schema: {} components: schemas: + Body_change_password_submit_config_change_password_post: + properties: + current_password: + type: string + title: Current Password + new_password: + type: string + title: New Password + confirm_password: + type: string + title: Confirm Password + csrf_token: + type: string + title: Csrf Token + type: object + required: + - current_password + - new_password + - confirm_password + - csrf_token + title: Body_change_password_submit_config_change_password_post Body_login_submit_login_post: properties: username: diff --git a/scripts/app_db_adopt.py b/scripts/app_db_adopt.py index 39760d7..3979f54 100644 --- a/scripts/app_db_adopt.py +++ b/scripts/app_db_adopt.py @@ -13,7 +13,7 @@ if str(PROJECT_ROOT) not in sys.path: from app.config import get_settings -APP_BASELINE_REVISION = "20260420_03_app_auth_baseline" +APP_BASELINE_REVISION = "20260420_04_app_config_table" class AppDatabaseAdoptionError(RuntimeError): @@ -102,13 +102,10 @@ def adopt_or_initialize_app_db(database_url: str) -> str: if database_path.exists(): if _alembic_version_table_exists(database_path): current_revision = _fetch_alembic_revision(database_path) - if current_revision != APP_BASELINE_REVISION: - raise AppDatabaseAdoptionError( - "App DB is already Alembic-managed but revision does not match " - f"the expected baseline: expected {APP_BASELINE_REVISION}, " - f"got {current_revision}" - ) - return "already_managed" + if current_revision == APP_BASELINE_REVISION: + return "already_managed" + command.upgrade(alembic_config, "head") + return "upgraded" existing_tables = _list_user_tables(database_path) if existing_tables: @@ -127,6 +124,8 @@ def main() -> None: result = adopt_or_initialize_app_db(settings.app_database_url) if result == "initialized": print("Initialized a new app DB via Alembic upgrade head.") + elif result == "upgraded": + print("Upgraded existing app DB to the expected Alembic head revision.") else: print("App DB is already Alembic-managed at the expected baseline revision.") diff --git a/tests/test_app.py b/tests/test_app.py index ac009ba..9e7ba37 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -25,8 +25,9 @@ def _prepare_app_db(tmp_path) -> str: def test_app_starts(client: TestClient) -> None: - response = client.get("/") - assert response.status_code == 200 + response = client.get("/", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/login" def test_status_endpoint(client: TestClient) -> None: @@ -73,11 +74,56 @@ def test_app_db_adoption_initializes_new_database(tmp_path) -> None: "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" ).fetchall() } - assert {"auth_users", "auth_sessions", "alembic_version"} <= tables + assert {"auth_users", "auth_sessions", "app_config", "alembic_version"} <= tables finally: conn.close() +def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_values( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + app_database_url = _prepare_app_db(tmp_path) + location_database_path = tmp_path / "location_ready.db" + poo_database_path = tmp_path / "poo_ready.db" + command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head") + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + + app_database_path = tmp_path / "app_ready.db" + conn = sqlite3.connect(app_database_path) + conn.execute( + "INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + ("APP_NAME", "Database Owned Name"), + ) + conn.commit() + conn.close() + + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") + monkeypatch.setenv("APP_NAME", "Bootstrap Name") + monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://bootstrap-ha.local:8123") + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") + get_settings.cache_clear() + reset_auth_db_caches() + + app = create_app() + anyio.run(_run_lifespan, app) + + conn = sqlite3.connect(app_database_path) + try: + rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall()) + finally: + conn.close() + + assert rows["APP_NAME"] == "Database Owned Name" + assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123" + assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session" + + get_settings.cache_clear() + reset_auth_db_caches() + + def test_app_start_fails_when_location_db_missing( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py index 8dab0b8..cdbb498 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,11 @@ import re +import sqlite3 +from pathlib import Path from fastapi.testclient import TestClient +from app.config import get_settings + def _extract_csrf_token(html: str) -> str: match = re.search(r'name="csrf_token" value="([^"]+)"', html) @@ -9,8 +13,16 @@ def _extract_csrf_token(html: str) -> str: return match.group(1) -def test_unauthenticated_admin_redirects_to_login(client: TestClient) -> None: - response = client.get("/admin", follow_redirects=False) +def _stringify_for_form(value) -> str: + if value is None: + return "" + if isinstance(value, bool): + return str(value).lower() + return str(value) + + +def test_unauthenticated_config_redirects_to_login(client: TestClient) -> None: + response = client.get("/config", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/login" @@ -31,18 +43,19 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC ) assert response.status_code == 303 - assert response.headers["location"] == "/admin" + assert response.headers["location"] == "/config" set_cookie_header = response.headers["set-cookie"].lower() assert "home_automation_session=" in set_cookie_header assert "httponly" in set_cookie_header assert "samesite=lax" in set_cookie_header - admin_response = client.get("/admin") - assert admin_response.status_code == 200 - assert "首次登录后需要先修改密码" in admin_response.text - assert "Current Password" in admin_response.text - assert "New Password" in admin_response.text - assert "当前用户" not in admin_response.text + config_response = client.get("/config") + assert config_response.status_code == 200 + assert "首次登录后需要先修改密码" in config_response.text + assert "Current Password" in config_response.text + assert "New Password" in config_response.text + assert "Save Config" in config_response.text + assert "当前用户" in config_response.text def test_login_failure_returns_generic_error(client: TestClient) -> None: @@ -76,8 +89,8 @@ def test_logout_revokes_session(client: TestClient) -> None: }, ) - admin_page = client.get("/admin") - logout_csrf_token = _extract_csrf_token(admin_page.text) + config_page = client.get("/config") + logout_csrf_token = _extract_csrf_token(config_page.text) logout_response = client.post( "/logout", @@ -88,9 +101,9 @@ def test_logout_revokes_session(client: TestClient) -> None: assert logout_response.status_code == 303 assert logout_response.headers["location"] == "/login" - admin_after_logout = client.get("/admin", follow_redirects=False) - assert admin_after_logout.status_code == 303 - assert admin_after_logout.headers["location"] == "/login" + config_after_logout = client.get("/config", follow_redirects=False) + assert config_after_logout.status_code == 303 + assert config_after_logout.headers["location"] == "/login" def test_login_rejects_invalid_csrf(client: TestClient) -> None: @@ -107,3 +120,70 @@ def test_login_rejects_invalid_csrf(client: TestClient) -> None: assert response.status_code == 400 assert "invalid login request" in response.text + + +def test_legacy_admin_route_redirects_to_config_when_authenticated(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/admin", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"] == "/config" + + +def test_config_page_update_persists_to_database( + client: TestClient, test_database_urls +) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + config_page = client.get("/config") + config_csrf_token = _extract_csrf_token(config_page.text) + settings = get_settings() + + form_data = {"csrf_token": config_csrf_token} + from app.services.config_page import CONFIG_FIELDS + + for field in CONFIG_FIELDS: + if field.secret: + form_data[field.env_name] = "" + else: + form_data[field.env_name] = _stringify_for_form(getattr(settings, field.setting_attr)) + + form_data["APP_NAME"] = "Updated Home Automation" + form_data["HOME_ASSISTANT_AUTH_TOKEN"] = "new-token" + + response = client.post("/config", data=form_data, follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?saved=1" + + conn = sqlite3.connect(test_database_urls["app_path"]) + try: + rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall()) + finally: + conn.close() + + assert rows["APP_NAME"] == "Updated Home Automation" + assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token" + assert "AUTH_BOOTSTRAP_USERNAME" not in rows