Compare commits
18 Commits
v1.8.2
...
background
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0926757b7a | ||
|
|
13c32e9181 | ||
|
|
6b9eb562ba | ||
|
|
9558333c94 | ||
|
|
40e7abd012 | ||
|
|
3d7a456299 | ||
|
|
5acf597944 | ||
|
|
3f4bbcbb0d | ||
|
|
c8beeaf1b1 | ||
|
|
5ee8419c7c | ||
|
|
e376c86fbe | ||
|
|
69e21a18c9 | ||
|
|
90a2f7be1f | ||
|
|
d4633a0f46 | ||
|
|
b72b0e78b0 | ||
|
|
2a04099f95 | ||
|
|
58f9b74a1c | ||
|
|
8c9c328302 |
@@ -2,6 +2,12 @@ DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
|
||||
SECRET_KEY=your-random-secret-key-here
|
||||
BASE_URL=http://localhost:8000
|
||||
|
||||
EVOTOR_APP_ID=your-evotor-app-id
|
||||
EVOTOR_WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
VK_CLIENT_ID=your-vk-client-id
|
||||
VK_CLIENT_SECRET=your-vk-client-secret
|
||||
|
||||
DB_ROOT_PASSWORD=rootpass
|
||||
DB_NAME=evosync
|
||||
DB_USER=evosync
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ passwords.txt
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
certbot
|
||||
|
||||
55
CHANGELOG.md
55
CHANGELOG.md
@@ -5,47 +5,46 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.8.0] - 2026-03-06
|
||||
## [1.8.2] - 2026-03-06
|
||||
|
||||
### Added
|
||||
### Miscellaneous
|
||||
|
||||
- Connections dashboard (`/connections`) — unified page for all service integrations with online/offline status indicators
|
||||
- Add-connection page (`/connections/add`) — shows only unconnected services; "all connected" state when none remain
|
||||
- VK OAuth connection — connect VK account via OAuth, store access token and profile info
|
||||
- Background health checker — runs every 10 minutes, checks Evotor and VK tokens, updates `is_online` / `last_checked_at`
|
||||
- Sync configuration page (`/sync`) — master enable/disable toggle, confirm-and-start button, filter summary, warnings for missing connections
|
||||
- Catalog browser (`/catalog`) — browse Evotor stores, groups, and products in table views with cache auto-refresh on first visit
|
||||
- Catalog filter management — include/exclude rules per store/group/product via inline dropdown; rules stored in `sync_filters` table
|
||||
- Catalog CSV export — download stores, groups, or products as UTF-8 BOM CSV (Excel-compatible)
|
||||
- Alembic migrations for all new tables: `evotor_connections` (health fields), `vk_connections`, `sync_configs`, `sync_filters`, `cached_stores`, `cached_groups`, `cached_products`
|
||||
- `run/read_config.py` — CLI helper for shell sync scripts to read per-user sync config as JSON
|
||||
- Replace EvoSync with ЭВОСИНК throughout UI
|
||||
|
||||
### Changed
|
||||
## [1.8.1] - 2026-03-06
|
||||
|
||||
- Navbar: replaced "Эвотор" link with "Подключения", added "Каталог" and "Синхронизация" links
|
||||
- Evotor and VK connect/disconnect flows now redirect to `/connections`
|
||||
- Back links on `/evotor` and `/vk` pages updated to "Вернуться к подключениям"
|
||||
- VK connection card icon changed to `bi-bag` (shopping bag) to reflect VK Market use case
|
||||
- Password reset and email confirmation pages: replaced dev-mode console instructions with user-facing copy
|
||||
### Documentation
|
||||
|
||||
- Update changelog for v1.7.3
|
||||
- Fix changelog order — 1.8.0 before 1.7.3
|
||||
|
||||
### Other
|
||||
|
||||
- V1.8.1
|
||||
|
||||
## [1.7.3] - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
- Nginx reverse proxy configuration — SSL termination, HTTP→HTTPS redirect, proxy to uvicorn
|
||||
- `scripts/init-letsencrypt.sh` — automated Let's Encrypt certificate provisioning via certbot webroot challenge
|
||||
- Request logging for Evotor token exchange errors to aid debugging
|
||||
- Add nginx reverse proxy and Let's Encrypt TLS setup
|
||||
|
||||
### Fixed
|
||||
### Other
|
||||
|
||||
- Evotor OAuth token exchange — move `client_id` and `client_secret` from HTTP Basic Auth to form body fields (resolves `invalid_client` errors)
|
||||
- Docker environment — pass `EVOTOR_CLIENT_ID` and `EVOTOR_CLIENT_SECRET` to web container via docker-compose
|
||||
- V1.7.3
|
||||
|
||||
### Changed
|
||||
## [1.8.0] - 2026-03-06
|
||||
|
||||
- Default `BASE_URL` changed to `https://evosync.ru` for production deployment
|
||||
- `docker-compose.yml` — web service now exposes port 8000 internally only (accessed via nginx)
|
||||
- Added `refresh_token` field to `EvotorConnection` model for future token refresh logic
|
||||
### Added
|
||||
|
||||
- Add Evotor OAuth connection feature with formatted phone input
|
||||
- Add Alembic database migrations
|
||||
- Add connections dashboard with background health checks
|
||||
- Add VK OAuth connection with health checks
|
||||
- Release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Add semantic versioning and automatic changelog generation
|
||||
|
||||
## [1.7.2] - 2026-03-05
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8080:8000"
|
||||
environment:
|
||||
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@172.25.0.1:3306/${DB_NAME}
|
||||
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME}
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
- BASE_URL=${BASE_URL:-https://evosync.ru}
|
||||
- EVOTOR_CLIENT_ID=${EVOTOR_CLIENT_ID}
|
||||
- EVOTOR_CLIENT_SECRET=${EVOTOR_CLIENT_SECRET}
|
||||
- EVOTOR_APP_ID=${EVOTOR_APP_ID}
|
||||
- EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
|
||||
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
||||
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
||||
volumes:
|
||||
@@ -20,6 +20,8 @@ services:
|
||||
- ./alembic.ini:/app/alembic.ini
|
||||
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
# sync:
|
||||
# build:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
upstream web {
|
||||
server 127.0.0.1:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name evosync.ru www.evosync.ru;
|
||||
@@ -23,7 +27,7 @@ server {
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
location / {
|
||||
proxy_pass http://web:8000;
|
||||
proxy_pass http://web;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
57
scripts/evotor-get-token.sh
Normal file
57
scripts/evotor-get-token.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Obtain an Evotor developer access token via password grant (no browser required).
|
||||
# Uses dev.evotor.ru credentials (your Evotor developer account).
|
||||
#
|
||||
# Usage: ./scripts/evotor-get-token.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Load .env if present
|
||||
if [[ -f .env ]]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
EVOTOR_TOKEN_URL="https://dev.evotor.ru/oauth/token"
|
||||
|
||||
# Prompt for credentials
|
||||
read -rp "Evotor developer login (email): " EVOTOR_LOGIN
|
||||
read -rsp "Evotor developer password: " EVOTOR_PASSWORD
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "A 2FA code will be sent to your email if this IP is not recognized."
|
||||
read -rp "2FA code (leave blank if not required): " EVOTOR_2FA
|
||||
|
||||
# Build request body
|
||||
BODY="type=LOGIN&grant_type=password&client_id=Evo-UI&username=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_LOGIN")&password=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_PASSWORD")"
|
||||
|
||||
EXTRA_HEADERS=()
|
||||
if [[ -n "$EVOTOR_2FA" ]]; then
|
||||
EXTRA_HEADERS+=(-H "2fa_confirmation: $EVOTOR_2FA")
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Requesting token..."
|
||||
RESPONSE=$(curl -s -X POST "$EVOTOR_TOKEN_URL" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
"${EXTRA_HEADERS[@]}" \
|
||||
-d "$BODY")
|
||||
|
||||
echo
|
||||
echo "Response:"
|
||||
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$RESPONSE"
|
||||
|
||||
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$ACCESS_TOKEN" ]]; then
|
||||
echo
|
||||
echo "ERROR: No access_token in response." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Access token:"
|
||||
echo "$ACCESS_TOKEN"
|
||||
echo
|
||||
echo "To save this token to .env, add or update:"
|
||||
echo " EVOTOR_ACCESS_TOKEN=$ACCESS_TOKEN"
|
||||
@@ -1,57 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Obtain TLS certificates from Let's Encrypt for evosync.ru
|
||||
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
|
||||
# Requires nginx running on the host with acme-challenge location configured
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="evosync.ru"
|
||||
EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}"
|
||||
COMPOSE="docker compose"
|
||||
CERTBOT_DIR="./certbot"
|
||||
ACME_DIR="/var/www/certbot"
|
||||
|
||||
echo "==> Creating certbot directories..."
|
||||
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
|
||||
|
||||
echo "==> Starting nginx (HTTP only, for ACME challenge)..."
|
||||
# Temporarily use a basic config that doesn't require certs
|
||||
cat > nginx/nginx-temp.conf <<'TMPCONF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name evosync.ru www.evosync.ru;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 200 'Setting up TLS...';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
TMPCONF
|
||||
|
||||
$COMPOSE up -d nginx
|
||||
echo "==> Ensuring acme-challenge directory exists on host..."
|
||||
sudo mkdir -p "$ACME_DIR"
|
||||
sudo chmod 755 "$ACME_DIR"
|
||||
|
||||
echo "==> Requesting certificate from Let's Encrypt..."
|
||||
docker run --rm \
|
||||
-v "$(pwd)/$CERTBOT_DIR/conf:/etc/letsencrypt" \
|
||||
-v "$(pwd)/$CERTBOT_DIR/www:/var/www/certbot" \
|
||||
--network "${COMPOSE_PROJECT_NAME:-evo-syncgit}_default" \
|
||||
certbot/certbot certonly \
|
||||
sudo certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
--webroot-path="$ACME_DIR" \
|
||||
--email "$EMAIL" \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
-d "$DOMAIN" \
|
||||
-d "www.$DOMAIN"
|
||||
|
||||
echo "==> Restoring production nginx config..."
|
||||
rm -f nginx/nginx-temp.conf
|
||||
|
||||
echo "==> Restarting nginx with TLS..."
|
||||
$COMPOSE restart nginx
|
||||
echo "==> Copying certificates to project directory..."
|
||||
sudo cp "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" "$CERTBOT_DIR/conf/fullchain.pem"
|
||||
sudo cp "/etc/letsencrypt/live/$DOMAIN/privkey.pem" "$CERTBOT_DIR/conf/privkey.pem"
|
||||
sudo chown "$(whoami):$(whoami)" "$CERTBOT_DIR/conf"/*.pem
|
||||
|
||||
echo "==> Done! TLS certificate installed for $DOMAIN"
|
||||
echo " Set up auto-renewal with: sudo crontab -e"
|
||||
echo " Add: 0 3 * * * cd $(pwd) && docker run --rm -v $(pwd)/$CERTBOT_DIR/conf:/etc/letsencrypt -v $(pwd)/$CERTBOT_DIR/www:/var/www/certbot certbot/certbot renew --quiet && docker compose restart nginx"
|
||||
echo ""
|
||||
echo "Certificate files:"
|
||||
echo " - $CERTBOT_DIR/conf/fullchain.pem"
|
||||
echo " - $CERTBOT_DIR/conf/privkey.pem"
|
||||
echo ""
|
||||
echo "Configure nginx:"
|
||||
echo " ssl_certificate $CERTBOT_DIR/conf/fullchain.pem;"
|
||||
echo " ssl_certificate_key $CERTBOT_DIR/conf/privkey.pem;"
|
||||
echo ""
|
||||
echo "Set up auto-renewal with: sudo crontab -e"
|
||||
echo "Add: 0 3 * * * certbot renew --quiet && systemctl reload nginx"
|
||||
|
||||
@@ -4,12 +4,11 @@ from pydantic_settings import BaseSettings
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
BASE_URL: str = "http://localhost:8000"
|
||||
BASE_URL: str = "http://localhost:8080"
|
||||
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
||||
|
||||
EVOTOR_CLIENT_ID: str = ""
|
||||
EVOTOR_CLIENT_SECRET: str = ""
|
||||
EVOTOR_SCOPES: str = "store:read product:read"
|
||||
EVOTOR_APP_ID: str = ""
|
||||
EVOTOR_WEBHOOK_SECRET: str = ""
|
||||
|
||||
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 402:
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items", data) if isinstance(data, dict) else data
|
||||
@@ -46,6 +48,8 @@ async def fetch_products(access_token: str, store_id: str) -> list[dict]:
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 402:
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items", data) if isinstance(data, dict) else data
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""evotor webhook token flow: add evotor_user_id, make user_id nullable
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0
|
||||
Branch Labels: None
|
||||
Depends On: None
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'f6a7b8c9d0e1'
|
||||
down_revision = 'e5f6a7b8c9d0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Check existing columns
|
||||
columns = [row[0] for row in conn.execute(sa.text(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'evotor_connections'"
|
||||
))]
|
||||
|
||||
if 'evotor_user_id' not in columns:
|
||||
op.add_column('evotor_connections',
|
||||
sa.Column('evotor_user_id', sa.String(255), nullable=True))
|
||||
|
||||
# Check existing indexes
|
||||
indexes = [row[2] for row in conn.execute(sa.text(
|
||||
"SHOW INDEX FROM evotor_connections"
|
||||
))]
|
||||
|
||||
if 'uq_evotor_connections_evotor_user_id' not in indexes:
|
||||
op.create_unique_constraint('uq_evotor_connections_evotor_user_id',
|
||||
'evotor_connections', ['evotor_user_id'])
|
||||
|
||||
if 'ix_evotor_connections_evotor_user_id' not in indexes:
|
||||
op.create_index('ix_evotor_connections_evotor_user_id',
|
||||
'evotor_connections', ['evotor_user_id'])
|
||||
|
||||
op.alter_column('evotor_connections', 'user_id',
|
||||
existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column('evotor_connections', 'user_id',
|
||||
existing_type=sa.Integer(), nullable=False)
|
||||
op.drop_index('ix_evotor_connections_evotor_user_id', 'evotor_connections')
|
||||
op.drop_constraint('uq_evotor_connections_evotor_user_id', 'evotor_connections')
|
||||
op.drop_column('evotor_connections', 'evotor_user_id')
|
||||
@@ -33,7 +33,8 @@ class EvotorConnection(Base):
|
||||
__tablename__ = "evotor_connections"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True)
|
||||
evotor_user_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
access_token = Column(Text, nullable=False)
|
||||
store_id = Column(String(255), nullable=True)
|
||||
store_name = Column(String(255), nullable=True)
|
||||
|
||||
@@ -17,7 +17,7 @@ SERVICE_TYPES = [
|
||||
"icon": "bi-shop",
|
||||
"description": "Подключите кассу Эвотор для синхронизации каталога товаров.",
|
||||
"configure_url": "/evotor",
|
||||
"connect_url": "/evotor/connect",
|
||||
"connect_url": "/evotor",
|
||||
},
|
||||
{
|
||||
"type": "vk",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import secrets
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from fastapi.responses import RedirectResponse
|
||||
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
|
||||
@@ -14,16 +13,16 @@ 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_AUTHORIZE_URL = "https://oauth.evotor.ru/oauth/authorize"
|
||||
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
||||
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
|
||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||
|
||||
|
||||
def _redirect_uri() -> str:
|
||||
return f"{settings.BASE_URL}/evotor/callback"
|
||||
# Pending connections older than this are ignored during linking
|
||||
PENDING_LINK_WINDOW_SECONDS = 300
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -50,72 +49,47 @@ def evotor_connect(request: Request, user: User | None = Depends(get_current_use
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
request.session["evotor_oauth_state"] = state
|
||||
# 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()
|
||||
|
||||
params = (
|
||||
f"?client_id={settings.EVOTOR_CLIENT_ID}"
|
||||
f"&response_type=code"
|
||||
f"&redirect_uri={_redirect_uri()}"
|
||||
f"&scope={settings.EVOTOR_SCOPES.replace(' ', '%20')}"
|
||||
f"&state={state}"
|
||||
)
|
||||
return RedirectResponse(EVOTOR_AUTHORIZE_URL + params, 302)
|
||||
url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID)
|
||||
return RedirectResponse(url, 302)
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
class EvotorTokenPayload(BaseModel):
|
||||
userId: str
|
||||
token: str
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def evotor_callback(
|
||||
request: Request,
|
||||
payload: EvotorTokenPayload,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
"""
|
||||
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")
|
||||
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
saved_state = request.session.pop("evotor_oauth_state", None)
|
||||
now = datetime.utcnow()
|
||||
|
||||
if not code or not state or state != saved_state:
|
||||
return RedirectResponse("/evotor?error=invalid_state", 303)
|
||||
|
||||
# Exchange code for access token
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
EVOTOR_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": _redirect_uri(),
|
||||
"client_id": settings.EVOTOR_CLIENT_ID,
|
||||
"client_secret": settings.EVOTOR_CLIENT_SECRET,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
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)
|
||||
|
||||
# Fetch first store to save store info
|
||||
# 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 {access_token}"},
|
||||
headers={"Authorization": f"Bearer {payload.token}"},
|
||||
timeout=15,
|
||||
)
|
||||
if stores_response.status_code == 200:
|
||||
@@ -125,28 +99,143 @@ async def evotor_callback(
|
||||
store_id = items[0].get("uuid") or items[0].get("id")
|
||||
store_name = items[0].get("name")
|
||||
except Exception:
|
||||
pass # Store info is optional; token is still saved
|
||||
pass # Store info is optional
|
||||
|
||||
# Save or update connection
|
||||
from datetime import datetime, timedelta
|
||||
now = datetime.utcnow()
|
||||
token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
||||
# 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()
|
||||
|
||||
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.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("/token")
|
||||
async def evotor_token_manual(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
"""Allow user to manually paste their Evotor token."""
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
form = await request.form()
|
||||
token = (form.get("token") or "").strip()
|
||||
if not token:
|
||||
return RedirectResponse("/evotor?error=empty_token", 303)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Fetch store info
|
||||
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 {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")
|
||||
elif stores_response.status_code == 401:
|
||||
return RedirectResponse("/evotor?error=invalid_token", 303)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
if connection:
|
||||
connection.access_token = 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(
|
||||
user_id=user.id,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_expires_at=token_expires_at,
|
||||
access_token=token,
|
||||
store_id=store_id,
|
||||
store_name=store_name,
|
||||
is_online=True,
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-boundary="document" data-bs-display="dynamic" data-bs-strategy="fixed">
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger mt-4">
|
||||
{% if error == "invalid_state" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново.
|
||||
{% elif error == "token_exchange" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от Эвотор. Попробуйте позже.
|
||||
{% elif error == "no_token" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Эвотор не вернул токен доступа. Попробуйте позже.
|
||||
{% if error == "token_not_received" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен от Эвотор ещё не получен. Убедитесь, что авторизация прошла успешно, и попробуйте снова.
|
||||
{% elif error == "link_timeout" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Время ожидания истекло. Попробуйте подключить аккаунт заново.
|
||||
{% elif error == "session_expired" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Сессия устарела. Попробуйте подключить аккаунт заново.
|
||||
{% elif error == "invalid_token" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
|
||||
{% elif error == "empty_token" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
|
||||
{% else %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
||||
{% endif %}
|
||||
@@ -54,6 +58,15 @@
|
||||
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<p class="text-muted small mb-2">Обновить токен вручную (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
|
||||
<form method="post" action="/evotor/token">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
|
||||
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ── NOT CONNECTED STATE ── #}
|
||||
@@ -67,9 +80,19 @@
|
||||
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
|
||||
<li>Вы можете отключить доступ в любой момент</li>
|
||||
</ul>
|
||||
<div class="d-grid">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="/evotor/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a>
|
||||
<a href="/evotor/link" class="btn btn-outline-secondary">Уже авторизовался — подтвердить подключение</a>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
<p class="text-muted small mb-2">Если приложение уже установлено, введите токен вручную. Его можно найти в личном кабинете Эвотор: <strong>Приложения → ЭвоСинк → Настройки</strong>.</p>
|
||||
<form method="post" action="/evotor/token">
|
||||
<div class="input-group">
|
||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Введите токен Эвотор" required>
|
||||
<button type="submit" class="btn btn-outline-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user