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

-

Admin

- {% if force_password_change %} -

- 首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。 -

- - {% if password_change_error %} -
{{ password_change_error }}
- {% endif %} - -
- - - - - - - - - -
- {% else %} -

- 你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。 -

- -
-
-
当前用户
-
{{ current_username }}
-
-
-
运行环境
-
{{ app_env }}
-
-
-
下一步
-
在这里接入配置页面与更细的受保护操作。
-
-
- {% endif %} - -
- - -
-
-{% endblock %} diff --git a/app/templates/config.html b/app/templates/config.html new file mode 100644 index 0000000..f657c61 --- /dev/null +++ b/app/templates/config.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block title %}Config · {{ app_name }}{% endblock %} + +{% block content %} +
+

Configuration

+

Config

+ + {% if force_password_change %} +
+ 首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。 +
+ {% endif %} + + {% if password_change_error %} +
{{ password_change_error }}
+ {% endif %} + + {% if config_error %} +
{{ config_error }}
+ {% endif %} + + {% if config_saved %} +
config saved to .env. Some changes may require an app restart.
+ {% endif %} + +
+
+
当前用户
+
admin
+
+
+ +
+

Change Password

+
+ + + + + + + + + +
+
+ +
+

Config

+
+ + + {% for section in config_sections %} +
+ {{ section.name }} + {% for field in section.fields %} + + {% endfor %} +
+ {% endfor %} + + +
+
+ +
+ + +
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index a3a3310..8dcc2d7 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -7,7 +7,7 @@

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