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(
|
||||
session,
|
||||
email=settings.admin_default_email.lower().strip(),
|
||||
name="Administrator",
|
||||
password_hash=hash_password(settings.admin_default_password),
|
||||
)
|
||||
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."""
|
||||
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],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue