feat: add production deployment for rf4spotter.ru

This commit is contained in:
ik
2026-09-06 13:52:30 +07:00
parent b45ba38a00
commit a4bd395856
10 changed files with 350 additions and 3 deletions
+22 -1
View File
@@ -1,8 +1,9 @@
from pydantic import Field
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
deployment_environment: str = "development"
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
admin_token: str = "change-me-in-production"
s3_endpoint_url: str = "http://localhost:9000"
@@ -18,7 +19,27 @@ class Settings(BaseSettings):
import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret"
log_level: str = "INFO"
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@model_validator(mode="after")
def reject_insecure_production_defaults(self) -> "Settings":
if self.deployment_environment != "production":
return self
insecure = {
"ADMIN_TOKEN": self.admin_token == "change-me-in-production" or len(self.admin_token) < 32,
"RATE_LIMIT_SECRET": self.rate_limit_secret == "change-rate-limit-secret" or len(self.rate_limit_secret) < 32,
"S3_ACCESS_KEY": self.s3_access_key == "rf4-local" or len(self.s3_access_key) < 12,
"S3_SECRET_KEY": self.s3_secret_key == "rf4-local-secret" or len(self.s3_secret_key) < 32,
}
invalid = [name for name, failed in insecure.items() if failed]
if invalid:
raise ValueError(f"insecure production settings: {', '.join(invalid)}")
if not self.cors_origins or any(not origin.startswith("https://") for origin in self.cors_origins):
raise ValueError("production CORS_ORIGINS must contain only HTTPS origins")
if not self.s3_public_endpoint_url.startswith("https://"):
raise ValueError("production S3_PUBLIC_ENDPOINT_URL must use HTTPS")
return self
settings = Settings()
+6 -1
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger("rf4.api")
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"],
allow_origins=settings.cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
@@ -50,6 +50,11 @@ async def structured_request_log(request: Request, call_next):
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
if settings.deployment_environment == "production":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
except Exception as exc:
logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__})
+36
View File
@@ -0,0 +1,36 @@
import pytest
from pydantic import ValidationError
from app.config import Settings
def production_settings(**changes) -> Settings:
values = {
"deployment_environment": "production",
"admin_token": "a" * 32,
"rate_limit_secret": "r" * 32,
"s3_access_key": "access-key-1234",
"s3_secret_key": "s" * 32,
"s3_public_endpoint_url": "https://files.rf4spotter.ru",
"cors_origins": ["https://rf4spotter.ru"],
}
return Settings(**(values | changes))
def test_production_settings_accept_real_domains_and_secrets() -> None:
settings = production_settings()
assert settings.cors_origins == ["https://rf4spotter.ru"]
@pytest.mark.parametrize(("field", "value"), [
("admin_token", "change-me-in-production"),
("rate_limit_secret", "short"),
("s3_access_key", "rf4-local"),
("s3_secret_key", "rf4-local-secret"),
("cors_origins", ["http://rf4spotter.ru"]),
("s3_public_endpoint_url", "http://files.rf4spotter.ru"),
])
def test_production_settings_reject_insecure_values(field: str, value: object) -> None:
with pytest.raises(ValidationError):
production_settings(**{field: value})