Files
evo-sync/web/routes/evotor.py

204 lines
6.4 KiB
Python
Raw Normal View History

import logging
import httpx
from datetime import datetime, timedelta
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.config import settings
from web.database import get_db
from web.models import User, EvotorConnection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/evotor")
templates = Jinja2Templates(directory="web/templates")
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
# Pending connections older than this are ignored during linking
PENDING_LINK_WINDOW_SECONDS = 300
@router.get("")
def evotor_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
error = request.query_params.get("error")
return templates.TemplateResponse("evotor.html", {
"request": request,
"user": user,
"connection": connection,
"error": error,
})
@router.get("/connect")
def evotor_connect(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
# Record when this user initiated a connect so we can link the incoming webhook
request.session["evotor_connect_user_id"] = user.id
request.session["evotor_connect_at"] = datetime.utcnow().isoformat()
url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID)
return RedirectResponse(url, 302)
class EvotorTokenPayload(BaseModel):
userId: str
token: str
@router.post("/callback")
async def evotor_callback(
request: Request,
payload: EvotorTokenPayload,
db: Session = Depends(get_db),
):
"""
Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} here
after the user authorizes the app in their Evotor account.
"""
# Verify the Authorization header matches our configured webhook secret
if settings.EVOTOR_WEBHOOK_SECRET:
auth_header = request.headers.get("Authorization", "")
expected = f"Bearer {settings.EVOTOR_WEBHOOK_SECRET}"
if auth_header != expected:
logger.warning("Evotor webhook: invalid Authorization header")
raise HTTPException(status_code=401, detail="Unauthorized")
now = datetime.utcnow()
# Fetch store info using the received token
store_id = None
store_name = None
try:
async with httpx.AsyncClient() as client:
stores_response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {payload.token}"},
timeout=15,
)
if stores_response.status_code == 200:
stores = stores_response.json()
items = stores.get("items", stores) if isinstance(stores, dict) else stores
if items:
store_id = items[0].get("uuid") or items[0].get("id")
store_name = items[0].get("name")
except Exception:
pass # Store info is optional
# Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called)
connection = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == payload.userId
).first()
if connection:
connection.access_token = payload.token
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = now
connection.updated_at = now
else:
connection = EvotorConnection(
evotor_user_id=payload.userId,
access_token=payload.token,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=now,
)
db.add(connection)
db.commit()
logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId)
return JSONResponse({"status": "ok"})
@router.get("/link")
def evotor_link(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
"""
Called when the user returns to our app after authorizing on Evotor.
Links the most recently received unlinked token to this user.
"""
if not user:
return RedirectResponse("/login", 303)
connect_user_id = request.session.pop("evotor_connect_user_id", None)
connect_at_str = request.session.pop("evotor_connect_at", None)
if not connect_user_id or connect_user_id != user.id or not connect_at_str:
return RedirectResponse("/evotor?error=session_expired", 303)
try:
connect_at = datetime.fromisoformat(connect_at_str)
except ValueError:
return RedirectResponse("/evotor?error=session_expired", 303)
cutoff = connect_at - timedelta(seconds=10) # allow slight clock drift
now = datetime.utcnow()
if (now - connect_at).total_seconds() > PENDING_LINK_WINDOW_SECONDS:
return RedirectResponse("/evotor?error=link_timeout", 303)
# Find an unlinked connection received after the user clicked "Connect"
pending = (
db.query(EvotorConnection)
.filter(
EvotorConnection.user_id.is_(None),
EvotorConnection.connected_at >= cutoff,
)
.order_by(EvotorConnection.connected_at.desc())
.first()
)
if not pending:
return RedirectResponse("/evotor?error=token_not_received", 303)
# Detach any existing connection for this user
existing = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if existing:
db.delete(existing)
db.flush()
pending.user_id = user.id
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/disconnect")
async def evotor_disconnect(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
db.delete(connection)
db.commit()
return RedirectResponse("/connections", 303)