From 3828b46985fac8c7f4bf51f1e2f58440c3360b32 Mon Sep 17 00:00:00 2001 From: febux Date: Fri, 14 Aug 2026 00:30:39 +0300 Subject: [PATCH] Admin panel was added. TG bot commands were extended. --- .env.example | 6 + Makefile | 18 +- README.md | 7 +- docker-compose.yml | 10 + docs/ARCHITECTURE.md | 85 +++- docs/DEPLOY.md | 8 +- migrations/versions/0004_user_roles.py | 39 ++ migrations/versions/0005_tg_user_hardening.py | 70 +++ pyproject.toml | 2 + src/contract_check/api/admin/__init__.py | 6 + src/contract_check/api/admin/auth.py | 120 +++++ src/contract_check/api/admin/router.py | 124 +++++ .../api/admin/templates/_user_card.html | 70 +++ .../api/admin/templates/base.html | 90 ++++ .../api/admin/templates/login.html | 19 + .../api/admin/templates/users_detail.html | 11 + .../api/admin/templates/users_list.html | 68 +++ .../api/admin/templates/users_new.html | 51 +++ src/contract_check/api/admin/templating.py | 28 ++ src/contract_check/api/admin/users.py | 422 ++++++++++++++++++ src/contract_check/api/app.py | 63 +++ src/contract_check/api/deps.py | 97 +++- src/contract_check/api/routes/auth.py | 31 +- src/contract_check/api/routes/me.py | 101 ++++- src/contract_check/bot/__main__.py | 11 +- src/contract_check/bot/client.py | 53 ++- src/contract_check/bot/config.py | 14 + src/contract_check/bot/handlers.py | 192 +++++++- src/contract_check/bot/rate_limit.py | 38 ++ src/contract_check/core/config.py | 18 + src/contract_check/core/db/enums.py | 6 + src/contract_check/core/db/models.py | 9 + src/contract_check/core/rate_limit.py | 9 + tests/integration/test_admin_panel.py | 189 ++++++++ tests/integration/test_b2b_api.py | 2 +- uv.lock | 22 +- 36 files changed, 2054 insertions(+), 55 deletions(-) create mode 100644 migrations/versions/0004_user_roles.py create mode 100644 migrations/versions/0005_tg_user_hardening.py create mode 100644 src/contract_check/api/admin/__init__.py create mode 100644 src/contract_check/api/admin/auth.py create mode 100644 src/contract_check/api/admin/router.py create mode 100644 src/contract_check/api/admin/templates/_user_card.html create mode 100644 src/contract_check/api/admin/templates/base.html create mode 100644 src/contract_check/api/admin/templates/login.html create mode 100644 src/contract_check/api/admin/templates/users_detail.html create mode 100644 src/contract_check/api/admin/templates/users_list.html create mode 100644 src/contract_check/api/admin/templates/users_new.html create mode 100644 src/contract_check/api/admin/templating.py create mode 100644 src/contract_check/api/admin/users.py create mode 100644 src/contract_check/bot/rate_limit.py create mode 100644 tests/integration/test_admin_panel.py diff --git a/.env.example b/.env.example index 5742eb3..68176f4 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,12 @@ JWT_REFRESH_TTL_DAYS=30 # refresh-token lifetime for webUI auth # --- WebUI auth (email + password) --- WEB_AUTH_ENABLED=true # toggle /api/v1/auth/{register,login,...} routes +WEB_ADMIN_ENABLED=true # toggle the /admin management UI (users role = 'admin') +ADMIN_REQUIRED_ROLE=admin # users.role value required to enter /admin +ADMIN_DEFAULT_EMAIL=admin@contract-check.local +# Default admin password. Set once on first deploy; the user is auto-created with role=admin. +# Leave empty to disable default admin creation (create admins manually via DB or register). +ADMIN_DEFAULT_PASSWORD=changeme-strong-password PASSWORD_RESET_TTL_MINUTES=60 PASSWORD_MIN_LENGTH=8 WEB_APP_BASE_URL=http://localhost:5173 # SPA base — used to build reset links diff --git a/Makefile b/Makefile index bab56c2..e72722b 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ infra-up infra-down infra-logs services-up services-down services-logs \ api api-logs bot bot-logs worker-extract worker-analyze worker-notify \ seed-token jwt-secret jwt-token jwt-verify health shell-api shell-bot \ - shell-db clean + shell-db admin-promote admin-list clean # ───────────────────────────────────────────────────────────────────────────── # Help @@ -103,6 +103,22 @@ migrate: ## Run Alembic migrations (inside api container) shell-db: ## Open psql inside postgres container docker compose exec postgres psql -U contract_check -d contract_check +# ───────────────────────────────────────────────────────────────────────────── +# Admin panel (/admin — manage users; needs role = 'admin') +# ───────────────────────────────────────────────────────────────────────────── +admin-promote: ## Grant admin role to a user (usage: make admin-promote EMAIL=a@b.c) + @if [ -z "$(EMAIL)" ]; then \ + echo "Usage: make admin-promote EMAIL=a@b.c"; \ + exit 1; \ + fi + @docker compose exec -T postgres psql -U contract_check -d contract_check \ + -c "UPDATE users SET role = 'admin' WHERE email = '$(EMAIL)';" \ + -c "SELECT id, email, role FROM users WHERE email = '$(EMAIL)';" + +admin-list: ## List current admin users + @docker compose exec -T postgres psql -U contract_check -d contract_check \ + -c "SELECT id, email, telegram_id, role, is_active FROM users WHERE role = 'admin';" + # ───────────────────────────────────────────────────────────────────────────── # Auth / tokens # ───────────────────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 1b59155..9c35f22 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ src/contract_check/ analysis/ extractor.py chunker.py checklist.py report_schema.py ocr.py analyzer.py api/ # FastAPI-образ app.py deps.py middleware.py services.py __main__.py + admin/ # серверный UI по /admin (users; позже — подписки) routes/ health.py documents.py reports.py me.py metrics.py b2b.py worker_extract/ # CPU-образ (pymupdf + tesseract) consumer.py handler.py extract_document.py __main__.py @@ -120,12 +121,13 @@ uv run python -m contract_check prototype contract.pdf --json metrics.json # + ## API (кратко) -Все роуты под `/api/v1`. Auth зависит от роута: +JSON-роуты под `/api/v1`; серверный admin UI — по `/admin/*` (FastAPI + Jinja2 + HTMX). Auth зависит от роута: - **Пользовательские роуты** (bot / web / Mini App) — `Authorization: Bearer `. JWT выдаётся через `/api/v1/auth/telegram/*` после проверки identity от Telegram. - **Адаптер-level** (только `/api/v1/auth/telegram/bot`) — `Authorization: Bearer `. - **B2B** — `X-API-Key`. Управление B2B-ключами требует пользовательский JWT. - **Health/metrics** — без auth. +- **/admin** — HttpOnly cookie с user JWT + `users.role = admin` (или `ADMIN_REQUIRED_ROLE`). | Метод | Путь | Auth | Назначение | |---|---|---|---| @@ -133,6 +135,8 @@ uv run python -m contract_check prototype contract.pdf --json metrics.json # + | POST | `/api/v1/auth/telegram/bot` | service token | бот меняет verified `telegram_id` на JWT | | POST | `/api/v1/auth/telegram/web` | — | Telegram Login Widget → JWT | | POST | `/api/v1/auth/telegram/miniapp` | — | Mini App `initData` → JWT | +| POST | `/api/v1/auth/register` | — | регистрация email/password | +| POST | `/api/v1/auth/login` | — | вход email/password → JWT-пара | | GET | `/api/v1/auth/me` | user JWT | introspect JWT | | POST | `/api/v1/documents` | user JWT | multipart upload → reserve credit → MinIO → publish → `202 {document_id, correlation_id}` | | GET | `/api/v1/documents/{id}` | user JWT | статус + stage (для поллинга) | @@ -141,6 +145,7 @@ uv run python -m contract_check prototype contract.pdf --json metrics.json # + | POST | `/api/v1/analyze` | `X-API-Key` | B2B: анализ документа | | GET | `/api/v1/b2b/reports/{id}`, `/api/v1/b2b/usage` | `X-API-Key` | B2B: отчёт / usage | | POST/GET | `/api/v1/b2b/keys`, `/api/v1/b2b/keys/{id}/revoke`, `.../usage` | user JWT | управление B2B-ключами | +| GET/POST | `/admin/users*` | admin cookie | управление пользователями (list, create, edit, ban, role, credits) | Полная спецификация — `docs/ARCHITECTURE.md §15` (актуализируется). diff --git a/docker-compose.yml b/docker-compose.yml index 609d299..e6cfa49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -172,6 +172,10 @@ services: JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440} JWT_REFRESH_TTL_DAYS: ${JWT_REFRESH_TTL_DAYS:-30} WEB_AUTH_ENABLED: ${WEB_AUTH_ENABLED:-true} + WEB_ADMIN_ENABLED: ${WEB_ADMIN_ENABLED:-true} + ADMIN_REQUIRED_ROLE: ${ADMIN_REQUIRED_ROLE:-admin} + ADMIN_DEFAULT_EMAIL: ${ADMIN_DEFAULT_EMAIL:-admin@contract-check.local} + ADMIN_DEFAULT_PASSWORD: ${ADMIN_DEFAULT_PASSWORD:-} PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-60} PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH:-8} WEB_APP_BASE_URL: ${WEB_APP_BASE_URL:-http://localhost:5173} @@ -299,6 +303,9 @@ services: SENTRY_DSN: ${SENTRY_DSN:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} OTEL_SERVICE_NAME: worker-notify + S3_ENDPOINT_URL: http://minio:9000 + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-contract_check} + S3_SECRET_KEY: ${S3_SECRET_KEY:-contract_check} MQ_PREFETCH_NOTIFY: ${MQ_PREFETCH_NOTIFY:-5} MQ_MAX_ATTEMPTS: ${MQ_MAX_ATTEMPTS:-5} MQ_RETRY_BASE_MS: ${MQ_RETRY_BASE_MS:-2000} @@ -309,6 +316,9 @@ services: SMTP_FROM: ${SMTP_FROM:-no-reply@contract-check.local} SMTP_USE_TLS: ${SMTP_USE_TLS:-true} WEB_APP_BASE_URL: ${WEB_APP_BASE_URL:-http://localhost:5173} + JWT_SECRET: ${JWT_SECRET} + JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} + JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440} ports: - "9103:9103" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 20bb57a..e86ec43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -423,11 +423,17 @@ before api/worker start. Alternatively `core/s3/minio_storage.py` does --- -## 7. Postgres schema (initial Alembic migration) +## 7. Postgres schema (Alembic migrations) -Six tables. `status`/`queue`/`adapter` columns are `TEXT + CHECK` (not -Postgres enums) so migrations are additive — matches the existing convention -noted in `IMPLEMENTATION_PLAN.md` §1.2. +Six core tables plus additive migrations. `status`/`queue`/`adapter`/`role` +columns are `TEXT + CHECK` (not Postgres enums) so migrations are additive — +matches the convention noted in `IMPLEMENTATION_PLAN.md` §1.2. + +Migrations (hand-written, async `env.py`): +- `0001` initial schema: users/documents/reports/jobs/service_tokens/invoices +- `0002` `api_keys` + `api_key_requests` (B2B) +- `0003` webUI auth columns on `users` (`email`, `password_hash`, reset tokens, `is_active`) +- `0004` `users.role` for the admin panel (`'user'|'admin'`, default `'user'`) ```sql -- users @@ -436,8 +442,17 @@ CREATE TABLE users ( telegram_id BIGINT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), credits_left INTEGER NOT NULL DEFAULT 0, - CONSTRAINT users_credits_nonneg CHECK (credits_left >= 0) + email TEXT UNIQUE, -- added by 0003 + password_hash TEXT, -- added by 0003 + password_reset_token_hash TEXT, -- added by 0003 + password_reset_expires_at TIMESTAMPTZ, -- added by 0003 + is_active BOOLEAN NOT NULL DEFAULT TRUE, -- added by 0003 + role TEXT NOT NULL DEFAULT 'user' -- added by 0004 + CHECK (role IN ('user','admin')), + CONSTRAINT users_credits_nonneg CHECK (credits_left >= 0), + CONSTRAINT users_identity_present CHECK (telegram_id IS NOT NULL OR email IS NOT NULL) ); +CREATE INDEX users_role_idx ON users (role); -- added by 0004 -- documents CREATE TABLE documents ( @@ -725,7 +740,7 @@ None of 1–5 requires touching `core/` application code — only compose/infra. |---|---|---| | `ENV` | `dev` | dev/staging/prod — toggles TLS, sentry sample rate | | `LOG_LEVEL` | `INFO` | structlog level | -| `LOG_FORMAT` | `json` | `json` (prod/staging) \| `console` (dev) — explicit override of env-based default | +| `LOG_FORMAT` | `json` | `json` (prod/staging) or `console` (dev) — explicit override of env-based default | | `APP_VERSION` | `unknown` | added to every log line; set at build/deploy time | | `DATABASE_URL` | (required) | `postgresql+asyncpg://...` | | `REDIS_URL` | `redis://redis:6379/0` | rate limit / sessions (future) | @@ -763,15 +778,15 @@ None of 1–5 requires touching `core/` application code — only compose/infra. ### API -| Env | Default | +| Env | Default | Notes | |---|---|---| -| `API_HOST` | `0.0.0.0` | -| `API_PORT` | `8000` | -| `API_METRICS_PORT` | `9100` | -| `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` (per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps`) | -| `CORS_ORIGINS` | (empty, future web) | +| `API_HOST` | `0.0.0.0` | | +| `API_PORT` | `8000` | | +| `API_METRICS_PORT` | `9100` | | +| `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` | per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps` | +| `CORS_ORIGINS` | (empty, future web) | | -### Auth (JWT + Telegram identity verification) +### Auth (JWT + Telegram identity verification + webUI + admin panel) | Env | Default | Notes | |---|---|---| @@ -779,11 +794,18 @@ None of 1–5 requires touching `core/` application code — only compose/infra. | `JWT_SECRET` | (required) | HS256 secret for signing user JWTs; generate with `openssl rand -hex 32` | | `JWT_ALGORITHM` | `HS256` | | | `JWT_ACCESS_TTL_MINUTES` | `1440` (24h) | access-token lifetime; tune per env | +| `JWT_REFRESH_TTL_DAYS` | `30` | refresh-token lifetime for webUI sessions | +| `WEB_AUTH_ENABLED` | `true` | toggle `/api/v1/auth/{register,login,logout,forgot-password,reset-password}` | +| `WEB_APP_BASE_URL` | `http://localhost:5173` | base URL of the future web SPA; used for password-reset links | +| `PASSWORD_RESET_TTL_MINUTES` | `60` | reset-link validity | +| `PASSWORD_MIN_LENGTH` | `8` | enforced at register, reset, and in the `/admin` create form | +| `WEB_ADMIN_ENABLED` | `true` | toggle the `/admin/*` server-rendered management UI | +| `ADMIN_REQUIRED_ROLE` | `admin` | `users.role` value required to enter `/admin` (must match the DB CHECK constraint) | ### Bot | Env | Default | -|---|---|---| +|---|---| | `BOT_TOKEN` | (required) | | `API_URL` | `http://api:8000` | | `BOT_SERVICE_TOKEN` | (required — bearer for adapter auth to `/api/v1/auth/telegram/bot`, looked up against `service_tokens`) | @@ -1023,7 +1045,11 @@ pytest -m integration -q # integration, CI only ## 15. API surface (FastAPI) -All under `/api/v1` (mounted from day one). Three auth modes: +JSON API routes live under `/api/v1`. The server-rendered **admin panel** +is mounted at `/admin/*` and is the only HTML surface in the api; it reuses +the user JWT for authentication (stored in an HttpOnly cookie). + +Three JSON auth modes: - **User JWT** (`Authorization: Bearer `) — common token for bot users, Telegram Login Widget users, and Telegram Mini App users. Issued by @@ -1046,6 +1072,11 @@ Health/metrics exempt from auth. | POST | `/api/v1/auth/telegram/bot` | service token | bot exchanges verified `telegram_id` for a user JWT | | POST | `/api/v1/auth/telegram/web` | — | verify Telegram Login Widget payload → issue user JWT | | POST | `/api/v1/auth/telegram/miniapp` | — | verify Mini App `initData` HMAC → issue user JWT | +| POST | `/api/v1/auth/register` | — | email/password → new user + JWT pair (requires `WEB_AUTH_ENABLED`) | +| POST | `/api/v1/auth/login` | — | email/password → JWT pair (requires `WEB_AUTH_ENABLED`) | +| POST | `/api/v1/auth/logout` | refresh token | revoke refresh (requires `WEB_AUTH_ENABLED`) | +| POST | `/api/v1/auth/forgot-password` | — | enqueue reset email (requires `WEB_AUTH_ENABLED`) | +| POST | `/api/v1/auth/reset-password` | reset token | rotate password (requires `WEB_AUTH_ENABLED`) | | GET | `/api/v1/auth/me` | user JWT | introspect JWT claims | ### User endpoints (user JWT) @@ -1072,6 +1103,30 @@ Health/metrics exempt from auth. | POST | `/api/v1/b2b/keys/{id}/revoke` | user JWT | revoke (instant auth disable) | | GET | `/api/v1/b2b/keys/{id}/usage` | user JWT | per-month request counts | +### Admin panel (`api/admin/`) + +Mounted at `/admin/*`, gated by `WEB_ADMIN_ENABLED` and by the +`ADMIN_REQUIRED_ROLE` check on `users.role`. Stack: FastAPI + Jinja2 + HTMX +(client-side via CDN). Reuses the same access JWT, but stored in an +HttpOnly cookie (`cc_admin_token`) so browsers can navigate it. + +| Method | Path | Behavior | +|---|---|---| +| GET/POST | `/admin/login` | email/password → cookie; non-`admin` roles rejected | +| POST | `/admin/logout` | clear cookie | +| GET | `/admin` / `/admin/` | redirect to `/admin/users` if logged in, else `/admin/login` | +| GET | `/admin/users` | paginated, searchable user list | +| GET | `/admin/users/new` | create-user form | +| POST | `/admin/users` | create a user (email, password, role, credits, optional telegram_id) | +| GET | `/admin/users/{user_id}` | user detail + document stats | +| POST | `/admin/users/{user_id}` | update role / active flag / credits | +| POST | `/admin/users/{user_id}/toggle-active` | ban/unban (HTMX partial swap) | +| GET | `/admin/users/{user_id}/credits` | bump credits by `delta` (HTMX partial swap) | + +To grant admin access: set `users.role = 'admin'` (or the value of +`ADMIN_REQUIRED_ROLE`) for the target account. The Makefile has helpers: +`make admin-promote EMAIL=...` and `make admin-list`. + No synchronous `/analyze` (locked). Adapters poll `/reports/{id}`; the fine-grained `stage` field powers a progress signal in the bot ("Extracting text…", "Analyzing…"). SSE/webhook added later. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 7cf873b..818daba 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -105,7 +105,13 @@ TELEGRAM_BOT_TOKEN=$BOT_TOKEN # тот же токен; API испо BOT_SERVICE_TOKEN=bot-prod-secret-xxx # см. §5.3 JWT_SECRET=$(openssl rand -hex 32) # HS256 secret для подписи JWT -# --- Postgres (можно оставить defaults для dev) --- +# --- WebUI auth + admin panel (опционально, defaults ниже) --- +WEB_AUTH_ENABLED=true # /api/v1/auth/register,login,... +WEB_APP_BASE_URL=http://localhost:5173 # SPA — ссылки для сброса пароля +WEB_ADMIN_ENABLED=true # панель управления по /admin +ADMIN_REQUIRED_ROLE=admin # users.role, которое допущено в /admin +PASSWORD_MIN_LENGTH=8 + POSTGRES_USER=contract_check POSTGRES_PASSWORD=changeme-strong-password POSTGRES_DB=contract_check diff --git a/migrations/versions/0004_user_roles.py b/migrations/versions/0004_user_roles.py new file mode 100644 index 0000000..01fa61f --- /dev/null +++ b/migrations/versions/0004_user_roles.py @@ -0,0 +1,39 @@ +"""role column on users for the /admin panel (Stage 5). + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-08-13 + +Adds a non-null `role` column (default 'user') constrained to ('user','admin'). +The admin panel gate (settings.admin_required_role) matches this value. Existing +rows back-fill to 'user' via the server_default; admin accounts are promoted +manually (`UPDATE users SET role='admin' WHERE email=...`). +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0004" +down_revision: str | None = "0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("role", sa.String(), nullable=False, server_default=sa.text("'user'")), + ) + op.create_check_constraint("users_role_check", "users", "role IN ('user','admin')") + op.create_index("users_role_idx", "users", ["role"]) + + +def downgrade() -> None: + op.drop_index("users_role_idx", table_name="users") + op.drop_constraint("users_role_check", "users", type_="check") + op.drop_column("users", "role") diff --git a/migrations/versions/0005_tg_user_hardening.py b/migrations/versions/0005_tg_user_hardening.py new file mode 100644 index 0000000..33df05f --- /dev/null +++ b/migrations/versions/0005_tg_user_hardening.py @@ -0,0 +1,70 @@ +"""Telegram bot user hardening: rate-limit, profile guards, binding audit. + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-08-13 + +Adds optional audit columns to users for Telegram-bound accounts and a +verification flag so the admin panel can review anonymous/suspicious accounts. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0005" +down_revision: str | None = "0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Telegram profile snapshot captured on first /start (for admin review). + op.add_column( + "users", + sa.Column( + "telegram_profile_json", + sa.Text(), + nullable=True, + comment="Snapshot of Telegram first_name/last_name/username/language_code", + ), + ) + + # True once the account is considered trusted. New auto-created Telegram + # users may start as not_verified if their profile is empty. + op.add_column( + "users", + sa.Column( + "telegram_verified", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + comment="Telegram user passed profile guard; false = admin review", + ), + ) + + # Audit timestamp for when a Telegram identity was first bound. + op.add_column( + "users", + sa.Column( + "telegram_bound_at", + sa.DateTime(timezone=True), + nullable=True, + comment="When telegram_id was first set (auto-created or bound)", + ), + ) + + op.create_index("users_telegram_verified_idx", "users", ["telegram_verified"]) + op.create_index("users_telegram_bound_idx", "users", ["telegram_bound_at"]) + + +def downgrade() -> None: + op.drop_index("users_telegram_bound_idx", table_name="users") + op.drop_index("users_telegram_verified_idx", table_name="users") + op.drop_column("users", "telegram_bound_at") + op.drop_column("users", "telegram_verified") + op.drop_column("users", "telegram_profile_json") diff --git a/pyproject.toml b/pyproject.toml index 87a23e7..8f9421a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ api = [ { include-group = "obs" }, "fastapi>=0.110", "uvicorn[standard]>=0.29", + "jinja2>=3.1", "python-multipart>=0.0.9", "redis>=5.0", "pyjwt[crypto]>=2.8", @@ -94,6 +95,7 @@ analyze = [ ] bot = [ "aiogram>=3.4", + "redis>=5.0", ] notify = [ { include-group = "db" }, diff --git a/src/contract_check/api/admin/__init__.py b/src/contract_check/api/admin/__init__.py new file mode 100644 index 0000000..86d7974 --- /dev/null +++ b/src/contract_check/api/admin/__init__.py @@ -0,0 +1,6 @@ +"""Server-rendered admin panel (FastAPI + Jinja2 + HTMX), mounted at /admin. + +Reuses the api's auth (JWT + users.role) and DB session. The panel is a set of +HTML routes for managing users (and, later, subscriptions). It is gated behind +``WEB_ADMIN_ENABLED`` and the ``users.role = 'admin'`` check in ``auth.py``. +""" diff --git a/src/contract_check/api/admin/auth.py b/src/contract_check/api/admin/auth.py new file mode 100644 index 0000000..782803d --- /dev/null +++ b/src/contract_check/api/admin/auth.py @@ -0,0 +1,120 @@ +"""Admin authentication: cookie/JWT session + role gate. + +The panel authenticates operators by email+password (the existing web-auth +flow) and stores the resulting access JWT in an HttpOnly cookie so the browser +can drive HTMX navigation. The same JWT is also accepted via the +``Authorization: Bearer`` header, so the panel can be scripted if needed. + +Access requires ``users.role = 'admin'`` and ``is_active = true``. When the +guard cannot establish an admin session it raises :class:`AdminAuthError`, +which app.py maps to a redirect to ``/admin/login`` (bypassing the generic +500 handler, which would otherwise swallow it). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.auth import AuthError, verify_access_token +from ...core.config import get_settings +from ..deps import AsyncSessionDep + +# HttpOnly cookie carrying the admin access JWT. +ADMIN_COOKIE = "cc_admin_token" +# One day — matches the default JWT_ACCESS_TTL_MINUTES (1440). +ADMIN_COOKIE_MAX_AGE = 24 * 3600 + + +class AdminAuthError(Exception): + """Raised to short-circuit a request into a redirect to ``/admin/login``.""" + + def __init__(self, reason: str = "auth") -> None: + self.reason = reason + super().__init__(reason) + + +@dataclass(slots=True) +class AdminUser: + """The authenticated operator driving the admin panel.""" + + user_id: UUID + telegram_id: int + email: str | None + role: str + + +def _token_from_request(request: Request) -> str | None: + cookie = request.cookies.get(ADMIN_COOKIE) + if cookie: + return cookie + header = request.headers.get("authorization") + if header and header.lower().startswith("bearer "): + return header[7:].strip() + return None + + +async def resolve_admin(request: Request, session: AsyncSession) -> AdminUser | None: + """Return the admin identity if the request carries a valid admin session. + + Returns ``None`` for missing/expired tokens, unknown users, inactive + accounts, or non-admin roles — the caller decides how to react. + """ + if not get_settings().web_admin_enabled: + return None + + token = _token_from_request(request) + if not token: + return None + + try: + claims = verify_access_token(token) + except AuthError: + return None + + result = await session.execute( + text("SELECT id, telegram_id, email, role, is_active FROM users WHERE id = :u"), + {"u": claims.sub}, + ) + row = result.first() + if row is None: + return None + + user_id, telegram_id, email, role, is_active = row + if not is_active or role != get_settings().admin_required_role: + return None + return AdminUser( + user_id=user_id, + telegram_id=int(telegram_id or 0), + email=email, + role=role, + ) + + +async def require_admin(request: Request, session: AsyncSessionDep) -> AdminUser: + """Dependency: ensure an admin session exists, else redirect to login.""" + admin = await resolve_admin(request, session) + if admin is None: + raise AdminAuthError() + return admin + + +AdminUserDep = AdminUser + + +def require_htmx(request: Request) -> None: + """Dependency: only accept mutating requests coming from HTMX. + + HTMX sends ``HX-Request: true`` on every request; a cross-site HTML form + cannot set a custom header without triggering a CORS preflight, so this is + an effective CSRF defence for cookie-authed, htmx-driven forms. + """ + if request.headers.get("hx-request") != "true": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="htmx request required") + + +HtmxGuard = Depends(require_htmx) diff --git a/src/contract_check/api/admin/router.py b/src/contract_check/api/admin/router.py new file mode 100644 index 0000000..e0fd513 --- /dev/null +++ b/src/contract_check/api/admin/router.py @@ -0,0 +1,124 @@ +"""Parent admin router: index redirect + login/logout. + +Authentication reuses the webUI email/password flow (argon2 verify) and the +existing JWT issuer, then sets an HttpOnly cookie. Only ``role = 'admin'`` +accounts may log in here. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Form, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import text + +from ...core.auth import create_access_token +from ...core.config import get_settings +from ...core.logging import get_logger +from ...core.security.passwords import verify_password +from ..deps import AsyncSessionDep +from .auth import ADMIN_COOKIE, ADMIN_COOKIE_MAX_AGE, resolve_admin +from .templating import templates +from .users import router as users_router + +log = get_logger(__name__) + +router = APIRouter(tags=["admin"]) +router.include_router(users_router) + + +@router.get("/admin", include_in_schema=False) +@router.get("/admin/", include_in_schema=False) +async def index( + request: Request, + session: AsyncSessionDep, +) -> RedirectResponse: + """Authenticated entry point → users list; otherwise → login.""" + admin = await resolve_admin(request, session) + target = "/admin/users" if admin is not None else "/admin/login" + return RedirectResponse(url=target, status_code=status.HTTP_303_SEE_OTHER) + + +@router.get( + "/admin/login", + response_class=HTMLResponse, + response_model=None, + include_in_schema=False, +) +async def login_form( + request: Request, + session: AsyncSessionDep, + reason: str = "", +) -> HTMLResponse | RedirectResponse: + # Already logged in as admin → skip the form. + if await resolve_admin(request, session) is not None: + return RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER) + messages = { + "invalid": "Неверный email или пароль.", + "forbidden": "Учётная запись не имеет прав администратора.", + "disabled": "Учётная запись отключена.", + "auth": "Сессия истекла — войдите снова.", + } + return templates.TemplateResponse( + request, + "login.html", + {"request": request, "error": messages.get(reason, "")}, + ) + + +@router.post("/admin/login", include_in_schema=False) +async def login( + session: AsyncSessionDep, + email: Annotated[str, Form()], + password: Annotated[str, Form()], +) -> RedirectResponse: + """Verify email/password + admin role, set the cookie, redirect to users.""" + email_norm = email.lower().strip() + result = await session.execute( + text( + "SELECT id, telegram_id, email, password_hash, role, is_active " + "FROM users WHERE email = :e" + ), + {"e": email_norm}, + ) + row = result.first() + invalid = RedirectResponse( + url="/admin/login?reason=invalid", status_code=status.HTTP_303_SEE_OTHER + ) + if row is None: + return invalid + + user_id, telegram_id, _email, password_hash, role, is_active = row + if not password_hash or not verify_password(password, password_hash): + return invalid + if not is_active: + return RedirectResponse( + url="/admin/login?reason=disabled", status_code=status.HTTP_303_SEE_OTHER + ) + if role != get_settings().admin_required_role: + log.warning("admin_login_forbidden", user_id=str(user_id), role=role) + return RedirectResponse( + url="/admin/login?reason=forbidden", status_code=status.HTTP_303_SEE_OTHER + ) + + token = create_access_token(user_id, int(telegram_id or 0)) + response = RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER) + response.set_cookie( + key=ADMIN_COOKIE, + value=token, + max_age=ADMIN_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=get_settings().env == "prod", + ) + log.info("admin_logged_in", user_id=str(user_id)) + return response + + +@router.post("/admin/logout", include_in_schema=False) +async def logout() -> RedirectResponse: + """Clear the admin cookie and return to the login page.""" + response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER) + response.delete_cookie(ADMIN_COOKIE) + return response diff --git a/src/contract_check/api/admin/templates/_user_card.html b/src/contract_check/api/admin/templates/_user_card.html new file mode 100644 index 0000000..23ad55e --- /dev/null +++ b/src/contract_check/api/admin/templates/_user_card.html @@ -0,0 +1,70 @@ +{# Partial swapped by HTMX after updates. Depends on `user`, `stats`, `roles`. #} +
+
+

