diff --git a/deploy/nginx/templates/contract-check-http.conf.template b/deploy/nginx/templates/contract-check-http.conf.template new file mode 100644 index 0000000..943c682 --- /dev/null +++ b/deploy/nginx/templates/contract-check-http.conf.template @@ -0,0 +1,57 @@ +# HTTP-only edge for «Контракт-чек» behind a front proxy (VPS layout). +# TLS is terminated by the host nginx (deploy/vps/front.conf); this edge only +# forwards to the api service. Same envsubst vars as the TLS template: +# NGINX_SERVER_NAME (expanded by the nginx Docker entrypoint). + +upstream api { + server api:8000; +} + +log_format contract_check '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'rt=$request_time cid=$http_x_correlation_id'; + +access_log /var/log/nginx/access.log contract_check; + +client_max_body_size 50M; + +server { + listen 80; + server_name ${NGINX_SERVER_NAME}; + + location ~ ^/(healthz|readyz|metrics|api/|admin/|docs|openapi.json) { + proxy_pass http://api; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Correlation-Id $http_x_correlation_id; + + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 120s; + proxy_buffering off; + } + + location /webhook/ { + proxy_pass http://api/api/v1/webhooks/; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 60s; + } + + location / { + return 404; + } +} diff --git a/deploy/vps/docker-compose.override.example.yml b/deploy/vps/docker-compose.override.example.yml new file mode 100644 index 0000000..9a13908 --- /dev/null +++ b/deploy/vps/docker-compose.override.example.yml @@ -0,0 +1,14 @@ +# VPS layout: the host nginx (deploy/vps/front.conf) owns 80/443 and proxies +# to this stack's edge over plain HTTP on 127.0.0.1:8081. The in-compose +# certbot/TLS from the base file is not used; run certbot on the host. +# +# Usage on the VPS (Docker Compose >= 2.24 for !override): +# cp deploy/vps/docker-compose.override.example.yml docker-compose.override.yml +# docker compose --profile services --profile edge up -d nginx api worker-extract worker-analyze worker-prescreen worker-notify bot + +services: + nginx: + ports: + - "127.0.0.1:8081:80" + volumes: + - ./deploy/nginx/templates/contract-check-http.conf.template:/etc/nginx/templates/contract-check.conf.template:ro diff --git a/deploy/vps/front.conf b/deploy/vps/front.conf new file mode 100644 index 0000000..438eff5 --- /dev/null +++ b/deploy/vps/front.conf @@ -0,0 +1,69 @@ +# Front proxy config for the HOST (system) nginx on the VPS. +# This is the single entrypoint owning 80/443: +# - «Контракт-чек» (DealDocumentScreening) edge container -> 127.0.0.1:8081 +# - homelab-stack nginx container -> 127.0.0.1:8088 +# +# Install: +# sudo cp front.conf /etc/nginx/sites-available/apps +# sudo ln -s /etc/nginx/sites-available/apps /etc/nginx/sites-enabled/ +# sudo rm -f /etc/nginx/sites-enabled/default +# sudo nginx -t && sudo systemctl reload nginx +# +# TLS later: `sudo certbot --nginx -d your.domain` rewrites the 80-block and +# adds the 443 server automatically (install python3-certbot-nginx first). + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# Backends (containers must publish on 127.0.0.1, see deploy/vps/README or override) +upstream contract_check_edge { server 127.0.0.1:8081; } +upstream homelab_nginx { server 127.0.0.1:8088; } + +server { + listen 80 default_server; + listen [::]:80 default_server; + + # Let's Encrypt renewal (host certbot, webroot mode) — keep even after TLS is on + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # ── «Контракт-чек»: exact path set of its edge (contract-check-http.conf.template) + location ~ ^/(healthz|readyz|metrics|api/|admin/|docs|openapi\.json) { + proxy_pass http://contract_check_edge; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Correlation-Id $http_x_correlation_id; + client_max_body_size 50M; # large contract uploads + proxy_read_timeout 120s; + proxy_buffering off; + } + + location /webhook/ { + proxy_pass http://contract_check_edge; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + # ── everything else: homelab-stack (landing page, /homepage/, /dozzle/, ...) + location / { + proxy_pass http://homelab_nginx; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; # dozzle/kuma websockets + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 60s; + } +} diff --git a/migrations/versions/0009_user_name.py b/migrations/versions/0009_user_name.py new file mode 100644 index 0000000..4f2b5fb --- /dev/null +++ b/migrations/versions/0009_user_name.py @@ -0,0 +1,30 @@ +"""add users.name for webUI registration + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-08-21 + +Nullable display name collected at /auth/register. Telegram-only users keep +NULL; the column is optional everywhere it is read. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0009" +down_revision: str | None = "0008" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("name", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "name") diff --git a/src/contract_check/api/app.py b/src/contract_check/api/app.py index 3293f70..e032238 100644 --- a/src/contract_check/api/app.py +++ b/src/contract_check/api/app.py @@ -158,6 +158,7 @@ async def _ensure_default_admin(settings: Any, redis_client: Any) -> None: user = await create_email_user( session, email=settings.admin_default_email.lower().strip(), + name="Administrator", password_hash=hash_password(settings.admin_default_password), ) await session.execute( diff --git a/src/contract_check/api/deps.py b/src/contract_check/api/deps.py index ebfb839..0f13d5f 100644 --- a/src/contract_check/api/deps.py +++ b/src/contract_check/api/deps.py @@ -237,7 +237,7 @@ 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( text( - "SELECT id, telegram_id, email, password_hash, is_active, created_at, credits_left " + "SELECT id, telegram_id, email, name, password_hash, is_active, created_at, credits_left " "FROM users WHERE email = :e" ), {"e": email}, @@ -249,10 +249,11 @@ async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None: 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], + name=row[3], + password_hash=row[4], + is_active=row[5], + created_at=row[6], + credits_left=row[7], ) @@ -260,7 +261,7 @@ async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | """Fetch a user by UUID including web-auth columns.""" result = await session.execute( text( - "SELECT id, telegram_id, email, password_hash, is_active, created_at, credits_left " + "SELECT id, telegram_id, email, name, password_hash, is_active, created_at, credits_left " "FROM users WHERE id = :u" ), {"u": user_id}, @@ -272,22 +273,25 @@ async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> 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], + name=row[3], + password_hash=row[4], + is_active=row[5], + created_at=row[6], + credits_left=row[7], ) -async def create_email_user(session: AsyncSession, *, email: str, password_hash: str) -> User: +async def create_email_user( + session: AsyncSession, *, email: str, name: str | None, password_hash: str +) -> User: """Insert a new email/password user with 0 credits and return it.""" result = await session.execute( text( - "INSERT INTO users (email, password_hash, credits_left) " - "VALUES (:e, :p, 0) " - "RETURNING id, telegram_id, email, password_hash, is_active, created_at, credits_left" + "INSERT INTO users (email, name, password_hash, credits_left) " + "VALUES (:e, :n, :p, 0) " + "RETURNING id, telegram_id, email, name, password_hash, is_active, created_at, credits_left" ), - {"e": email, "p": password_hash}, + {"e": email, "n": name, "p": password_hash}, ) row = result.first() assert row is not None @@ -296,10 +300,11 @@ async def create_email_user(session: AsyncSession, *, email: str, password_hash: 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], + name=row[3], + password_hash=row[4], + is_active=row[5], + created_at=row[6], + credits_left=row[7], ) diff --git a/src/contract_check/api/middleware.py b/src/contract_check/api/middleware.py index f91c4d3..4ff9a75 100644 --- a/src/contract_check/api/middleware.py +++ b/src/contract_check/api/middleware.py @@ -6,8 +6,10 @@ import time from typing import Any from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from ..core.config import get_settings from ..core.logging import get_logger, new_correlation_id, set_correlation_id from ..core.metrics import http_request_duration from ..core.sentry import init_sentry @@ -16,6 +18,16 @@ log = get_logger(__name__) def add_middleware(app: FastAPI) -> None: + origins = get_settings().cors_origins + if origins: + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + @app.middleware("http") async def _middleware(request: Request, call_next: Any) -> Any: cid = request.headers.get("x-correlation-id") or new_correlation_id() diff --git a/src/contract_check/api/routes/auth.py b/src/contract_check/api/routes/auth.py index d8e284d..67d16de 100644 --- a/src/contract_check/api/routes/auth.py +++ b/src/contract_check/api/routes/auth.py @@ -107,6 +107,7 @@ class WebUserPublic(BaseModel): id: str email: str | None = None + name: str | None = None telegram_id: int | None = None credits_left: int is_active: bool @@ -117,6 +118,7 @@ class MeResponse(TokenIntrospectResponse): """Extends the introspection shape with web-user fields (additive).""" email: str | None = None + name: str | None = None credits_left: int = 0 is_active: bool = True created_at: dt.datetime | None = None @@ -218,6 +220,7 @@ async def me( type=claims.type, exp=claims.exp or 0, email=user.email, + name=user.name, credits_left=user.credits_left, is_active=bool(user.is_active), created_at=user.created_at, @@ -237,6 +240,7 @@ async def token_permissions_dummy() -> dict[str, Any]: class RegisterRequest(BaseModel): email: EmailStr + name: str = Field(..., min_length=1, max_length=128) password: str = Field(..., min_length=8, max_length=128) @@ -290,6 +294,7 @@ def _web_user_public(user: User) -> WebUserPublic: return WebUserPublic( id=str(user.id), email=user.email, + name=user.name, telegram_id=user.telegram_id, credits_left=user.credits_left, is_active=bool(user.is_active), @@ -342,7 +347,9 @@ async def register( raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email already registered") hashed = hash_password(body.password) - user = await create_email_user(session, email=email_normalized, password_hash=hashed) + user = await create_email_user( + session, email=email_normalized, name=body.name.strip(), password_hash=hashed + ) log.info("user_registered", user_id=str(user.id), email=email_normalized) return await _issue_pair(user, refresh_store) diff --git a/src/contract_check/core/config.py b/src/contract_check/core/config.py index 1f789cb..679ddf4 100644 --- a/src/contract_check/core/config.py +++ b/src/contract_check/core/config.py @@ -12,10 +12,10 @@ full env reference. from __future__ import annotations from functools import lru_cache -from typing import Literal +from typing import Annotated, Literal -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict Env = Literal["dev", "staging", "prod"] RefundPolicy = Literal["all", "infra_only"] @@ -94,6 +94,19 @@ class Settings(BaseSettings): api_metrics_port: int = 9100 b2b_default_rate_limit_rps: int = 3 + # Comma-separated browser origins allowed to call the API (CORS). + # Empty disables CORS entirely (no browser clients). + cors_origins: Annotated[list[str], NoDecode] = [] + + @field_validator("cors_origins", mode="before") + @classmethod + def _split_cors_origins(cls, v: object) -> object: + """Accept comma-separated strings (the documented .env format) or lists.""" + if isinstance(v, str): + value = v.split("#", 1)[0] # tolerate inline comments + return [origin.strip() for origin in value.split(",") if origin.strip()] + return v + # --- auth (JWT + Telegram identity verification) --- telegram_bot_token: str = Field( "", description="Telegram bot token; used to verify Login Widget / Mini App signatures" diff --git a/src/contract_check/core/db/models.py b/src/contract_check/core/db/models.py index e8ef78b..0ae60f8 100644 --- a/src/contract_check/core/db/models.py +++ b/src/contract_check/core/db/models.py @@ -46,6 +46,7 @@ class User(Base): ) telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True) email: Mapped[str | None] = mapped_column(Text, unique=True) + name: Mapped[str | None] = mapped_column(Text) password_hash: Mapped[str | None] = mapped_column(Text) password_reset_token_hash: Mapped[str | None] = mapped_column(Text) password_reset_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))