diff --git a/.env.example b/.env.example index 99100da..17eb090 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,13 @@ APP_NAME=Home Automation Backend (Python) -APP_ENV=development -APP_DEBUG=true -APP_HOST=0.0.0.0 -APP_PORT=8000 +APP_ENV=production +APP_DEBUG=false +APP_HOSTNAME=home-automation.example.com APP_DATABASE_URL=sqlite:///./data/app.db AUTH_BOOTSTRAP_USERNAME=admin AUTH_BOOTSTRAP_PASSWORD=admin AUTH_SESSION_COOKIE_NAME=home_automation_session AUTH_SESSION_TTL_HOURS=12 -AUTH_COOKIE_SECURE_OVERRIDE=false +AUTH_COOKIE_SECURE_OVERRIDE=true LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db POO_DATABASE_URL=sqlite:///./data/pooRecorder.db POO_WEBHOOK_ID= @@ -16,7 +15,6 @@ POO_SENSOR_ENTITY_NAME=sensor.test_poo_status POO_SENSOR_FRIENDLY_NAME=Poo Status TICKTICK_CLIENT_ID= TICKTICK_CLIENT_SECRET= -TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback TICKTICK_TOKEN= HOME_ASSISTANT_BASE_URL=http://localhost:8123 HOME_ASSISTANT_AUTH_TOKEN= diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index b696b14..d9603e2 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -226,6 +226,7 @@ def _render_config_page( "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_redirect_uri": settings.ticktick_redirect_uri, "ticktick_oauth_notice": None, "ticktick_oauth_error": None, }, diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index ec12211..2fb774e 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -78,6 +78,7 @@ def config_page( "config_saved": request.query_params.get("saved") == "1", "config_sections": build_config_sections(auth_db_session, settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_redirect_uri": settings.ticktick_redirect_uri, "ticktick_oauth_notice": ticktick_oauth_notice, "ticktick_oauth_error": ticktick_oauth_error, } @@ -109,6 +110,7 @@ async def config_submit( "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_redirect_uri": settings.ticktick_redirect_uri, "ticktick_oauth_notice": None, "ticktick_oauth_error": None, } @@ -135,6 +137,7 @@ async def config_submit( "config_saved": False, "config_sections": build_config_sections(auth_db_session, refreshed_settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings), + "ticktick_redirect_uri": refreshed_settings.ticktick_redirect_uri, "ticktick_oauth_notice": None, "ticktick_oauth_error": None, } diff --git a/app/config.py b/app/config.py index 7c5aa78..1d7e0b9 100644 --- a/app/config.py +++ b/app/config.py @@ -7,10 +7,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): app_name: str = "Home Automation Backend (Python)" - app_env: str = "development" + app_env: str = "production" app_debug: bool = False - app_host: str = "0.0.0.0" - app_port: int = 8000 + app_hostname: str = "localhost:8000" app_database_url: str = "sqlite:///./data/app.db" location_database_url: str = "sqlite:///./data/locationRecorder.db" @@ -18,7 +17,6 @@ class Settings(BaseSettings): ticktick_client_id: str = "" ticktick_client_secret: str = "" - ticktick_redirect_uri: str = "" ticktick_token: str = "" home_assistant_base_url: str = "" @@ -32,12 +30,13 @@ class Settings(BaseSettings): auth_bootstrap_password: str = "admin" auth_session_cookie_name: str = "home_automation_session" auth_session_ttl_hours: int = 12 - auth_cookie_secure_override: bool | None = None + auth_cookie_secure_override: bool | None = True model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, + extra="ignore", ) @computed_field @@ -45,6 +44,22 @@ class Settings(BaseSettings): def is_development(self) -> bool: return self.app_env.lower() == "development" + @computed_field + @property + def app_base_url(self) -> str: + hostname = self.app_hostname.strip().rstrip("/") + if not hostname: + return "" + scheme = "http" if self.is_development else "https" + return f"{scheme}://{hostname}" + + @computed_field + @property + def ticktick_redirect_uri(self) -> str: + if not self.app_base_url: + return "" + return f"{self.app_base_url}/ticktick/auth/code" + @staticmethod def _sqlite_path_from_url(database_url: str) -> Path | None: prefix = "sqlite:///" diff --git a/app/integrations/ticktick.py b/app/integrations/ticktick.py index 2b9ae40..dba152b 100644 --- a/app/integrations/ticktick.py +++ b/app/integrations/ticktick.py @@ -278,7 +278,7 @@ class TickTickClient: return self.settings.ticktick_client_secret.strip() def _redirect_uri(self) -> str: - return self.settings.ticktick_redirect_uri.strip() + return self.settings.ticktick_redirect_uri def _require_auth_config(self) -> None: if not self.is_configured(): @@ -288,7 +288,7 @@ class TickTickClient: ) if not self._redirect_uri(): raise TickTickConfigError( - "TickTick integration is missing TICKTICK_REDIRECT_URI." + "TickTick integration is missing APP_HOSTNAME for OAuth callback generation." ) def _require_token(self) -> None: diff --git a/app/services/config_page.py b/app/services/config_page.py index 43a5e19..38ff16e 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -26,8 +26,7 @@ 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("System", "APP_HOSTNAME", "app_hostname", "App Hostname"), ConfigField( "Authentication", "AUTH_SESSION_COOKIE_NAME", @@ -62,12 +61,6 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( "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", @@ -190,9 +183,9 @@ def save_config_value( def is_ticktick_oauth_ready(settings: Settings) -> bool: return bool( - settings.ticktick_client_id + settings.app_hostname + and settings.ticktick_client_id and settings.ticktick_client_secret - and settings.ticktick_redirect_uri ) @@ -244,14 +237,12 @@ def _settings_payload(settings: Settings) -> dict[str, Any]: "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_hostname": settings.app_hostname, "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, diff --git a/app/templates/config.html b/app/templates/config.html index ad4fc2a..6ce1b81 100644 --- a/app/templates/config.html +++ b/app/templates/config.html @@ -22,7 +22,7 @@ {% endif %} {% if config_saved %} -
config saved to .env. Some changes may require an app restart.
+
config saved to the app database. Some changes may require an app restart.
{% endif %} {% if ticktick_oauth_error %} @@ -88,10 +88,11 @@

