22 Commits

Author SHA1 Message Date
mguschin
0926757b7a Fix dropdown clipping using fixed positioning strategy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:17:25 +03:00
mguschin
13c32e9181 Fix dropdown clipping in product table using data-bs-boundary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:16:33 +03:00
mguschin
6b9eb562ba Fix dropdown clipping in product table by allowing overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:13:07 +03:00
mguschin
9558333c94 Handle 402 Payment Required from Evotor API gracefully
Return empty list for groups/products when Evotor returns 402,
instead of crashing the refresh with an unhandled HTTP error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:04:13 +03:00
mguschin
40e7abd012 Fix typo and redirect connections/add to evotor page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:39:31 +03:00
mguschin
3d7a456299 Add manual token entry for Evotor connection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:35:17 +03:00
mguschin
5acf597944 Redirect to app page on Evotor market instead of generic market URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:33:00 +03:00
mguschin
3f4bbcbb0d Fix alter_column missing existing_type for MySQL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:26:32 +03:00
mguschin
c8beeaf1b1 Fix migration to skip already-existing evotor_user_id column/indexes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:25:36 +03:00
mguschin
5ee8419c7c Replace EVOTOR_CLIENT_ID/SECRET with EVOTOR_APP_ID/WEBHOOK_SECRET in config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:23:50 +03:00
mguschin
e376c86fbe Switch Evotor integration to webhook-based token delivery flow
Replace OAuth 2.0 authorization code flow with Evotor's proprietary
webhook token delivery: POST /evotor/callback receives token server-to-server,
GET /evotor/link links it to the logged-in user's account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:18:25 +03:00
mguschin
69e21a18c9 fix docker compose. 2026-03-09 16:47:35 +03:00
mguschin
90a2f7be1f fix docker compose. 2026-03-09 16:41:59 +03:00
mguschin
d4633a0f46 Update nginx conf. 2026-03-09 16:41:09 +03:00
mguschin
b72b0e78b0 Nginx upstream. 2026-03-09 16:23:59 +03:00
mguschin
2a04099f95 Fix tls script. 2026-03-09 16:11:03 +03:00
mguschin
58f9b74a1c Change default port. 2026-03-09 15:54:19 +03:00
mguschin
8c9c328302 chore(release): v1.8.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:41:43 +03:00
mguschin
784bb27958 chore: replace EvoSync with ЭВОСИНК throughout UI
Update all page titles and branding in FastAPI app and templates to use Russian transliteration of product name.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-06 18:40:11 +03:00
mguschin
1add9fa299 release: v1.8.1 2026-03-06 17:00:08 +03:00
mguschin
b8696793f4 docs: fix changelog order — 1.8.0 before 1.7.3 2026-03-06 16:59:32 +03:00
mguschin
37e2df1fef docs: update changelog for v1.7.3 2026-03-06 16:58:51 +03:00
35 changed files with 411 additions and 165 deletions

View File

@@ -2,6 +2,12 @@ DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
SECRET_KEY=your-random-secret-key-here SECRET_KEY=your-random-secret-key-here
BASE_URL=http://localhost:8000 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_ROOT_PASSWORD=rootpass
DB_NAME=evosync DB_NAME=evosync
DB_USER=evosync DB_USER=evosync

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ passwords.txt
.env .env
__pycache__/ __pycache__/
*.pyc *.pyc
certbot

View File

@@ -5,28 +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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.8.2] - 2026-03-06
### Miscellaneous
- Replace EvoSync with ЭВОСИНК throughout UI
## [1.8.1] - 2026-03-06
### 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
- Add nginx reverse proxy and Let's Encrypt TLS setup
### Other
- V1.7.3
## [1.8.0] - 2026-03-06 ## [1.8.0] - 2026-03-06
### Added ### Added
- Connections dashboard (`/connections`) — unified page for all service integrations with online/offline status indicators - Add Evotor OAuth connection feature with formatted phone input
- Add-connection page (`/connections/add`) — shows only unconnected services; "all connected" state when none remain - Add Alembic database migrations
- VK OAuth connection — connect VK account via OAuth, store access token and profile info - Add connections dashboard with background health checks
- Background health checker — runs every 10 minutes, checks Evotor and VK tokens, updates `is_online` / `last_checked_at` - Add VK OAuth connection with health checks
- Sync configuration page (`/sync`) — master enable/disable toggle, confirm-and-start button, filter summary, warnings for missing connections - Release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser
- 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
### Changed ### Miscellaneous
- Navbar: replaced "Эвотор" link with "Подключения", added "Каталог" and "Синхронизация" links - Add semantic versioning and automatic changelog generation
- 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
## [1.7.2] - 2026-03-05 ## [1.7.2] - 2026-03-05

View File

@@ -5,14 +5,14 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.web dockerfile: Dockerfile.web
expose: ports:
- "8000" - "8080:8000"
environment: 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} - SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- BASE_URL=${BASE_URL:-https://evosync.ru} - BASE_URL=${BASE_URL:-https://evosync.ru}
- EVOTOR_CLIENT_ID=${EVOTOR_CLIENT_ID} - EVOTOR_APP_ID=${EVOTOR_APP_ID}
- EVOTOR_CLIENT_SECRET=${EVOTOR_CLIENT_SECRET} - EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
- VK_CLIENT_ID=${VK_CLIENT_ID} - VK_CLIENT_ID=${VK_CLIENT_ID}
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET} - VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
volumes: volumes:
@@ -20,6 +20,8 @@ services:
- ./alembic.ini:/app/alembic.ini - ./alembic.ini:/app/alembic.ini
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh - ./docker-entrypoint.sh:/app/docker-entrypoint.sh
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
# sync: # sync:
# build: # build:

View File

@@ -1,3 +1,7 @@
upstream web {
server 127.0.0.1:8080;
}
server { server {
listen 80; listen 80;
server_name evosync.ru www.evosync.ru; server_name evosync.ru www.evosync.ru;
@@ -23,7 +27,7 @@ server {
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;
location / { location / {
proxy_pass http://web:8000; proxy_pass http://web;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View 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"

View File

@@ -1,57 +1,46 @@
#!/bin/bash #!/bin/bash
# Obtain TLS certificates from Let's Encrypt for evosync.ru # Obtain TLS certificates from Let's Encrypt for evosync.ru
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh # Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
# Requires nginx running on the host with acme-challenge location configured
set -euo pipefail set -euo pipefail
DOMAIN="evosync.ru" DOMAIN="evosync.ru"
EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}" EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}"
COMPOSE="docker compose"
CERTBOT_DIR="./certbot" CERTBOT_DIR="./certbot"
ACME_DIR="/var/www/certbot"
echo "==> Creating certbot directories..." echo "==> Creating certbot directories..."
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www" mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
echo "==> Starting nginx (HTTP only, for ACME challenge)..." echo "==> Ensuring acme-challenge directory exists on host..."
# Temporarily use a basic config that doesn't require certs sudo mkdir -p "$ACME_DIR"
cat > nginx/nginx-temp.conf <<'TMPCONF' sudo chmod 755 "$ACME_DIR"
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 "==> Requesting certificate from Let's Encrypt..." echo "==> Requesting certificate from Let's Encrypt..."
docker run --rm \ sudo certbot certonly \
-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 \
--webroot \ --webroot \
--webroot-path=/var/www/certbot \ --webroot-path="$ACME_DIR" \
--email "$EMAIL" \ --email "$EMAIL" \
--agree-tos \ --agree-tos \
--no-eff-email \ --no-eff-email \
-d "$DOMAIN" \ -d "$DOMAIN" \
-d "www.$DOMAIN" -d "www.$DOMAIN"
echo "==> Restoring production nginx config..." echo "==> Copying certificates to project directory..."
rm -f nginx/nginx-temp.conf 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"
echo "==> Restarting nginx with TLS..." sudo chown "$(whoami):$(whoami)" "$CERTBOT_DIR/conf"/*.pem
$COMPOSE restart nginx
echo "==> Done! TLS certificate installed for $DOMAIN" echo "==> Done! TLS certificate installed for $DOMAIN"
echo " Set up auto-renewal with: sudo crontab -e" echo ""
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 "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"

View File

@@ -1 +1 @@
1.7.3 1.8.1

View File

@@ -4,12 +4,11 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync" DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
SECRET_KEY: str = "change-me-in-production" 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 PASSWORD_RESET_EXPIRE_MINUTES: int = 60
EVOTOR_CLIENT_ID: str = "" EVOTOR_APP_ID: str = ""
EVOTOR_CLIENT_SECRET: str = "" EVOTOR_WEBHOOK_SECRET: str = ""
EVOTOR_SCOPES: str = "store:read product:read"
HEALTH_CHECK_INTERVAL_SECONDS: int = 600 HEALTH_CHECK_INTERVAL_SECONDS: int = 600

View File

@@ -33,6 +33,8 @@ async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {access_token}"},
timeout=15, timeout=15,
) )
if resp.status_code == 402:
return []
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data 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}"}, headers={"Authorization": f"Bearer {access_token}"},
timeout=15, timeout=15,
) )
if resp.status_code == 402:
return []
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data items = data.get("items", data) if isinstance(data, dict) else data

View File

@@ -25,7 +25,7 @@ async def lifespan(app: FastAPI):
pass pass
app = FastAPI(title="EvoSync — Личный кабинет", lifespan=lifespan) app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
app.mount("/static", StaticFiles(directory="web/static"), name="static") app.mount("/static", StaticFiles(directory="web/static"), name="static")

View File

@@ -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')

View File

@@ -33,7 +33,8 @@ class EvotorConnection(Base):
__tablename__ = "evotor_connections" __tablename__ = "evotor_connections"
id = Column(Integer, primary_key=True, autoincrement=True) 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) access_token = Column(Text, nullable=False)
store_id = Column(String(255), nullable=True) store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True) store_name = Column(String(255), nullable=True)

View File

@@ -17,7 +17,7 @@ SERVICE_TYPES = [
"icon": "bi-shop", "icon": "bi-shop",
"description": "Подключите кассу Эвотор для синхронизации каталога товаров.", "description": "Подключите кассу Эвотор для синхронизации каталога товаров.",
"configure_url": "/evotor", "configure_url": "/evotor",
"connect_url": "/evotor/connect", "connect_url": "/evotor",
}, },
{ {
"type": "vk", "type": "vk",

View File

@@ -1,12 +1,11 @@
import secrets
import logging import logging
import httpx import httpx
from fastapi import APIRouter, Request, Depends from datetime import datetime, timedelta
from fastapi import APIRouter, Request, Depends, HTTPException
logger = logging.getLogger(__name__) from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import get_current_user from web.auth import get_current_user
@@ -14,16 +13,16 @@ from web.config import settings
from web.database import get_db from web.database import get_db
from web.models import User, EvotorConnection from web.models import User, EvotorConnection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/evotor") router = APIRouter(prefix="/evotor")
templates = Jinja2Templates(directory="web/templates") templates = Jinja2Templates(directory="web/templates")
EVOTOR_AUTHORIZE_URL = "https://oauth.evotor.ru/oauth/authorize" EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
EVOTOR_STORES_URL = "https://api.evotor.ru/stores" EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
# Pending connections older than this are ignored during linking
def _redirect_uri() -> str: PENDING_LINK_WINDOW_SECONDS = 300
return f"{settings.BASE_URL}/evotor/callback"
@router.get("") @router.get("")
@@ -50,72 +49,47 @@ def evotor_connect(request: Request, user: User | None = Depends(get_current_use
if not user: if not user:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
state = secrets.token_urlsafe(32) # Record when this user initiated a connect so we can link the incoming webhook
request.session["evotor_oauth_state"] = state request.session["evotor_connect_user_id"] = user.id
request.session["evotor_connect_at"] = datetime.utcnow().isoformat()
params = ( url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID)
f"?client_id={settings.EVOTOR_CLIENT_ID}" return RedirectResponse(url, 302)
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)
@router.get("/callback") class EvotorTokenPayload(BaseModel):
userId: str
token: str
@router.post("/callback")
async def evotor_callback( async def evotor_callback(
request: Request, request: Request,
payload: EvotorTokenPayload,
db: Session = Depends(get_db), 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") now = datetime.utcnow()
state = request.query_params.get("state")
saved_state = request.session.pop("evotor_oauth_state", None)
if not code or not state or state != saved_state: # Fetch store info using the received token
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
store_id = None store_id = None
store_name = None store_name = None
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
stores_response = await client.get( stores_response = await client.get(
EVOTOR_STORES_URL, EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {payload.token}"},
timeout=15, timeout=15,
) )
if stores_response.status_code == 200: 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_id = items[0].get("uuid") or items[0].get("id")
store_name = items[0].get("name") store_name = items[0].get("name")
except Exception: except Exception:
pass # Store info is optional; token is still saved pass # Store info is optional
# Save or update connection # Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called)
from datetime import datetime, timedelta connection = db.query(EvotorConnection).filter(
now = datetime.utcnow() EvotorConnection.evotor_user_id == payload.userId
token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None ).first()
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection: if connection:
connection.access_token = access_token connection.access_token = payload.token
connection.refresh_token = refresh_token
connection.token_expires_at = token_expires_at
connection.store_id = store_id connection.store_id = store_id
connection.store_name = store_name connection.store_name = store_name
connection.is_online = True connection.is_online = True
connection.last_checked_at = now 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: else:
connection = EvotorConnection( connection = EvotorConnection(
user_id=user.id, user_id=user.id,
access_token=access_token, access_token=token,
refresh_token=refresh_token,
token_expires_at=token_expires_at,
store_id=store_id, store_id=store_id,
store_name=store_name, store_name=store_name,
is_online=True, is_online=True,

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EvoSync{% endblock %}</title> <title>{% block title %}ЭВОСИНК{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -11,7 +11,7 @@
<body> <body>
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border"> <nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
<div class="container"> <div class="container">
<a href="/" class="navbar-brand brand-logo">EvoSync</a> <a href="/" class="navbar-brand brand-logo">ЭВОСИНК</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Группы — {{ store.name }} — EvoSync{% endblock %} {% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<nav aria-label="breadcrumb" class="mb-3"> <nav aria-label="breadcrumb" class="mb-3">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Товары — EvoSync{% endblock %} {% block title %}Товары — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<nav aria-label="breadcrumb" class="mb-3"> <nav aria-label="breadcrumb" class="mb-3">
@@ -73,7 +73,7 @@
</td> </td>
<td> <td>
<div class="dropdown"> <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> <i class="bi bi-funnel"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Каталог — EvoSync{% endblock %} {% block title %}Каталог — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Подтверждение email — EvoSync{% endblock %} {% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Подключения — EvoSync{% endblock %} {% block title %}Подключения — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="d-flex align-items-center justify-content-between mb-4"> <div class="d-flex align-items-center justify-content-between mb-4">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Добавить подключение — EvoSync{% endblock %} {% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="d-flex align-items-center mb-4"> <div class="d-flex align-items-center mb-4">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Email подтвержден — EvoSync{% endblock %} {% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Подключение Эвотор — EvoSync{% endblock %} {% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
@@ -7,12 +7,16 @@
{% if error %} {% if error %}
<div class="alert alert-danger mt-4"> <div class="alert alert-danger mt-4">
{% if error == "invalid_state" %} {% if error == "token_not_received" %}
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново. <i class="bi bi-exclamation-triangle me-2"></i>Токен от Эвотор ещё не получен. Убедитесь, что авторизация прошла успешно, и попробуйте снова.
{% elif error == "token_exchange" %} {% elif error == "link_timeout" %}
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от Эвотор. Попробуйте позже. <i class="bi bi-exclamation-triangle me-2"></i>Время ожидания истекло. Попробуйте подключить аккаунт заново.
{% elif error == "no_token" %} {% elif error == "session_expired" %}
<i class="bi bi-exclamation-triangle me-2"></i>Эвотор не вернул токен доступа. Попробуйте позже. <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 %} {% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }} <i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %} {% endif %}
@@ -54,6 +58,15 @@
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button> <button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
</form> </form>
</div> </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 %} {% else %}
{# ── NOT CONNECTED STATE ── #} {# ── NOT CONNECTED STATE ── #}
@@ -67,9 +80,19 @@
<li>После подтверждения доступа синхронизация будет настроена автоматически</li> <li>После подтверждения доступа синхронизация будет настроена автоматически</li>
<li>Вы можете отключить доступ в любой момент</li> <li>Вы можете отключить доступ в любой момент</li>
</ul> </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/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a>
<a href="/evotor/link" class="btn btn-outline-secondary">Уже авторизовался — подтвердить подключение</a>
</div> </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> </div>
{% endif %} {% endif %}

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Забыли пароль — EvoSync{% endblock %} {% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Вход — EvoSync{% endblock %} {% block title %}Вход — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ title }} — EvoSync{% endblock %} {% block title %}{{ title }} — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Изменить пароль — EvoSync{% endblock %} {% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Удалить аккаунт — EvoSync{% endblock %} {% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Редактировать профиль — EvoSync{% endblock %} {% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Личный кабинет — EvoSync{% endblock %} {% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Регистрация — EvoSync{% endblock %} {% block title %}Регистрация — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Новый пароль — EvoSync{% endblock %} {% block title %}Новый пароль — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Синхронизация — EvoSync{% endblock %} {% block title %}Синхронизация — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<h1 class="h4 mb-4">Синхронизация</h1> <h1 class="h4 mb-4">Синхронизация</h1>

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Подключение ВКонтакте — EvoSync{% endblock %} {% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">