Fix CORS origins middleware and ebvs. Register route was fixed with

schema.
This commit is contained in:
febux 2026-08-21 22:04:46 +03:00
parent 60e74a3137
commit 58bc2a0528
10 changed files with 232 additions and 23 deletions

View file

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

View file

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

69
deploy/vps/front.conf Normal file
View file

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

View file

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

View file

@ -158,6 +158,7 @@ async def _ensure_default_admin(settings: Any, redis_client: Any) -> None:
user = await create_email_user( user = await create_email_user(
session, session,
email=settings.admin_default_email.lower().strip(), email=settings.admin_default_email.lower().strip(),
name="Administrator",
password_hash=hash_password(settings.admin_default_password), password_hash=hash_password(settings.admin_default_password),
) )
await session.execute( await session.execute(

View file

@ -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.""" """Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found."""
result = await session.execute( result = await session.execute(
text( 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" "FROM users WHERE email = :e"
), ),
{"e": email}, {"e": email},
@ -249,10 +249,11 @@ async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None:
id=row[0], id=row[0],
telegram_id=row[1], telegram_id=row[1],
email=row[2], email=row[2],
password_hash=row[3], name=row[3],
is_active=row[4], password_hash=row[4],
created_at=row[5], is_active=row[5],
credits_left=row[6], 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.""" """Fetch a user by UUID including web-auth columns."""
result = await session.execute( result = await session.execute(
text( 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" "FROM users WHERE id = :u"
), ),
{"u": user_id}, {"u": user_id},
@ -272,22 +273,25 @@ async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User |
id=row[0], id=row[0],
telegram_id=row[1], telegram_id=row[1],
email=row[2], email=row[2],
password_hash=row[3], name=row[3],
is_active=row[4], password_hash=row[4],
created_at=row[5], is_active=row[5],
credits_left=row[6], 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.""" """Insert a new email/password user with 0 credits and return it."""
result = await session.execute( result = await session.execute(
text( text(
"INSERT INTO users (email, password_hash, credits_left) " "INSERT INTO users (email, name, password_hash, credits_left) "
"VALUES (:e, :p, 0) " "VALUES (:e, :n, :p, 0) "
"RETURNING id, telegram_id, email, password_hash, is_active, created_at, credits_left" "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() row = result.first()
assert row is not None assert row is not None
@ -296,10 +300,11 @@ async def create_email_user(session: AsyncSession, *, email: str, password_hash:
id=row[0], id=row[0],
telegram_id=row[1], telegram_id=row[1],
email=row[2], email=row[2],
password_hash=row[3], name=row[3],
is_active=row[4], password_hash=row[4],
created_at=row[5], is_active=row[5],
credits_left=row[6], created_at=row[6],
credits_left=row[7],
) )

View file

@ -6,8 +6,10 @@ import time
from typing import Any from typing import Any
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse 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.logging import get_logger, new_correlation_id, set_correlation_id
from ..core.metrics import http_request_duration from ..core.metrics import http_request_duration
from ..core.sentry import init_sentry from ..core.sentry import init_sentry
@ -16,6 +18,16 @@ log = get_logger(__name__)
def add_middleware(app: FastAPI) -> None: 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") @app.middleware("http")
async def _middleware(request: Request, call_next: Any) -> Any: async def _middleware(request: Request, call_next: Any) -> Any:
cid = request.headers.get("x-correlation-id") or new_correlation_id() cid = request.headers.get("x-correlation-id") or new_correlation_id()

View file

@ -107,6 +107,7 @@ class WebUserPublic(BaseModel):
id: str id: str
email: str | None = None email: str | None = None
name: str | None = None
telegram_id: int | None = None telegram_id: int | None = None
credits_left: int credits_left: int
is_active: bool is_active: bool
@ -117,6 +118,7 @@ class MeResponse(TokenIntrospectResponse):
"""Extends the introspection shape with web-user fields (additive).""" """Extends the introspection shape with web-user fields (additive)."""
email: str | None = None email: str | None = None
name: str | None = None
credits_left: int = 0 credits_left: int = 0
is_active: bool = True is_active: bool = True
created_at: dt.datetime | None = None created_at: dt.datetime | None = None
@ -218,6 +220,7 @@ async def me(
type=claims.type, type=claims.type,
exp=claims.exp or 0, exp=claims.exp or 0,
email=user.email, email=user.email,
name=user.name,
credits_left=user.credits_left, credits_left=user.credits_left,
is_active=bool(user.is_active), is_active=bool(user.is_active),
created_at=user.created_at, created_at=user.created_at,
@ -237,6 +240,7 @@ async def token_permissions_dummy() -> dict[str, Any]:
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
name: str = Field(..., min_length=1, max_length=128)
password: str = Field(..., min_length=8, 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( return WebUserPublic(
id=str(user.id), id=str(user.id),
email=user.email, email=user.email,
name=user.name,
telegram_id=user.telegram_id, telegram_id=user.telegram_id,
credits_left=user.credits_left, credits_left=user.credits_left,
is_active=bool(user.is_active), 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") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email already registered")
hashed = hash_password(body.password) 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) log.info("user_registered", user_id=str(user.id), email=email_normalized)
return await _issue_pair(user, refresh_store) return await _issue_pair(user, refresh_store)

View file

@ -12,10 +12,10 @@ full env reference.
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from typing import Literal from typing import Annotated, Literal
from pydantic import Field from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
Env = Literal["dev", "staging", "prod"] Env = Literal["dev", "staging", "prod"]
RefundPolicy = Literal["all", "infra_only"] RefundPolicy = Literal["all", "infra_only"]
@ -94,6 +94,19 @@ class Settings(BaseSettings):
api_metrics_port: int = 9100 api_metrics_port: int = 9100
b2b_default_rate_limit_rps: int = 3 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) --- # --- auth (JWT + Telegram identity verification) ---
telegram_bot_token: str = Field( telegram_bot_token: str = Field(
"", description="Telegram bot token; used to verify Login Widget / Mini App signatures" "", description="Telegram bot token; used to verify Login Widget / Mini App signatures"

View file

@ -46,6 +46,7 @@ class User(Base):
) )
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True) telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True)
email: Mapped[str | None] = mapped_column(Text, 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_hash: Mapped[str | None] = mapped_column(Text)
password_reset_token_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)) password_reset_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))