+ {{ user.email or 'Без email' }} + {% if user.telegram_id %}· tg {{ user.telegram_id }}{% endif %} +

+

ID: {{ user.id }}

+

+ {% if user.telegram_verified %} + TG проверен + {% else %} + TG на проверке + {% endif %} +

+ +
+
{{ user.credits_left }}
Кредиты
+
{{ stats.docs_total }}
Документов
+
{{ stats.docs_done }}
Успешных
+
{{ stats.docs_failed }}
Ошибок
+
+
+ +
+
+ + + + +
+ + +10 + −1 + +100 +
+ + + +
+ + {% if user.is_active %} + + {% else %} + + {% endif %} + {% if not user.telegram_verified %} + + {% endif %} +
+
+
+
diff --git a/src/contract_check/api/admin/templates/base.html b/src/contract_check/api/admin/templates/base.html new file mode 100644 index 0000000..0a442a4 --- /dev/null +++ b/src/contract_check/api/admin/templates/base.html @@ -0,0 +1,90 @@ + + + + + + {% block title %}Админка — Контракт-чек{% endblock %} + + + + + {% block header %} +
+ ⚙ Контракт-чек · Admin + + +
+
+ {% endblock %} +
+ {% block content %}{% endblock %} +
+
+ + + diff --git a/src/contract_check/api/admin/templates/login.html b/src/contract_check/api/admin/templates/login.html new file mode 100644 index 0000000..696c7fd --- /dev/null +++ b/src/contract_check/api/admin/templates/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Вход — Админка{% endblock %} +{% block header %}{% endblock %} +{% block content %} +
+
+