TickTick OAuth

+

Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}

{% if ticktick_oauth_ready %}

Use the saved TickTick client settings to start the authorization flow.

{% else %} -

Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth.

+

Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.

{% endif %}
{% if ticktick_oauth_ready %} diff --git a/docker-compose.yml b/docker-compose.yml index eceb282..2cab369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,11 @@ services: app: build: . ports: - - "8000:8000" + - "${APP_PORT:-8000}:8000" env_file: - .env environment: LOCATION_DATABASE_URL: sqlite:////app/data/locationRecorder.db POO_DATABASE_URL: sqlite:////app/data/pooRecorder.db - APP_HOST: 0.0.0.0 - APP_PORT: 8000 volumes: - ./data:/app/data diff --git a/docs/ticktick.md b/docs/ticktick.md index cce6796..b572a7c 100644 --- a/docs/ticktick.md +++ b/docs/ticktick.md @@ -22,15 +22,16 @@ ## 当前配置项 +- `APP_HOSTNAME` - `TICKTICK_CLIENT_ID` - `TICKTICK_CLIENT_SECRET` -- `TICKTICK_REDIRECT_URI` - `TICKTICK_TOKEN` - `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` ## 兼容性说明 - 仍保留 legacy 的 OAuth authorization code flow +- OAuth callback URI 现在由 `APP_HOSTNAME` 和当前环境自动推导:`development` 使用 `http`,其他环境使用 `https` - `state` 仍是进程内临时状态;如果服务在 start 和 callback 之间重启,本轮实现下授权需要重新开始 - 不再把 token 写回 `.env` 或其他配置文件,统一写入 config 表 - 当前没有引入 legacy 的第三方 TickTick 库,先用标准库完成兼容行为 diff --git a/tests/test_auth.py b/tests/test_auth.py index 32a12b7..b0d8c56 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -58,7 +58,7 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC assert "New Password" in config_response.text assert "Save Config" in config_response.text assert "当前用户" in config_response.text - assert "Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth." in config_response.text + assert "Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth." in config_response.text assert 'aria-disabled="true">Authorize TickTick<' in config_response.text @@ -200,9 +200,10 @@ def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured( auth_database, monkeypatch, ) -> None: + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() @@ -224,6 +225,7 @@ def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured( assert config_response.status_code == 200 assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text + assert "Redirect URI: https://localhost:8000/ticktick/auth/code" in config_response.text assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text diff --git a/tests/test_config.py b/tests/test_config.py index 6dd13ea..598d280 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: monkeypatch.setenv("APP_DATABASE_URL", "sqlite:///./data/app.db") monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db") + monkeypatch.setenv("APP_HOSTNAME", "home.example.com") monkeypatch.setenv("POO_WEBHOOK_ID", "poo-hook") monkeypatch.setenv("POO_SENSOR_ENTITY_NAME", "sensor.test_poo_status") monkeypatch.setenv("POO_SENSOR_FRIENDLY_NAME", "Poo Status") @@ -28,6 +29,9 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: assert settings.home_assistant_base_url == "http://ha.local:8123" assert settings.home_assistant_auth_token == "token" assert settings.home_assistant_timeout_seconds == 2.5 + assert settings.app_hostname == "home.example.com" + assert settings.app_base_url == "https://home.example.com" + assert settings.ticktick_redirect_uri == "https://home.example.com/ticktick/auth/code" assert settings.auth_bootstrap_username == "admin" assert settings.auth_bootstrap_password == "secret" assert settings.auth_session_cookie_name == "auth_cookie" @@ -39,3 +43,13 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: assert settings.poo_sqlite_path is not None assert settings.poo_sqlite_path.name == "pooRecorder.db" assert settings.auth_cookie_secure is True + + +def test_settings_derive_development_ticktick_redirect_uri(monkeypatch) -> None: + monkeypatch.setenv("APP_ENV", "development") + monkeypatch.setenv("APP_HOSTNAME", "localhost:11001") + + settings = Settings(_env_file=None) + + assert settings.app_base_url == "http://localhost:11001" + assert settings.ticktick_redirect_uri == "http://localhost:11001/ticktick/auth/code" diff --git a/tests/test_ticktick.py b/tests/test_ticktick.py index 1c2766f..32ea1a9 100644 --- a/tests/test_ticktick.py +++ b/tests/test_ticktick.py @@ -38,9 +38,10 @@ class _FakeJsonResponse: def _configured_settings(**overrides) -> Settings: payload = { + "app_env": "development", + "app_hostname": "localhost:8000", "ticktick_client_id": "ticktick-client-id", "ticktick_client_secret": "ticktick-client-secret", - "ticktick_redirect_uri": "http://localhost:8000/ticktick/auth/code", "ticktick_token": "ticktick-access-token", "home_assistant_action_task_project_id": "project-123", } @@ -105,9 +106,9 @@ def test_exchange_authorization_code_trims_ticktick_config_values(monkeypatch: p captured = {} client = TickTickClient( settings=_configured_settings( + app_hostname=" localhost:8000 ", ticktick_client_id=" ticktick-client-id ", ticktick_client_secret=" ticktick-client-secret ", - ticktick_redirect_uri=" http://localhost:8000/ticktick/auth/code ", ) ) default_auth_state_store.pending_state = "trimmed-state" @@ -214,9 +215,9 @@ def test_homeassistant_publish_creates_ticktick_action_task( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") monkeypatch.setenv("TICKTICK_TOKEN", "ticktick-access-token") monkeypatch.setenv("HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "project-123") get_settings.cache_clear() @@ -260,9 +261,9 @@ def test_ticktick_auth_start_redirects_authenticated_user( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect") @@ -296,9 +297,9 @@ def test_ticktick_auth_callback_persists_token( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "callback-state" @@ -337,9 +338,9 @@ def test_ticktick_auth_callback_redirects_on_invalid_state( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "expected-state" @@ -361,9 +362,9 @@ def test_ticktick_auth_callback_redirects_when_token_exchange_fails( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "callback-state"