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(

View File

@@ -0,0 +1,24 @@
"""add refresh_token and token_expires_at to evotor_connections
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-03-06 00:04:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'e5f6a7b8c9d0'
down_revision = 'd4e5f6a7b8c9'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('evotor_connections', sa.Column('refresh_token', sa.Text(), nullable=True))
op.add_column('evotor_connections', sa.Column('token_expires_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('evotor_connections', 'token_expires_at')
op.drop_column('evotor_connections', 'refresh_token')

View File

@@ -37,6 +37,8 @@ class EvotorConnection(Base):
access_token = Column(Text, nullable=False)
store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime, nullable=True)
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -1,7 +1,10 @@
import secrets
import logging
import httpx
from fastapi import APIRouter, Request, Depends
logger = logging.getLogger(__name__)
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
@@ -85,16 +88,23 @@ async def evotor_callback(
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _redirect_uri(),
"client_id": settings.EVOTOR_CLIENT_ID,
"client_secret": settings.EVOTOR_CLIENT_SECRET,
},
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
timeout=15,
)
token_response.raise_for_status()
token_data = token_response.json()
except Exception:
except httpx.HTTPStatusError as e:
logger.error("Evotor token exchange HTTP error %s: %s", e.response.status_code, e.response.text)
return RedirectResponse("/evotor?error=token_exchange", 303)
except Exception as e:
logger.error("Evotor token exchange failed: %s", e, exc_info=True)
return RedirectResponse("/evotor?error=token_exchange", 303)
access_token = token_data.get("access_token")
refresh_token = token_data.get("refresh_token")
expires_in = token_data.get("expires_in")
if not access_token:
return RedirectResponse("/evotor?error=no_token", 303)
@@ -118,22 +128,29 @@ async def evotor_callback(
pass # Store info is optional; token is still saved
# Save or update connection
from datetime import datetime
from datetime import datetime, timedelta
now = datetime.utcnow()
token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
connection.access_token = access_token
connection.refresh_token = refresh_token
connection.token_expires_at = token_expires_at
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = datetime.utcnow()
connection.last_checked_at = now
else:
connection = EvotorConnection(
user_id=user.id,
access_token=access_token,
refresh_token=refresh_token,
token_expires_at=token_expires_at,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=datetime.utcnow(),
last_checked_at=now,
)
db.add(connection)
db.commit()