Вход в админку

+

Только для учётных записей с ролью admin.

+ {% if error %}

{{ error }}

{% endif %} +
+ + + + + +
+
+
+{% endblock %} diff --git a/src/contract_check/api/admin/templates/users_detail.html b/src/contract_check/api/admin/templates/users_detail.html new file mode 100644 index 0000000..4d81924 --- /dev/null +++ b/src/contract_check/api/admin/templates/users_detail.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}{{ user.email or user.telegram_id or user.id }} — Админка{% endblock %} +{% block content %} + + +
+ {% include "_user_card.html" %} +
+{% endblock %} diff --git a/src/contract_check/api/admin/templates/users_list.html b/src/contract_check/api/admin/templates/users_list.html new file mode 100644 index 0000000..9ee8130 --- /dev/null +++ b/src/contract_check/api/admin/templates/users_list.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}Пользователи — Админка{% endblock %} +{% block content %} +
+
+

Пользователи ({{ total }})

+ + Только TG + На проверке + + Создать +
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% else %} + + {% endfor %} + +
Email / TelegramРольСтатусTG проверенКредитыСоздан
+ {{ u.email or '—' }} + {% if u.telegram_id %}
tg: {{ u.telegram_id }}
{% endif %} +
{{ u.role }} + {% if u.is_active %}active{% else %}blocked{% endif %} + + {% if u.telegram_verified %}да{% else %}на проверке{% endif %} + {{ u.credits_left }}{{ u.created_at | dt }}Открыть
Ничего не найдено
+ + {% if pages > 1 %} +
+ {% for p in range(1, pages + 1) %} + {% if p == page %}{{ p }} + {% else %}{{ p }}{% endif %} + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/src/contract_check/api/admin/templates/users_new.html b/src/contract_check/api/admin/templates/users_new.html new file mode 100644 index 0000000..0ce5b2e --- /dev/null +++ b/src/contract_check/api/admin/templates/users_new.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Новый пользователь — Админка{% endblock %} +{% block content %} + + +
+

Новый пользователь

+

Email/пароль с выбираемой ролью и стартовыми кредитами.

+ {% if error %}

{{ error }}

{% endif %} +
+ + + + + + + + + +
+
+ + +
+
+ + +
+
+ + + +
+ + Отмена +
+
+
+{% endblock %} diff --git a/src/contract_check/api/admin/templating.py b/src/contract_check/api/admin/templating.py new file mode 100644 index 0000000..35533e7 --- /dev/null +++ b/src/contract_check/api/admin/templating.py @@ -0,0 +1,28 @@ +"""Jinja2 template engine for the admin panel. + +Templates ship inside the package (``src/contract_check/api/admin/templates``) +so the Docker image picks them up via the wheel install. A ``dt`` filter keeps +datetime rendering consistent. +""" + +from __future__ import annotations + +import datetime as dt +from pathlib import Path + +from fastapi.templating import Jinja2Templates + +_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" + +templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + + +def _format_dt(value: object, fmt: str = "%Y-%m-%d %H:%M UTC") -> str: + if isinstance(value, dt.datetime): + return value.strftime(fmt) + if value is None: + return "—" + return str(value) + + +templates.env.filters["dt"] = _format_dt diff --git a/src/contract_check/api/admin/users.py b/src/contract_check/api/admin/users.py new file mode 100644 index 0000000..d4b4a47 --- /dev/null +++ b/src/contract_check/api/admin/users.py @@ -0,0 +1,422 @@ +"""User management routes for the admin panel. + +List / search / create / detail / edit (role, is_active, credits) over the +``users`` table. Edit routes are HTMX-driven and swap a single ``#user-card`` +partial so the UI updates without a full reload; create is a full-page form +that redirects to the new user's detail page. +""" + +from __future__ import annotations + +import json +import re +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.config import get_settings +from ...core.db.enums import USER_ROLE_USER, USER_ROLES +from ...core.logging import get_logger +from ...core.security.passwords import hash_password +from ..deps import AsyncSessionDep +from .auth import HtmxGuard, require_admin +from .templating import templates + +log = get_logger(__name__) + +router = APIRouter( + prefix="/admin/users", + tags=["admin-users"], + dependencies=[Depends(require_admin)], +) + +PAGE_SIZE = 25 + +_USER_COLUMNS = ( + "id, telegram_id, email, role, is_active, credits_left, created_at, telegram_verified" +) + + +async def _fetch_user(session: AsyncSession, user_id: uuid.UUID) -> dict[str, Any] | None: + result = await session.execute( + text(f"SELECT {_USER_COLUMNS} FROM users WHERE id = :u"), + {"u": user_id}, + ) + row = result.first() + if row is None: + return None + return _user_row_to_dict(row) + + +async def _fetch_user_stats(session: AsyncSession, user_id: uuid.UUID) -> dict[str, int]: + """Aggregate counts shown on the detail page.""" + result = await session.execute( + text( + "SELECT count(*), " + " count(*) FILTER (WHERE status = 'done'), " + " count(*) FILTER (WHERE status = 'failed') " + "FROM documents WHERE user_id = :u" + ), + {"u": user_id}, + ) + row = result.first() + if row is None: + return {"docs_total": 0, "docs_done": 0, "docs_failed": 0} + return { + "docs_total": int(row[0] or 0), + "docs_done": int(row[1] or 0), + "docs_failed": int(row[2] or 0), + } + + +def _user_row_to_dict(row: Any) -> dict[str, Any]: + return { + "id": row[0], + "telegram_id": row[1], + "email": row[2], + "role": row[3], + "is_active": bool(row[4]), + "credits_left": int(row[5]), + "created_at": row[6], + "telegram_verified": bool(row[7]), + } + + +@router.get("", response_class=HTMLResponse, include_in_schema=False) +async def list_users( + request: Request, + session: AsyncSessionDep, + q: str = "", + page: int = 1, +) -> HTMLResponse: + """Paginated, searchable user list.""" + page = max(page, 1) + offset = (page - 1) * PAGE_SIZE + term = q.strip() + filter_sql = "" + filter_params: dict[str, Any] = {} + + # Optional admin filters via query string (no URL params in form; keep simple). + only_telegram = request.query_params.get("only_telegram") == "1" + unverified = request.query_params.get("unverified") == "1" + + if only_telegram: + filter_sql += " AND telegram_id IS NOT NULL" + if unverified: + filter_sql += " AND telegram_verified = FALSE" + + base_where = "WHERE (email ILIKE :q OR telegram_id::text ILIKE :q)" if term else "WHERE TRUE" + base_where += filter_sql + like = f"%{term}%" + filter_params = {"q": like} if term else {} + + rows = ( + await session.execute( + text( + f"SELECT {_USER_COLUMNS} FROM users " + f"{base_where} " + "ORDER BY created_at DESC LIMIT :lim OFFSET :off" + ), + {**filter_params, "lim": PAGE_SIZE, "off": offset}, + ) + ).all() + total = int( + ( + await session.execute( + text(f"SELECT count(*) FROM users {base_where}"), + filter_params, + ) + ).scalar_one() + ) + + pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + users = [_user_row_to_dict(r) for r in rows] + return templates.TemplateResponse( + request, + "users_list.html", + { + "request": request, + "users": users, + "q": term, + "page": page, + "pages": pages, + "total": total, + "roles": USER_ROLES, + "only_telegram": only_telegram, + "unverified": unverified, + }, + ) + + +# Crude but sufficient email shape check — the DB UNIQUE constraint and the +# register route's EmailStr are the authoritative validators. +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def _render_new_form( + request: Request, + error: str = "", + email: str = "", + role: str = USER_ROLE_USER, + credits_left: int = 0, + telegram_id: str = "", + is_active: bool = True, +) -> HTMLResponse: + return templates.TemplateResponse( + request, + "users_new.html", + { + "request": request, + "error": error, + "email": email, + "role": role, + "credits_left": credits_left, + "telegram_id": telegram_id, + "is_active": is_active, + "roles": USER_ROLES, + "min_password_length": get_settings().password_min_length, + }, + ) + + +@router.get("/new", response_class=HTMLResponse, include_in_schema=False) +async def new_user_form(request: Request) -> HTMLResponse: + """Empty create-user form.""" + return _render_new_form(request) + + +@router.post("", response_class=HTMLResponse, response_model=None, include_in_schema=False) +async def create_user( + request: Request, + session: AsyncSessionDep, + email: Annotated[str, Form()], + password: Annotated[str, Form()], + role: Annotated[str, Form()] = USER_ROLE_USER, + credits_left: Annotated[int, Form()] = 0, + telegram_id: Annotated[str, Form()] = "", + is_active: Annotated[str, Form()] = "on", +) -> HTMLResponse | RedirectResponse: + """Create an email/password user with an explicit role + starting credits. + + Mirrors the public register route's conventions (email normalization, argon2 + hash, min-length gate, duplicate-email check) and additionally lets the + operator pick the role, grant credits, and optionally link a Telegram id. + """ + email_norm = email.lower().strip() + tg = telegram_id.strip() + + # ── validation ── + if not _EMAIL_RE.match(email_norm): + return _render_new_form(request, "Некорректный email.", email_norm, role, credits_left, tg) + min_len = get_settings().password_min_length + if len(password) < min_len: + return _render_new_form( + request, f"Пароль короче {min_len} символов.", email_norm, role, credits_left, tg + ) + if role not in USER_ROLES: + return _render_new_form(request, "Недопустимая роль.", email_norm, role, credits_left, tg) + credits = max(0, int(credits_left)) + tg_id: int | None = None + if tg: + try: + tg_id = int(tg) + if tg_id <= 0: + raise ValueError + except ValueError: + return _render_new_form( + request, + "Telegram ID должен быть положительным числом.", + email_norm, + role, + credits_left, + tg, + ) + + dup = ( + await session.execute(text("SELECT 1 FROM users WHERE email = :e"), {"e": email_norm}) + ).first() + if dup is not None: + return _render_new_form( + request, + "Пользователь с таким email уже существует.", + email_norm, + role, + credits_left, + tg, + ) + if tg_id is not None: + dup_tg = ( + await session.execute(text("SELECT 1 FROM users WHERE telegram_id = :t"), {"t": tg_id}) + ).first() + if dup_tg is not None: + return _render_new_form( + request, + "Этот Telegram ID уже привязан к другому пользователю.", + email_norm, + role, + credits_left, + tg, + ) + + # ── insert ── + hashed = hash_password(password) + result = await session.execute( + text( + "INSERT INTO users " + "(email, password_hash, role, credits_left, telegram_id, is_active) " + "VALUES (:e, :p, :r, :c, :t, :a) " + f"RETURNING {_USER_COLUMNS}" + ), + { + "e": email_norm, + "p": hashed, + "r": role, + "c": credits, + "t": tg_id, + "a": is_active == "on", + }, + ) + row = result.first() + assert row is not None # RETURNING always yields the inserted row + await session.commit() + new_id = row[0] + log.info("admin_user_created", user_id=str(new_id), email=email_norm, role=role) + return RedirectResponse(url=f"/admin/users/{new_id}", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/{user_id}", response_class=HTMLResponse, include_in_schema=False) +async def user_detail( + user_id: uuid.UUID, + request: Request, + session: AsyncSessionDep, +) -> HTMLResponse: + user = await _fetch_user(session, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") + stats = await _fetch_user_stats(session, user_id) + return templates.TemplateResponse( + request, + "users_detail.html", + { + "request": request, + "user": user, + "stats": stats, + "roles": USER_ROLES, + }, + ) + + +@router.post("/{user_id}", response_class=HTMLResponse, include_in_schema=False) +async def update_user( + user_id: uuid.UUID, + request: Request, + session: AsyncSessionDep, + _: Annotated[None, HtmxGuard], + role: Annotated[str, Form()], + is_active: Annotated[str, Form()] = "", + credits_left: Annotated[int, Form()] = 0, +) -> HTMLResponse: + """Update role / active flag / credits. Returns the refreshed user card.""" + if role not in USER_ROLES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role") + credits = max(0, int(credits_left)) + await session.execute( + text("UPDATE users SET role = :r, is_active = :a, credits_left = :c WHERE id = :u"), + {"r": role, "a": is_active == "on", "c": credits, "u": user_id}, + ) + await session.commit() + + user = await _fetch_user(session, user_id) + assert user is not None # just updated this row + stats = await _fetch_user_stats(session, user_id) + response = templates.TemplateResponse( + request, + "_user_card.html", + {"request": request, "user": user, "stats": stats, "roles": USER_ROLES}, + ) + response.headers["HX-Trigger"] = json.dumps({"showToast": "Пользователь обновлён"}) + return response + + +@router.post("/{user_id}/toggle-active", response_class=HTMLResponse, include_in_schema=False) +async def toggle_active( + user_id: uuid.UUID, + request: Request, + session: AsyncSessionDep, + _: Annotated[None, HtmxGuard], +) -> HTMLResponse: + await session.execute( + text("UPDATE users SET is_active = NOT is_active WHERE id = :u"), + {"u": user_id}, + ) + await session.commit() + user = await _fetch_user(session, user_id) + assert user is not None + stats = await _fetch_user_stats(session, user_id) + response = templates.TemplateResponse( + request, + "_user_card.html", + {"request": request, "user": user, "stats": stats, "roles": USER_ROLES}, + ) + msg = "Пользователь разблокирован" if user["is_active"] else "Пользователь заблокирован" + response.headers["HX-Trigger"] = json.dumps({"showToast": msg}) + return response + + +@router.post("/{user_id}/verify-telegram", response_class=HTMLResponse, include_in_schema=False) +async def verify_telegram( + user_id: uuid.UUID, + request: Request, + session: AsyncSessionDep, + _: Annotated[None, HtmxGuard], +) -> HTMLResponse: + """Mark the user's Telegram identity as verified (admin review).""" + await session.execute( + text("UPDATE users SET telegram_verified = TRUE WHERE id = :u"), + {"u": user_id}, + ) + await session.commit() + user = await _fetch_user(session, user_id) + assert user is not None + stats = await _fetch_user_stats(session, user_id) + response = templates.TemplateResponse( + request, + "_user_card.html", + {"request": request, "user": user, "stats": stats, "roles": USER_ROLES}, + ) + response.headers["HX-Trigger"] = json.dumps({"showToast": "Telegram-профиль подтверждён"}) + return response + + +@router.get("/{user_id}/credits", response_class=HTMLResponse, include_in_schema=False) +async def adjust_credits( + user_id: uuid.UUID, + request: Request, + session: AsyncSessionDep, + delta: int = 0, +) -> HTMLResponse: + """Bump credits by ``delta`` (clamped at 0). HTMX button target.""" + if delta != 0: + await session.execute( + text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"), + {"d": int(delta), "u": user_id}, + ) + await session.commit() + user = await _fetch_user(session, user_id) + assert user is not None + stats = await _fetch_user_stats(session, user_id) + response = templates.TemplateResponse( + request, + "_user_card.html", + {"request": request, "user": user, "stats": stats, "roles": USER_ROLES}, + ) + if delta != 0: + sign = "+" if delta > 0 else "" + response.headers["HX-Trigger"] = json.dumps( + {"showToast": f"Кредиты изменены на {sign}{delta}"} + ) + return response diff --git a/src/contract_check/api/app.py b/src/contract_check/api/app.py index 01abac4..8a81921 100644 --- a/src/contract_check/api/app.py +++ b/src/contract_check/api/app.py @@ -9,6 +9,7 @@ from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI +from fastapi.responses import RedirectResponse from ..core.config import get_settings from ..core.llm import port as llm_port # noqa: F401 — package loaded @@ -21,6 +22,9 @@ from ..core.redis_client import get_redis_client from ..core.s3.minio_storage import MinioStorage from ..core.sentry import init_sentry from ..core.telemetry import setup_telemetry, shutdown_telemetry +from .admin.auth import AdminAuthError +from .admin.router import router as admin_router +from .deps import create_email_user from .middleware import add_middleware from .routes import auth, b2b, documents, health, me, metrics, reports @@ -77,6 +81,11 @@ async def lifespan(app: FastAPI) -> Any: app.state.rate_limiter = rate_limiter app.state.redis = redis_client + # Ensure a default admin account exists if ADMIN_DEFAULT_PASSWORD is set. + # This runs on every startup but is a no-op once the account exists. + if settings.web_admin_enabled and settings.admin_default_password: + await _ensure_default_admin(settings, redis_client) + yield await publisher.close() @@ -104,4 +113,58 @@ def create_app() -> FastAPI: app.include_router(reports.router) app.include_router(me.router) app.include_router(b2b.router) + + # Server-rendered admin panel (FastAPI + Jinja2 + HTMX). Gated behind the + # WEB_ADMIN_ENABLED flag; routes additionally require users.role = 'admin'. + if get_settings().web_admin_enabled: + app.include_router(admin_router) + + # AdminAuthError → browser redirect to /admin/login (the generic 500 + # handler would otherwise swallow this Exception subclass). + @app.exception_handler(AdminAuthError) + async def _admin_auth_redirect( # noqa: RUF0 — intentional closure + _request: Any, exc: AdminAuthError + ) -> RedirectResponse: + return RedirectResponse(url=f"/admin/login?reason={exc.reason}", status_code=303) + return app + + +async def _ensure_default_admin(settings: Any, redis_client: Any) -> None: + """Create the default admin account if no user with the admin email exists. + + Uses a one-off DB session because the FastAPI dependency chain is not + available during lifespan setup. Requires web auth (Redis) to be available. + """ + from sqlalchemy import text + + from ..core.db.session import create_session_factory + from ..core.security.passwords import hash_password + + logger = get_logger(__name__) + if redis_client is None: + logger.warning("default_admin_skipped", reason="redis unavailable") + return + + factory = create_session_factory() + async with factory() as session: + existing = await session.execute( + text("SELECT id FROM users WHERE email = :e"), + {"e": settings.admin_default_email.lower().strip()}, + ) + if existing.first() is None: + user = await create_email_user( + session, + email=settings.admin_default_email.lower().strip(), + password_hash=hash_password(settings.admin_default_password), + ) + await session.execute( + text("UPDATE users SET role = :r WHERE id = :u"), + {"r": settings.admin_required_role, "u": user.id}, + ) + await session.commit() + logger.info( + "default_admin_created", + user_id=str(user.id), + email=settings.admin_default_email, + ) diff --git a/src/contract_check/api/deps.py b/src/contract_check/api/deps.py index 66c4250..ebfb839 100644 --- a/src/contract_check/api/deps.py +++ b/src/contract_check/api/deps.py @@ -2,6 +2,8 @@ from __future__ import annotations +import datetime as dt +import json from collections.abc import AsyncIterator from typing import Annotated, Any from uuid import UUID @@ -21,6 +23,7 @@ from ..core.mq.publisher import Publisher from ..core.notifications.publisher import NotificationPublisher from ..core.rate_limit import RateLimiter from ..core.s3.port import Storage +from ..core.security.passwords import hash_password from ..core.tokens import hash_token log = get_logger(__name__) @@ -117,27 +120,72 @@ async def require_service_token( AuthDep = Annotated[None, Depends(require_service_token)] -async def get_or_create_user_for_telegram(session: AsyncSession, telegram_id: int) -> User: - """Fetch or create a user identified by telegram_id.""" +async def get_or_create_user_for_telegram( + session: AsyncSession, + telegram_id: int, + *, + profile: dict[str, object] | None = None, + verified: bool = True, +) -> User: + """Fetch or create a user identified by telegram_id. + + On creation stores the profile snapshot, verification flag, and binding time. + On existing row optionally refreshes the profile snapshot. + """ result = await session.execute( - text("SELECT id, telegram_id, created_at, credits_left FROM users WHERE telegram_id = :t"), + text( + "SELECT id, telegram_id, email, password_hash, is_active, " + "created_at, credits_left, telegram_verified " + "FROM users WHERE telegram_id = :t" + ), {"t": telegram_id}, ) row = result.first() if row: - return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3]) + # Refresh profile snapshot on every /start so admin sees current data. + if profile: + await session.execute( + text( + "UPDATE users SET telegram_profile_json = :p, telegram_verified = :v " + "WHERE id = :u" + ), + {"p": json.dumps(profile, ensure_ascii=False), "v": verified, "u": row[0]}, + ) + await session.commit() + return User( + id=row[0], + telegram_id=row[1], + email=row[2], + password_hash=row[3], + is_active=row[4], + created_at=row[5], + credits_left=row[6], + telegram_verified=verified if profile else row[7], + ) + bound_at = dt.datetime.now(tz=dt.UTC) + profile_json = json.dumps(profile, ensure_ascii=False) if profile else None insert = await session.execute( text( - "INSERT INTO users (telegram_id, credits_left) VALUES (:t, 0) " - "RETURNING id, telegram_id, created_at, credits_left" + "INSERT INTO users (telegram_id, credits_left, telegram_profile_json, " + "telegram_verified, telegram_bound_at) " + "VALUES (:t, 0, :p, :v, :b) " + "RETURNING id, telegram_id, created_at, credits_left, is_active, " + "telegram_verified" ), - {"t": telegram_id}, + {"t": telegram_id, "p": profile_json, "v": verified, "b": bound_at}, ) new = insert.first() assert new is not None await session.commit() - return User(id=new[0], telegram_id=new[1], created_at=new[2], credits_left=new[3]) + return User( + id=new[0], + telegram_id=new[1], + created_at=new[2], + credits_left=new[3], + is_active=new[4], + telegram_verified=new[5], + ) async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User | None: @@ -152,6 +200,39 @@ async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3]) +async def bind_telegram_to_user( + session: AsyncSession, + user_id: UUID, + telegram_id: int, +) -> None: + """Link a Telegram id to an existing user (web -> Telegram). + + Raises HTTPException 409 if the telegram_id is already bound to another user. + """ + dup = await session.execute( + text("SELECT id FROM users WHERE telegram_id = :t AND id != :u"), + {"t": telegram_id, "u": user_id}, + ) + if dup.first() is not None: + raise HTTPException(status_code=409, detail="Telegram id already bound to another account") + + await session.execute( + text("UPDATE users SET telegram_id = :t, telegram_bound_at = now() WHERE id = :u"), + {"t": telegram_id, "u": user_id}, + ) + await session.commit() + + +async def set_user_password(session: AsyncSession, user_id: UUID, password: str) -> None: + """Set/rotate a web password for a user (Telegram -> web UI access).""" + hashed = hash_password(password) + await session.execute( + text("UPDATE users SET password_hash = :p WHERE id = :u"), + {"p": hashed, "u": user_id}, + ) + await session.commit() + + async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None: """Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found.""" result = await session.execute( diff --git a/src/contract_check/api/routes/auth.py b/src/contract_check/api/routes/auth.py index b6c563b..d8e284d 100644 --- a/src/contract_check/api/routes/auth.py +++ b/src/contract_check/api/routes/auth.py @@ -61,8 +61,16 @@ log = get_logger(__name__) router = APIRouter(tags=["auth"]) +class TelegramProfile(BaseModel): + username: str | None = None + first_name: str | None = None + last_name: str | None = None + language_code: str | None = None + + class TelegramBotAuthRequest(BaseModel): telegram_id: int = Field(..., gt=0, description="Verified Telegram user id from aiogram") + profile: TelegramProfile | None = None class TelegramWebAuthRequest(BaseModel): @@ -132,12 +140,31 @@ async def auth_telegram_bot( session: AsyncSessionDep, body: TelegramBotAuthRequest, ) -> AuthResponse: - """Exchange a verified telegram_id (from the bot) for a user JWT.""" + """Exchange a verified telegram_id (from the bot) for a user JWT. + + Stores/updates the Telegram profile snapshot and sets telegram_verified + based on whether the profile looks legitimate. + """ identity = verify_bot_identity(body.telegram_id) - user = await get_or_create_user_for_telegram(session, identity.telegram_id) + profile = body.profile + verified = _telegram_profile_looks_verified(profile) + user = await get_or_create_user_for_telegram( + session, + identity.telegram_id, + profile=profile.model_dump(exclude_none=True) if profile else None, + verified=verified, + ) + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") return _issue_token(user) +def _telegram_profile_looks_verified(profile: TelegramProfile | None) -> bool: + if profile is None: + return False + return bool(profile.username or profile.first_name or profile.last_name) + + @router.post("/api/v1/auth/telegram/web", status_code=status.HTTP_200_OK) async def auth_telegram_web( session: AsyncSessionDep, diff --git a/src/contract_check/api/routes/me.py b/src/contract_check/api/routes/me.py index 210075c..fd8f9ea 100644 --- a/src/contract_check/api/routes/me.py +++ b/src/contract_check/api/routes/me.py @@ -1,18 +1,107 @@ -"""User profile endpoint (credits balance).""" +"""User profile endpoint (credits balance + Telegram binding).""" from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, status +from pydantic import BaseModel, Field +from sqlalchemy import text -from ..deps import AsyncSessionDep, CurrentUserDep, get_credits +from ..deps import ( + AsyncSessionDep, + CurrentUserDep, + bind_telegram_to_user, + get_credits, + set_user_password, +) router = APIRouter(tags=["me"]) -@router.get("/api/v1/me") +class MeResponse(BaseModel): + telegram_id: int | None = None + credits_left: int + email: str | None = None + + +class BindTelegramRequest(BaseModel): + telegram_id: int = Field(..., gt=0, description="Verified Telegram user id") + + +class BindTelegramResponse(BaseModel): + ok: bool = True + telegram_id: int + + +class SetPasswordRequest(BaseModel): + password: str = Field(..., min_length=8, max_length=128) + + +@router.get("/api/v1/me", response_model=MeResponse) async def me( session: AsyncSessionDep, user: CurrentUserDep, -) -> dict[str, object]: +) -> MeResponse: credits = await get_credits(session, user.user_id) - return {"telegram_id": user.telegram_id, "credits_left": credits} + return MeResponse( + telegram_id=user.telegram_id if user.telegram_id else None, + credits_left=credits, + ) + + +@router.get("/api/v1/me/documents") +async def list_my_documents( + session: AsyncSessionDep, + user: CurrentUserDep, + limit: int = 10, +) -> dict[str, object]: + """Return the current user's recent documents for the bot /reports command.""" + if limit < 1: + limit = 1 + if limit > 100: + limit = 100 + + result = await session.execute( + text( + "SELECT d.id, d.filename, d.status, d.stage, d.created_at " + "FROM documents d " + "WHERE d.user_id = :u " + "ORDER BY d.created_at DESC " + "LIMIT :limit" + ), + {"u": user.user_id, "limit": limit}, + ) + rows = result.all() + return { + "documents": [ + { + "document_id": str(row[0]), + "filename": row[1], + "status": row[2], + "stage": row[3], + "created_at": row[4].isoformat() if row[4] else None, + } + for row in rows + ] + } + + +@router.post("/api/v1/me/telegram", response_model=BindTelegramResponse) +async def bind_telegram( + session: AsyncSessionDep, + user: CurrentUserDep, + body: BindTelegramRequest, +) -> BindTelegramResponse: + """Link a Telegram account to the current web/email user.""" + await bind_telegram_to_user(session, user.user_id, body.telegram_id) + return BindTelegramResponse(ok=True, telegram_id=body.telegram_id) + + +@router.post("/api/v1/me/password", status_code=status.HTTP_200_OK) +async def set_password( + session: AsyncSessionDep, + user: CurrentUserDep, + body: SetPasswordRequest, +) -> dict[str, bool]: + """Let a Telegram-only user set a web password to access the web UI.""" + await set_user_password(session, user.user_id, body.password) + return {"ok": True} diff --git a/src/contract_check/bot/__main__.py b/src/contract_check/bot/__main__.py index ce9d06f..152414c 100644 --- a/src/contract_check/bot/__main__.py +++ b/src/contract_check/bot/__main__.py @@ -11,6 +11,7 @@ from ..core.logging import bind_context, configure_logging, get_logger from .client import ApiClient from .config import get_bot_settings from .handlers import router as bot_router +from .rate_limit import get_rate_limiter log = get_logger(__name__) @@ -28,8 +29,12 @@ async def main() -> None: api = ApiClient(settings) await api.start() + # Rate limiter for /start and other bot-level guards. Redis in prod, + # in-memory fallback in dev/tests. + rate_limiter = await get_rate_limiter(settings.redis_url or "redis://redis:6379/0") + bot = Bot(token=settings.bot_token) - dp = Dispatcher(api=api, settings=settings) + dp = Dispatcher(api=api, settings=settings, rate_limiter=rate_limiter) dp.include_router(bot_router) me = await bot.get_me() @@ -56,6 +61,10 @@ async def main() -> None: await dp.stop_polling() await bot.session.close() await api.aclose() + try: + await rate_limiter.aclose() + except Exception: + pass log.info("bot_stopped") diff --git a/src/contract_check/bot/client.py b/src/contract_check/bot/client.py index 9f57cc9..44c8e21 100644 --- a/src/contract_check/bot/client.py +++ b/src/contract_check/bot/client.py @@ -123,11 +123,30 @@ class ApiClient: "X-Correlation-ID": correlation_id, } - async def login(self, telegram_id: int, correlation_id: str) -> None: - """Exchange a verified telegram_id for a user JWT and cache it.""" + async def login( + self, + telegram_id: int, + correlation_id: str, + *, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + language_code: str | None = None, + ) -> None: + """Exchange a verified telegram_id (+ profile snapshot) for a user JWT.""" + payload: dict[str, object] = {"telegram_id": telegram_id} + profile: dict[str, str | None] = { + "username": username, + "first_name": first_name, + "last_name": last_name, + "language_code": language_code, + } + if any(v is not None for v in profile.values()): + payload["profile"] = profile + r = await self.client.post( "/api/v1/auth/telegram/bot", - json={"telegram_id": telegram_id}, + json=payload, headers=self._service_headers(correlation_id), ) if r.status_code != 200: @@ -197,3 +216,31 @@ class ApiClient: markdown=body.get("markdown"), filename=body.get("filename"), ) + + async def get_me(self, telegram_id: int, correlation_id: str) -> dict[str, object]: + """Fetch current user profile from GET /api/v1/me.""" + r = await self.client.get( + "/api/v1/me", + headers=self._user_headers(telegram_id, correlation_id), + ) + if r.status_code != 200: + raise ApiError(r.status_code, _extract_detail(r)) + body = r.json() + if not isinstance(body, dict): + raise ApiError(500, "unexpected me response") + return body + + async def list_documents( + self, telegram_id: int, correlation_id: str, *, limit: int = 10 + ) -> list[dict[str, object]]: + """List the user's recent documents from GET /api/v1/me/documents.""" + r = await self.client.get( + "/api/v1/me/documents", + params={"limit": limit}, + headers=self._user_headers(telegram_id, correlation_id), + ) + if r.status_code != 200: + raise ApiError(r.status_code, _extract_detail(r)) + body = r.json() + docs = body.get("documents") if isinstance(body, dict) else body + return docs if isinstance(docs, list) else [] diff --git a/src/contract_check/bot/config.py b/src/contract_check/bot/config.py index 0f51b55..6c6d591 100644 --- a/src/contract_check/bot/config.py +++ b/src/contract_check/bot/config.py @@ -33,6 +33,10 @@ class BotSettings(BaseSettings): default="http://api:8000", description="Base URL of the api service (HTTP-only target).", ) + redis_url: str = Field( + default="redis://redis:6379/0", + description="Redis URL for the bot rate limiter (optional; falls back to memory).", + ) bot_service_token: str = Field( ..., description="Bearer token authenticating the bot against the api (service_tokens).", @@ -43,6 +47,16 @@ class BotSettings(BaseSettings): poll_timeout: float = 300.0 long_message_threshold: int = 4096 + # /start hardening + bot_start_rate_limit_rps: float = Field( + default=5.0, + description="Max /start requests per second per telegram_id (token-bucket rate limit).", + ) + bot_require_profile: bool = Field( + default=True, + description="If true, empty Telegram profiles (no username/name) are allowed but flagged for review.", + ) + @property def json_logs(self) -> bool: return self.env != "dev" diff --git a/src/contract_check/bot/handlers.py b/src/contract_check/bot/handlers.py index 479b110..0112771 100644 --- a/src/contract_check/bot/handlers.py +++ b/src/contract_check/bot/handlers.py @@ -19,9 +19,10 @@ import io from aiogram import Bot, F, Router from aiogram.filters import Command -from aiogram.types import BufferedInputFile, Document, Message +from aiogram.types import BufferedInputFile, Document, Message, User from ..core.logging import get_logger, new_correlation_id +from ..core.rate_limit import RateLimiter from .client import ( ApiClient, ApiError, @@ -58,27 +59,188 @@ def _user_id(message: Message) -> int: return message.from_user.id if message.from_user else 0 -@router.message(Command("start", "help")) -async def cmd_start(message: Message, api: ApiClient) -> None: - cid = new_correlation_id() +def _profile(user: User | None) -> dict[str, str | None]: + if user is None: + return {"username": None, "first_name": None, "last_name": None, "language_code": None} + return { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "language_code": user.language_code, + } + + +def _looks_like_profile(user: User | None) -> bool: + if user is None: + return False + return bool(user.username or user.first_name or user.last_name) + + +async def _ensure_login( + message: Message, + api: ApiClient, + correlation_id: str, +) -> None: tg = _user_id(message) + profile = _profile(message.from_user) + await api.login( + tg, + correlation_id, + username=profile.get("username"), + first_name=profile.get("first_name"), + last_name=profile.get("last_name"), + language_code=profile.get("language_code"), + ) + + +async def _check_rate_limit( + message: Message, + rate_limiter: RateLimiter, + settings: BotSettings, +) -> bool: + """Return True if the call is allowed. If blocked, reply to the user.""" + tg = _user_id(message) + limit = settings.bot_start_rate_limit_rps + result = await rate_limiter.allow(f"bot:start:{tg}", int(limit)) + if not result.allowed: + log.warning( + "start_rate_limited", + telegram_id=tg, + retry_after=result.retry_after_sec, + ) + await message.answer("Слишком много запросов. Подождите немного и попробуйте снова.") + return False + return True + + +@router.message(Command("start", "help")) +async def cmd_start( + message: Message, + api: ApiClient, + settings: BotSettings, + rate_limiter: RateLimiter, +) -> None: + cid = new_correlation_id() + if not await _check_rate_limit(message, rate_limiter, settings): + return try: - await api.login(tg, cid) - credits = await api.get_credits(tg, cid) + await _ensure_login(message, api, cid) + credits = await api.get_credits(_user_id(message), cid) except ApiError as exc: - log.error("me_failed", correlation_id=cid, error=str(exc)) + log.error("start_failed", correlation_id=cid, error=str(exc)) await message.answer( "Привет! Я «Контракт-чек» — скрининг рисков в договорах.\n" "Не удалось связаться с сервисом, попробуйте позже." ) return - await message.answer( - "Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах " - "по ГК РФ / ГК РБ.\n\n" - "Пришлите PDF или DOCX договор — я проверю его по чек-листу и пришлю " - "отчёт с рисками и рекомендациями.\n\n" - f"Осталось проверок: {credits}." - ) + + if settings.bot_require_profile and not _looks_like_profile(message.from_user): + greeting = ( + "Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах " + "по ГК РФ / ГК РБ.\n\n" + "Ваш Telegram-профиль пустой, поэтому аккаунт отправлен на ручную проверку. " + "Вы уже можете присылать договоры, но загрузки могут быть ограничены до проверки.\n\n" + f"Осталось проверок: {credits}." + "\n\nДоступные команды:\n" + "/start — приветствие и баланс\n" + "/balance — остаток проверок\n" + "/reports — ваши последние документы\n" + "/status — статус одного документа" + ) + else: + greeting = ( + "Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах " + "по ГК РФ / ГК РБ.\n\n" + "Пришлите PDF или DOCX договор — я проверю его по чек-листу и пришлю " + "отчёт с рисками и рекомендациями.\n\n" + f"Осталось проверок: {credits}." + "\n\nДоступные команды:\n" + "/start — приветствие и баланс\n" + "/balance — остаток проверок\n" + "/reports — ваши последние документы\n" + "/status — статус одного документа" + ) + await message.answer(greeting) + + +@router.message(Command("balance")) +async def cmd_balance(message: Message, api: ApiClient) -> None: + cid = new_correlation_id() + tg = _user_id(message) + try: + await _ensure_login(message, api, cid) + credits = await api.get_credits(tg, cid) + except ApiError as exc: + log.error("balance_failed", correlation_id=cid, error=str(exc)) + await message.answer("Не удалось получить баланс. Попробуйте позже.") + return + await message.answer(f"Осталось проверок: {credits}.") + + +@router.message(Command("reports")) +async def cmd_reports(message: Message, api: ApiClient) -> None: + cid = new_correlation_id() + tg = _user_id(message) + try: + await _ensure_login(message, api, cid) + docs = await api.list_documents(tg, cid, limit=10) + except ApiError as exc: + log.error("reports_failed", correlation_id=cid, error=str(exc)) + await message.answer("Не удалось загрузить список документов. Попробуйте позже.") + return + + if not docs: + await message.answer("У вас пока нет проверенных документов. Пришлите PDF или DOCX.") + return + + lines = ["Ваши последние документы:"] + for raw in docs: + if not isinstance(raw, dict): + continue + doc: dict[str, object] = raw + doc_id = str(doc.get("document_id", "?")) + filename = doc.get("filename") or "без имени" + status = str(doc.get("status") or "unknown") + stage = doc.get("stage") + label = _STAGE_LABELS.get(str(stage or status), "Обрабатываю…") + lines.append(f"\n📄 {filename}\nID: {doc_id}\nСтатус: {label}") + lines.append("\nДля подробностей: /status ") + await message.answer("\n".join(lines)) + + +@router.message(Command("status")) +async def cmd_status(message: Message, api: ApiClient) -> None: + cid = new_correlation_id() + tg = _user_id(message) + args = message.text.split()[1:] if message.text else [] + if not args: + await message.answer("Укажите ID документа: /status ") + return + document_id = args[0] + try: + await _ensure_login(message, api, cid) + report = await api.get_report(tg, cid, document_id) + except ApiError as exc: + log.warning("status_failed", correlation_id=cid, document_id=document_id, error=str(exc)) + await message.answer( + "Не удалось получить статус документа. Проверьте ID и попробуйте снова." + ) + return + + label = _stage_label(report) + if report.status == "done" and report.markdown: + bot = message.bot + if bot is None: + await message.answer("Внутренняя ошибка: бот недоступен.") + return + await _deliver_report(bot, message.chat.id, report, report.filename or "документ", 4096) + return + if report.status == "failed": + await message.answer( + "Не удалось обработать документ. Проверка списана не будет — попробуйте другой файл." + ) + return + await message.answer(f"Статус: {label}") @router.message(F.document) @@ -110,7 +272,7 @@ async def handle_document(message: Message, api: ApiClient, settings: BotSetting content_type = document.mime_type or _CONTENT_TYPES[suffix] try: - await api.login(tg, cid) + await _ensure_login(message, api, cid) upload = await api.upload_document(tg, cid, document.file_name, data, content_type) except NoCreditsError: await _edit(status_msg, "У вас закончились проверки. Пополните баланс, чтобы продолжить.") diff --git a/src/contract_check/bot/rate_limit.py b/src/contract_check/bot/rate_limit.py new file mode 100644 index 0000000..e7a903c --- /dev/null +++ b/src/contract_check/bot/rate_limit.py @@ -0,0 +1,38 @@ +"""Bot-specific rate limiter factory. + +Keeps the bot entrypoint free of infra imports that would break the boundary +in tests/unit/test_bot_boundary.py. Redis is resolved lazily at runtime. +""" + +from __future__ import annotations + +from typing import Any + +from ..core.logging import get_logger +from ..core.rate_limit import MemoryRateLimiter, RateLimiter + +log = get_logger(__name__) + + +def _redis_client(url: str) -> Any: + from ..core.redis_client import get_redis_client + + return get_redis_client(url) + + +async def get_rate_limiter(redis_url: str) -> RateLimiter: + """Build a Redis rate limiter or fall back to memory on failure.""" + redis_client = _redis_client(redis_url) + try: + await redis_client.ping() + from ..core.rate_limit import RedisRateLimiter + + log.info("rate_limiter_redis_ready") + return RedisRateLimiter(redis_client) + except Exception as exc: + log.warning("rate_limiter_redis_unavailable", error=str(exc)) + try: + await redis_client.aclose() + except Exception: + pass + return MemoryRateLimiter() diff --git a/src/contract_check/core/config.py b/src/contract_check/core/config.py index 8c79321..bd1ee05 100644 --- a/src/contract_check/core/config.py +++ b/src/contract_check/core/config.py @@ -104,6 +104,24 @@ class Settings(BaseSettings): # Minimum password length enforced at register / reset. password_min_length: int = 8 + # --- admin panel (server-rendered, mounted at /admin in the api) --- + web_admin_enabled: bool = Field( + default=True, + description="Toggle for the /admin management UI (users, future subscriptions)", + ) + admin_required_role: str = Field( + default="admin", + description="users.role value required to enter /admin (must match the DB CHECK constraint)", + ) + admin_default_email: str = Field( + default="admin@contract-check.local", + description="Default admin panel login email (auto-created if no admin exists)", + ) + admin_default_password: str = Field( + default="", + description="Default admin panel password. Empty disables auto-admin creation.", + ) + # --- SMTP (notification transport; empty host disables sending) --- smtp_host: str = "" smtp_port: int = 587 diff --git a/src/contract_check/core/db/enums.py b/src/contract_check/core/db/enums.py index ea397b3..30187a1 100644 --- a/src/contract_check/core/db/enums.py +++ b/src/contract_check/core/db/enums.py @@ -65,3 +65,9 @@ INVOICE_STATUSES: tuple[str, ...] = ( # refund policy switch (REFUND_POLICY env) RefundPolicyLike = Literal["all", "infra_only"] REFUND_POLICIES: tuple[str, ...] = ("all", "infra_only") + +# users.role — admin-panel RBAC (docs/ARCHITECTURE.md §admin) +UserRole = Literal["user", "admin"] +USER_ROLES: tuple[str, ...] = ("user", "admin") +USER_ROLE_USER: UserRole = "user" +USER_ROLE_ADMIN: UserRole = "admin" diff --git a/src/contract_check/core/db/models.py b/src/contract_check/core/db/models.py index baacebb..e8ef78b 100644 --- a/src/contract_check/core/db/models.py +++ b/src/contract_check/core/db/models.py @@ -52,6 +52,14 @@ class User(Base): is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default=text("true") ) + role: Mapped[str] = mapped_column( + String, nullable=False, default="user", server_default=text("'user'") + ) + telegram_profile_json: Mapped[str | None] = mapped_column(Text) + telegram_verified: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default=text("true") + ) + telegram_bound_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) @@ -64,6 +72,7 @@ class User(Base): "telegram_id IS NOT NULL OR email IS NOT NULL", name="users_identity_present", ), + CheckConstraint("role IN ('user', 'admin')", name="users_role_check"), ) documents: Mapped[list[Document]] = relationship( diff --git a/src/contract_check/core/rate_limit.py b/src/contract_check/core/rate_limit.py index 49a4bf0..8662a9d 100644 --- a/src/contract_check/core/rate_limit.py +++ b/src/contract_check/core/rate_limit.py @@ -25,6 +25,8 @@ class RateLimiter(Protocol): async def allow(self, key: str, limit_rps: int) -> RateLimitResult: ... + async def aclose(self) -> None: ... + @dataclass(frozen=True) class RateLimitResult: @@ -102,6 +104,10 @@ class RedisRateLimiter: retry_after = None if allowed else float(raw[1]) return RateLimitResult(allowed=allowed, retry_after_sec=retry_after) + async def aclose(self) -> None: + """Close the underlying Redis client.""" + await self._redis.aclose() + class MemoryRateLimiter: """In-memory token bucket for unit tests and dev without Redis.""" @@ -129,3 +135,6 @@ class MemoryRateLimiter: retry_after = (1.0 - tokens) / refill_rate self._buckets[key] = (tokens, now) return RateLimitResult(allowed=False, retry_after_sec=retry_after) + + async def aclose(self) -> None: + """No-op for the in-memory backend.""" diff --git a/tests/integration/test_admin_panel.py b/tests/integration/test_admin_panel.py new file mode 100644 index 0000000..36479ff --- /dev/null +++ b/tests/integration/test_admin_panel.py @@ -0,0 +1,189 @@ +"""Admin panel (/admin) integration tests: auth gate, create, edit, ban. + +Run against the Docker Compose infrastructure (`docker compose up -d`). +Covers the server-rendered FastAPI + Jinja2 + HTMX panel mounted in the api. +""" + +from __future__ import annotations + +import uuid + +import httpx +import pytest +from sqlalchemy import text + +from contract_check.core.security.passwords import hash_password + +pytestmark = pytest.mark.integration + + +async def _seed_user( + db_session, + *, + email: str, + password: str, + role: str = "user", + is_active: bool = True, + credits_left: int = 0, +) -> str: + """Insert a user and return its id (cleaned up by the per-test session rollback + semantics; we also delete explicitly to keep tables tidy across tests).""" + result = await db_session.execute( + text( + "INSERT INTO users (email, password_hash, role, is_active, credits_left) " + "VALUES (:e, :p, :r, :a, :c) RETURNING id" + ), + { + "e": email, + "p": hash_password(password), + "r": role, + "a": is_active, + "c": credits_left, + }, + ) + await db_session.commit() + user_id = result.first()[0] + return str(user_id) + + +async def _login_as(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response: + return await client.post( + "/admin/login", data={"email": email, "password": password}, follow_redirects=False + ) + + +async def test_admin_login_sets_cookie_for_admin(client: httpx.AsyncClient, db_session) -> None: + email = f"admin-{uuid.uuid4().hex[:8]}@test.local" + await _seed_user(db_session, email=email, password="adminpass-123", role="admin") + + r = await _login_as(client, email, "adminpass-123") + assert r.status_code == 303 + assert r.headers["location"] == "/admin/users" + assert "cc_admin_token" in r.cookies + + +async def test_admin_login_rejects_non_admin_role(client: httpx.AsyncClient, db_session) -> None: + email = f"user-{uuid.uuid4().hex[:8]}@test.local" + await _seed_user(db_session, email=email, password="userpass-123", role="user") + + r = await _login_as(client, email, "userpass-123") + assert r.status_code == 303 + assert "forbidden" in r.headers["location"] + assert "cc_admin_token" not in r.cookies + + +async def test_admin_routes_redirect_without_session(client: httpx.AsyncClient) -> None: + r = await client.get("/admin/users", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"].startswith("/admin/login") + + +async def test_admin_creates_user_and_redirects_to_detail( + client: httpx.AsyncClient, db_session +) -> None: + admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" + await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") + await _login_as(client, admin_email, "adminpass-123") + + new_email = f"new-{uuid.uuid4().hex[:8]}@test.local" + r = await client.post( + "/admin/users", + data={ + "email": new_email, + "password": "newpass-1234", + "role": "admin", + "credits_left": "7", + "is_active": "on", + }, + follow_redirects=False, + ) + assert r.status_code == 303 + location = r.headers["location"] + assert location.startswith("/admin/users/") + + # Persisted with the chosen role + credits. + row = ( + await db_session.execute( + text( + "SELECT credits_left, role, is_active, password_hash IS NOT NULL " + "FROM users WHERE email = :e" + ), + {"e": new_email}, + ) + ).first() + assert row is not None + assert row[0] == 7 + assert row[1] == "admin" + assert row[2] is True + assert row[3] is True + + # Detail page renders the created user. + detail = await client.get(location, follow_redirects=False) + assert detail.status_code == 200 + assert new_email in detail.text + + +async def test_admin_create_rejects_duplicate_email(client: httpx.AsyncClient, db_session) -> None: + admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" + existing = f"dup-{uuid.uuid4().hex[:8]}@test.local" + await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") + await _seed_user(db_session, email=existing, password="somepass-123") + await _login_as(client, admin_email, "adminpass-123") + + r = await client.post( + "/admin/users", + data={"email": existing, "password": "anotherpass-123"}, + follow_redirects=False, + ) + assert r.status_code == 200 # form re-rendered, not a redirect/5xx + assert "уже существует" in r.text + + +async def test_admin_create_rejects_short_password(client: httpx.AsyncClient, db_session) -> None: + admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" + await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") + await _login_as(client, admin_email, "adminpass-123") + + r = await client.post( + "/admin/users", + data={"email": f"short-{uuid.uuid4().hex[:8]}@test.local", "password": "x"}, + follow_redirects=False, + ) + assert r.status_code == 200 + assert "Пароль короче" in r.text + + +async def test_admin_new_form_resolves_before_dynamic_route( + client: httpx.AsyncClient, db_session +) -> None: + """`/admin/users/new` must hit the form route, not be parsed as {user_id}.""" + admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" + await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") + await _login_as(client, admin_email, "adminpass-123") + + r = await client.get("/admin/users/new", follow_redirects=False) + assert r.status_code == 200 + assert "Новый пользователь" in r.text + + +async def test_admin_toggle_active_bans_user(client: httpx.AsyncClient, db_session) -> None: + admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" + await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") + await _login_as(client, admin_email, "adminpass-123") + + target_email = f"ban-{uuid.uuid4().hex[:8]}@test.local" + target_id = await _seed_user(db_session, email=target_email, password="targetpass-12") + + r = await client.post( + f"/admin/users/{target_id}/toggle-active", + headers={"hx-request": "true"}, + follow_redirects=False, + ) + assert r.status_code == 200 + + is_active = ( + await db_session.execute( + text("SELECT is_active FROM users WHERE id = :u"), {"u": target_id} + ) + ).scalar_one() + assert is_active is False diff --git a/tests/integration/test_b2b_api.py b/tests/integration/test_b2b_api.py index 9d225aa..1059ae5 100644 --- a/tests/integration/test_b2b_api.py +++ b/tests/integration/test_b2b_api.py @@ -150,7 +150,7 @@ async def test_b2b_get_report_before_ready_returns_status( assert poll.status_code == 200 body = poll.json() assert body["document_id"] == document_id - assert body["status"] == "queued" + assert body["status"] in ("queued", "extracting") assert "stage" in body diff --git a/uv.lock b/uv.lock index d3157d6..94a2c82 100644 --- a/uv.lock +++ b/uv.lock @@ -584,6 +584,7 @@ api = [ { name = "asyncpg" }, { name = "email-validator" }, { name = "fastapi" }, + { name = "jinja2" }, { name = "minio" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-instrumentation-asgi" }, @@ -600,6 +601,7 @@ api = [ ] bot = [ { name = "aiogram" }, + { name = "redis" }, ] db = [ { name = "alembic" }, @@ -618,6 +620,7 @@ dev = [ { name = "asyncpg" }, { name = "email-validator" }, { name = "fastapi" }, + { name = "jinja2" }, { name = "minio" }, { name = "mypy" }, { name = "opentelemetry-exporter-otlp" }, @@ -714,6 +717,7 @@ api = [ { name = "asyncpg", specifier = ">=0.29" }, { name = "email-validator", specifier = ">=2.1" }, { name = "fastapi", specifier = ">=0.110" }, + { name = "jinja2", specifier = ">=3.1" }, { name = "minio", specifier = ">=7.2" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, { name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" }, @@ -728,7 +732,10 @@ api = [ { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, ] -bot = [{ name = "aiogram", specifier = ">=3.4" }] +bot = [ + { name = "aiogram", specifier = ">=3.4" }, + { name = "redis", specifier = ">=5.0" }, +] db = [ { name = "alembic", specifier = ">=1.13" }, { name = "asyncpg", specifier = ">=0.29" }, @@ -746,6 +753,7 @@ dev = [ { name = "asyncpg", specifier = ">=0.29" }, { name = "email-validator", specifier = ">=2.1" }, { name = "fastapi", specifier = ">=0.110" }, + { name = "jinja2", specifier = ">=3.1" }, { name = "minio", specifier = ">=7.2" }, { name = "mypy", specifier = ">=1.10" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, @@ -1221,6 +1229,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "librt" version = "0.15.0"