feat: add nginx reverse proxy and Let's Encrypt TLS setup

- Add nginx config for SSL termination and HTTP->HTTPS redirect
- Add init-letsencrypt.sh script for automated certificate provisioning
- Update docker-compose.yml: add nginx service, expose web on internal port only
- Fix Evotor OAuth token exchange: move client credentials to form body
- Add request logging for token exchange errors
- Update BASE_URL to https://evosync.ru and set default in docker-compose
- Add refresh_token field to EvotorConnection model

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-06 16:57:46 +03:00
parent 9aeef73b10
commit bacfd8fe54
7 changed files with 208 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
import asyncio
import logging
from datetime import datetime
from datetime import datetime, timedelta
import httpx
@@ -10,9 +10,37 @@ from web.models import EvotorConnection, VkConnection
logger = logging.getLogger("uvicorn.error")
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
VK_USERS_GET_URL = "https://api.vk.com/method/users.get"
VK_API_VERSION = "5.131"
# Refresh Evotor token if it expires within this window
REFRESH_BEFORE_EXPIRY = timedelta(hours=2)
async def _refresh_evotor_token(conn: EvotorConnection) -> str | None:
"""Attempt to refresh the Evotor access token. Returns new access token or None."""
from web.config import settings
if not conn.refresh_token:
return None
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
EVOTOR_TOKEN_URL,
data={
"grant_type": "refresh_token",
"refresh_token": conn.refresh_token,
},
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
timeout=15,
)
if resp.status_code != 200:
return None
data = resp.json()
return data if data.get("access_token") else None
except Exception:
return None
async def check_evotor_connection(access_token: str) -> bool:
try:
@@ -46,15 +74,46 @@ async def check_vk_connection(access_token: str) -> bool:
async def run_health_checks() -> None:
db = SessionLocal()
try:
now = datetime.utcnow()
evotor_connections = db.query(EvotorConnection).all()
for conn in evotor_connections:
conn.is_online = await check_evotor_connection(conn.access_token)
conn.last_checked_at = datetime.utcnow()
# Proactively refresh if token expires soon
needs_refresh = (
conn.refresh_token and
conn.token_expires_at and
conn.token_expires_at - now < REFRESH_BEFORE_EXPIRY
)
if needs_refresh:
token_data = await _refresh_evotor_token(conn)
if token_data:
conn.access_token = token_data["access_token"]
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
expires_in = token_data.get("expires_in")
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
logger.info("Refreshed Evotor token for user_id=%d", conn.user_id)
is_online = await check_evotor_connection(conn.access_token)
# If offline and not yet tried refresh, attempt it now
if not is_online and conn.refresh_token and not needs_refresh:
token_data = await _refresh_evotor_token(conn)
if token_data:
conn.access_token = token_data["access_token"]
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
expires_in = token_data.get("expires_in")
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
is_online = await check_evotor_connection(conn.access_token)
if is_online:
logger.info("Evotor token refreshed after failed check for user_id=%d", conn.user_id)
conn.is_online = is_online
conn.last_checked_at = now
vk_connections = db.query(VkConnection).all()
for conn in vk_connections:
conn.is_online = await check_vk_connection(conn.access_token)
conn.last_checked_at = datetime.utcnow()
conn.last_checked_at = now
db.commit()
logger.info(