19 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
34 changed files with 403 additions and 176 deletions

View File

@@ -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
View File

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

View File

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

View File

@@ -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:

View File

@@ -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;

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ async def lifespan(app: FastAPI):
pass
app = FastAPI(title="EvoSync — Личный кабинет", lifespan=lifespan)
app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
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"
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)

View File

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

View File

@@ -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,

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
@@ -11,7 +11,7 @@
<body>
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
<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">
<span class="navbar-toggler-icon"></span>
</button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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