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_LOGIN_URL = "https://market.evotor.ru/#/store/auth/login" 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() return RedirectResponse(EVOTOR_LOGIN_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)