diff --git a/.env.example b/.env.example index 8300125..f85c29d 100644 --- a/.env.example +++ b/.env.example @@ -1,90 +1,83 @@ -# TJWater Server 环境变量配置模板 -# 复制此文件为 .env 并填写实际值 -# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。 -ENVIRONMENT="production" -NETWORK_NAME="tjwater" -# ============================================ -# 敏感配置加密 (必填) -# ============================================ +# TJWaterServerBinary 环境变量模板 +# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。 +# 生产配置由部署主机的受控 backend.env 注入,不随镜像或 CI/CD 同步本地 .env。 +# 进程环境变量优先;修改 Compose env_file 后需重新创建容器。 -# Fernet 格式,生产环境必须替换为独立密钥 -# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置 +# 基础运行 +PROJECT_NAME="TJWater Server" +ENVIRONMENT="production" +API_V1_STR="/api/v1" +NETWORK_NAME="tjwater" + +# Fernet 格式;用于加密项目数据库 DSN、GeoServer 管理密码等敏感配置。 +# 生成方式:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key" -# ============================================ -# 数据库配置 (PostgreSQL) -# ============================================ +# 业务数据库(PostgreSQL) DB_NAME="tjwater" DB_HOST="localhost" DB_PORT="5432" DB_USER="tjwater" DB_PASSWORD="password" -# ============================================ -# 数据库配置 (TimescaleDB) -# ============================================ +# 时序数据库(TimescaleDB) TIMESCALEDB_DB_NAME="tjwater" TIMESCALEDB_DB_HOST="localhost" TIMESCALEDB_DB_PORT="5433" TIMESCALEDB_DB_USER="tjwater" TIMESCALEDB_DB_PASSWORD="password" -# ============================================ -# 元数据数据库配置 (Metadata DB) -# ============================================ +# 元数据数据库 METADATA_DB_NAME="system_hub" METADATA_DB_HOST="localhost" METADATA_DB_PORT="5432" METADATA_DB_USER="tjwater" METADATA_DB_PASSWORD="password" +METADATA_DB_POOL_SIZE=5 +METADATA_DB_MAX_OVERFLOW=10 -# Per-project synchronous connection pools -PROJECT_PG_CACHE_SIZE="16" -PROJECT_TS_CACHE_SIZE="16" -PROJECT_PG_POOL_MIN_SIZE="0" -PROJECT_PG_POOL_SIZE="4" -PROJECT_PG_MAX_OVERFLOW="2" -PROJECT_TS_POOL_MIN_SIZE="0" -PROJECT_TS_POOL_MAX_SIZE="4" +# 项目数据库连接池、缓存与模板 +PROJECT_PG_CACHE_SIZE=16 +PROJECT_TS_CACHE_SIZE=16 +PROJECT_PG_POOL_MIN_SIZE=0 +PROJECT_PG_POOL_SIZE=4 +PROJECT_PG_MAX_OVERFLOW=2 +PROJECT_TS_POOL_MIN_SIZE=0 +PROJECT_TS_POOL_MAX_SIZE=4 WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template" TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template" -WNDB_TEMP_DB_MAX_COUNT="8" +WNDB_TEMP_DB_MAX_COUNT=8 -# ============================================ # GeoServer 项目供应 -# ============================================ GEOSERVER_URL="http://localhost:8080/geoserver" GEOSERVER_USERNAME="admin" GEOSERVER_PASSWORD="password" -# 留空时分别复用 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD。 +# 留空时分别复用 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD。 # GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。 GEOSERVER_DB_HOST="" GEOSERVER_DB_PORT="" GEOSERVER_DB_USER="" GEOSERVER_DB_PASSWORD="" -# 30 天客户端缓存,单位为秒。 -GEOSERVER_CLIENT_CACHE_SECONDS="2592000" +# 客户端缓存时间,单位为秒;默认 30 天。 +GEOSERVER_CLIENT_CACHE_SECONDS=2592000 -# ============================================ -# Keycloak JWT (可选) -# ============================================ -KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" -KEYCLOAK_ALGORITHM=RS256 +# Keycloak JWT 校验 +# 未启用认证时可留空;生产环境应注入完整公钥和预期 audience。 +KEYCLOAK_PUBLIC_KEY="" +KEYCLOAK_ALGORITHM="RS256" KEYCLOAK_AUDIENCE="account" KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900 +# 浏览器跨域访问(主 API) +# 必须填写明确来源(协议、域名或 IP、端口),英文逗号分隔;不允许 *。 +CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173 -# ============================================ -# Bocha Web Search API -# ============================================ -BOCHA_API_KEY="sk-your-bocha-api-key" +# Bocha 网页搜索(旧版仍保留;未启用时令牌留空) +BOCHA_API_KEY="" BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search" BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30 -# ============================================ -# Tianditu Geocoding API -# ============================================ -TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token" +# 天地图地理编码(旧版使用 TIANDITU_GEOCODER_TOKEN) +TIANDITU_GEOCODER_TOKEN="" TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder" TIANDITU_GEOCODER_TIMEOUT_SECONDS=30 diff --git a/README.md b/README.md index 0a109a2..535c0c1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,22 @@ conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload conda activate server ``` +## CORS 配置 + +后端通过 `.env` 或进程环境变量中的 `CORS_ALLOWED_ORIGINS` 设置允许的浏览器页面来源, +进程环境变量优先。必须使用英文逗号分隔的明确来源,不能填写 `*`,例如: + +```dotenv +CORS_ALLOWED_ORIGINS=https://demo.waternetwork.cn,http://192.168.1.133,https://192.168.1.133,http://192.168.1.110,https://192.168.1.110 +``` + +未设置时默认只允许本机 `5173`、`15173` 开发来源。来源需包含协议、域名或 IP、端口, +不带路径;非默认端口须逐项列出。CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果。 +保留跨域凭据支持及 `Authorization`、`X-Project-Id` 请求头,接口认证和权限校验继续生效。 + +修改后需重启后端进程。容器部署需同步更新宿主机的受控 `backend.env`; +使用 Compose `env_file` 注入时,需要重新创建容器才能读取新值,CI/CD 不会自动同步本地 `.env`。 + ## 常用命令 ```bash diff --git a/app/core/config.py b/app/core/config.py index 273e612..2f77617 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -63,6 +63,19 @@ class Settings(BaseSettings): KEYCLOAK_AUDIENCE: str = "" KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900 + # Browser cross-origin access; process environment overrides .env. + CORS_ALLOWED_ORIGINS: str = ( + "http://127.0.0.1:5173,http://localhost:5173," + "http://127.0.0.1:15173,http://localhost:15173" + ) + + @property + def cors_allowed_origins(self) -> list[str]: + origins = [value.strip() for value in self.CORS_ALLOWED_ORIGINS.split(",") if value.strip()] + if "*" in origins: + raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins") + return origins + # Bocha Web Search API BOCHA_API_KEY: str = "" BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search" diff --git a/app/main.py b/app/main.py index a71bd87..424cbbe 100644 --- a/app/main.py +++ b/app/main.py @@ -66,7 +66,7 @@ app.add_middleware(AuditMiddleware) # 确保这是你最后一个添加的 app.add_middleware app.add_middleware( CORSMiddleware, - allow_origins=["*"], # 允许所有来源 + allow_origins=settings.cors_allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/tests/unit/test_cors.py b/tests/unit/test_cors.py new file mode 100644 index 0000000..56cf3b4 --- /dev/null +++ b/tests/unit/test_cors.py @@ -0,0 +1,73 @@ +import pytest +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from app.core.config import Settings + + +ALLOWED_ORIGINS = [ + "https://demo.waternetwork.cn", + "http://192.168.1.133", + "https://192.168.1.133", + "http://192.168.1.110", + "https://192.168.1.110", +] + + +@pytest.mark.parametrize("origins", ["*", " *, http://localhost:5173", "http://localhost:5173,*"]) +def test_wildcard_origins_are_rejected(origins: str): + with pytest.raises(ValueError, match="must list explicit origins"): + Settings(_env_file=None, CORS_ALLOWED_ORIGINS=origins).cors_allowed_origins + + +@pytest.mark.parametrize( + "origin", + ALLOWED_ORIGINS + [ + "http://192.168.1.114", + "http://192.168.1.133:5173", + "https://next-demo.waternetwork.cn", + "http://192.168.1.133.evil.example", + "null", + ], +) +def test_explicit_origin_preflight(origin: str): + settings = Settings(_env_file=None, CORS_ALLOWED_ORIGINS=", ".join(ALLOWED_ORIGINS)) + app = FastAPI() + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + with TestClient(app) as client: + response = client.options( + "/api/v1/projects", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization,x-project-id", + }, + ) + allowed = origin in ALLOWED_ORIGINS + assert response.status_code == (200 if allowed else 400) + assert response.headers.get("access-control-allow-origin") == (origin if allowed else None) + if allowed: + assert response.headers["access-control-allow-credentials"] == "true" + assert "authorization" in response.headers["access-control-allow-headers"] + + +def test_default_origins_are_explicit_local_sources(monkeypatch): + monkeypatch.delenv("CORS_ALLOWED_ORIGINS", raising=False) + settings = Settings(_env_file=None) + assert settings.cors_allowed_origins == [ + "http://127.0.0.1:5173", "http://localhost:5173", + "http://127.0.0.1:15173", "http://localhost:15173", + ] + + +def test_environment_controls_origins(monkeypatch): + monkeypatch.setenv("CORS_ALLOWED_ORIGINS", "http://192.168.1.133,https://192.168.1.110") + settings = Settings(_env_file=None) + assert settings.cors_allowed_origins == ["http://192.168.1.133", "https://192.168.1.110"]