Fix CORS origins middleware and ebvs. Register route was fixed with
schema.
This commit is contained in:
parent
60e74a3137
commit
58bc2a0528
10 changed files with 232 additions and 23 deletions
57
deploy/nginx/templates/contract-check-http.conf.template
Normal file
57
deploy/nginx/templates/contract-check-http.conf.template
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
deploy/vps/docker-compose.override.example.yml
Normal file
14
deploy/vps/docker-compose.override.example.yml
Normal 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
69
deploy/vps/front.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
migrations/versions/0009_user_name.py
Normal file
30
migrations/versions/0009_user_name.py
Normal 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")
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue