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
|
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
1
.gitignore
vendored
@@ -16,3 +16,4 @@ passwords.txt
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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/),
|
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.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
|
- Replace EvoSync with ЭВОСИНК throughout UI
|
||||||
- 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
|
|
||||||
|
|
||||||
### Changed
|
## [1.8.1] - 2026-03-06
|
||||||
|
|
||||||
- Navbar: replaced "Эвотор" link with "Подключения", added "Каталог" and "Синхронизация" links
|
### Documentation
|
||||||
- Evotor and VK connect/disconnect flows now redirect to `/connections`
|
|
||||||
- Back links on `/evotor` and `/vk` pages updated to "Вернуться к подключениям"
|
- Update changelog for v1.7.3
|
||||||
- VK connection card icon changed to `bi-bag` (shopping bag) to reflect VK Market use case
|
- Fix changelog order — 1.8.0 before 1.7.3
|
||||||
- Password reset and email confirmation pages: replaced dev-mode console instructions with user-facing copy
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- V1.8.1
|
||||||
|
|
||||||
## [1.7.3] - 2026-03-06
|
## [1.7.3] - 2026-03-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Nginx reverse proxy configuration — SSL termination, HTTP→HTTPS redirect, proxy to uvicorn
|
- Add nginx reverse proxy and Let's Encrypt TLS setup
|
||||||
- `scripts/init-letsencrypt.sh` — automated Let's Encrypt certificate provisioning via certbot webroot challenge
|
|
||||||
- Request logging for Evotor token exchange errors to aid debugging
|
|
||||||
|
|
||||||
### Fixed
|
### Other
|
||||||
|
|
||||||
- Evotor OAuth token exchange — move `client_id` and `client_secret` from HTTP Basic Auth to form body fields (resolves `invalid_client` errors)
|
- V1.7.3
|
||||||
- Docker environment — pass `EVOTOR_CLIENT_ID` and `EVOTOR_CLIENT_SECRET` to web container via docker-compose
|
|
||||||
|
|
||||||
### Changed
|
## [1.8.0] - 2026-03-06
|
||||||
|
|
||||||
- Default `BASE_URL` changed to `https://evosync.ru` for production deployment
|
### Added
|
||||||
- `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
|
- 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
|
## [1.7.2] - 2026-03-05
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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
|
#!/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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
__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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user