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(
session,
email=settings.admin_default_email.lower().strip(),
name="Administrator",
password_hash=hash_password(settings.admin_default_password),
)
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."""
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],
)

View file

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

View file

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

View file

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

View file

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