init
This commit is contained in:
4
src/presentation/decorators/__init__.py
Normal file
4
src/presentation/decorators/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.presentation.decorators.csrf import csrf_protect
|
||||
from src.presentation.decorators.rate_limit import rate_limit, _email_rl_key as email_rl_key
|
||||
from src.presentation.decorators.auth import require_access_token
|
||||
from src.presentation.decorators.cache import cached
|
||||
36
src/presentation/decorators/auth.py
Normal file
36
src/presentation/decorators/auth.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from src.application.contracts import IJwtService
|
||||
from src.application.domain.exceptions import UnauthorizedException
|
||||
from src.application.domain.dto import AccessTokenPayload, AuthContext
|
||||
from src.presentation.dependencies import get_jwt_service
|
||||
|
||||
|
||||
def _extract_access_token(request: Request) -> str | None:
|
||||
token = request.cookies.get('access_token')
|
||||
|
||||
if token:
|
||||
return token
|
||||
|
||||
auth = request.headers.get('Authorization')
|
||||
if auth:
|
||||
scheme, param = get_authorization_scheme_param(auth)
|
||||
if scheme.lower() == 'bearer' and param:
|
||||
return param
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def require_access_token(
|
||||
request: Request,
|
||||
jwt_service: IJwtService = Depends(get_jwt_service),
|
||||
) -> AuthContext:
|
||||
token = _extract_access_token(request)
|
||||
if not token:
|
||||
raise UnauthorizedException(message='Not authenticated')
|
||||
|
||||
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
|
||||
if payload.type != 'access':
|
||||
raise UnauthorizedException(message='Invalid token type')
|
||||
|
||||
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
||||
46
src/presentation/decorators/cache.py
Normal file
46
src/presentation/decorators/cache.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
from typing import Any, Awaitable, Callable
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from src.infrastructure.cache import KeydbCache
|
||||
from src.infrastructure.logger import get_logger
|
||||
from src.presentation.dependencies.cache import get_redis
|
||||
|
||||
|
||||
def cached(*, prefix: str) -> Callable:
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
logger = get_logger()
|
||||
|
||||
request = kwargs.get('request')
|
||||
if not isinstance(request, Request):
|
||||
for a in args:
|
||||
if isinstance(a, Request):
|
||||
request = a
|
||||
break
|
||||
|
||||
auth = kwargs.get('auth')
|
||||
user_id = getattr(auth, 'user_id', None) if auth else None
|
||||
|
||||
if request is None or user_id is None:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
cache_key = f'{prefix}:{user_id}'
|
||||
|
||||
try:
|
||||
redis = get_redis(request)
|
||||
cache = KeydbCache(redis)
|
||||
hit = await cache.get_user(user_id)
|
||||
if hit is not None:
|
||||
logger.debug(f'Cache hit key={cache_key}')
|
||||
return ORJSONResponse(status_code=200, content=hit)
|
||||
except Exception as e:
|
||||
logger.warning(f'Cache read failed key={cache_key} error={e}')
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
61
src/presentation/decorators/csrf.py
Normal file
61
src/presentation/decorators/csrf.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
import inspect
|
||||
from functools import wraps
|
||||
from typing import Callable, Awaitable, Any, Optional, Annotated
|
||||
from fastapi import Request, Header
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.security import CsrfService
|
||||
|
||||
|
||||
def csrf_protect(
|
||||
expected_subject_getter: Optional[Callable[[Request], Optional[str]]] = None,
|
||||
):
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
sig = inspect.signature(func)
|
||||
params = list(sig.parameters.values())
|
||||
|
||||
has_request = any(p.annotation is Request or p.name == 'request' for p in params)
|
||||
if not has_request:
|
||||
raise RuntimeError('csrf_protect requires endpoint to accept `request: Request`')
|
||||
|
||||
has_header = any(p.name == 'x_csrf_token' for p in params)
|
||||
if not has_header:
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
name='x_csrf_token',
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
default=None,
|
||||
annotation=Annotated[str | None, Header(alias='X-CSRF-Token')],
|
||||
)
|
||||
)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
request: Request | None = kwargs.get('request')
|
||||
if request is None:
|
||||
for arg in args:
|
||||
if isinstance(arg, Request):
|
||||
request = arg
|
||||
break
|
||||
|
||||
if request is None:
|
||||
raise ApplicationException(
|
||||
status_code=500,
|
||||
message='Request is required for CSRF protection',
|
||||
)
|
||||
|
||||
csrf = CsrfService()
|
||||
|
||||
cookie_token, _ = csrf.extract(request.cookies, request.headers)
|
||||
header_token = kwargs.get('x_csrf_token')
|
||||
|
||||
expected_subject = expected_subject_getter(request) if expected_subject_getter else None
|
||||
csrf.verify_pair(cookie_token, header_token, expected_subject)
|
||||
|
||||
kwargs.pop('x_csrf_token', None)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
wrapper.__signature__ = sig.replace(parameters=params)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
171
src/presentation/decorators/rate_limit.py
Normal file
171
src/presentation/decorators/rate_limit.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
import inspect
|
||||
import hashlib
|
||||
from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtime_checkable
|
||||
from fastapi import Request
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.logger import get_logger
|
||||
from src.presentation.dependencies import get_redis
|
||||
|
||||
|
||||
def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request:
|
||||
req = kwargs.get('request')
|
||||
if isinstance(req, Request):
|
||||
return req
|
||||
for a in args:
|
||||
if isinstance(a, Request):
|
||||
return a
|
||||
raise RuntimeError('rate_limit decorator requires fastapi.Request argument')
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
xff = request.headers.get('x-forwarded-for')
|
||||
if xff:
|
||||
return xff.split(',')[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return 'unknown'
|
||||
|
||||
|
||||
_LUA_INCR_EXPIRE_TTL = '''
|
||||
local key = KEYS[1]
|
||||
local window = tonumber(ARGV[1])
|
||||
|
||||
local current = redis.call('INCR', key)
|
||||
if current == 1 then
|
||||
redis.call('EXPIRE', key, window)
|
||||
end
|
||||
|
||||
local ttl = redis.call('TTL', key)
|
||||
return { current, ttl }
|
||||
'''
|
||||
|
||||
|
||||
Scope = Literal['ip', 'device', 'user', 'key']
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KeyBuilder1(Protocol):
|
||||
def __call__(self, request: Request) -> str: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KeyBuilder3(Protocol):
|
||||
def __call__(self, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: ...
|
||||
|
||||
|
||||
KeyBuilder = KeyBuilder1 | KeyBuilder3
|
||||
|
||||
|
||||
def _call_key_builder(builder: KeyBuilder, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
||||
try:
|
||||
sig = inspect.signature(builder)
|
||||
if len(sig.parameters) >= 3:
|
||||
return builder(request, args, kwargs)
|
||||
return builder(request)
|
||||
except Exception as e:
|
||||
try:
|
||||
return builder(request, args, kwargs)
|
||||
except Exception:
|
||||
raise e
|
||||
|
||||
def _email_rl_key(request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
||||
|
||||
body = kwargs.get('body')
|
||||
if body is None and args:
|
||||
for a in args:
|
||||
if hasattr(a, 'email'):
|
||||
body = a
|
||||
break
|
||||
|
||||
email = (getattr(body, 'email', '') or '').strip().lower()
|
||||
if not email:
|
||||
email = _client_ip(request)
|
||||
|
||||
digest = hashlib.sha256(email.encode('utf-8')).hexdigest()[:24]
|
||||
return f'email:{digest}'
|
||||
|
||||
def rate_limit(
|
||||
*,
|
||||
limit: int,
|
||||
window_seconds: int,
|
||||
scope: Scope = 'ip',
|
||||
key_prefix: str = 'rl',
|
||||
key_builder: Optional[KeyBuilder] = None,
|
||||
fail_open: bool = True,
|
||||
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
|
||||
|
||||
if limit <= 0:
|
||||
raise ValueError('rate_limit: limit must be > 0')
|
||||
if window_seconds <= 0:
|
||||
raise ValueError('rate_limit: window_seconds must be > 0')
|
||||
if scope == 'key' and not key_builder:
|
||||
raise ValueError('rate_limit: scope="key" requires key_builder')
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any):
|
||||
request = _find_request(args, kwargs)
|
||||
logger: ILogger = get_logger()
|
||||
|
||||
if scope == 'ip':
|
||||
ident = _client_ip(request)
|
||||
elif scope == 'device':
|
||||
ident = request.cookies.get('device_id') or _client_ip(request)
|
||||
elif scope == 'user':
|
||||
user = getattr(request.state, 'user', None)
|
||||
user_id = getattr(user, 'id', None) if user else None
|
||||
ident = str(user_id) if user_id else _client_ip(request)
|
||||
else:
|
||||
try:
|
||||
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.error(f'RateLimit key_builder failed error={str(e)}')
|
||||
raise ApplicationException(500, 'Rate limiter key_builder failed')
|
||||
|
||||
route = request.url.path
|
||||
method = request.method
|
||||
redis_key = f'{key_prefix}:{scope}:{method}:{route}:{ident}'
|
||||
|
||||
logger.debug(f'RateLimit check key={redis_key} limit={limit} window={window_seconds}')
|
||||
|
||||
try:
|
||||
redis: Redis = get_redis(request)
|
||||
|
||||
result = await redis.eval(
|
||||
_LUA_INCR_EXPIRE_TTL,
|
||||
1,
|
||||
redis_key,
|
||||
str(window_seconds),
|
||||
)
|
||||
|
||||
count = int(result[0])
|
||||
ttl_raw = int(result[1]) if result and len(result) > 1 else window_seconds
|
||||
ttl = window_seconds if ttl_raw < 0 else ttl_raw
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'RateLimit redis failure key={redis_key} error={str(e)}')
|
||||
|
||||
if fail_open:
|
||||
logger.warning(f'RateLimit fail-open activated key={redis_key}')
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
raise ApplicationException(503, 'Rate limiter unavailable')
|
||||
|
||||
if count > limit:
|
||||
retry_after = max(ttl, 0)
|
||||
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
|
||||
raise ApplicationException(
|
||||
status_code=429,
|
||||
message='Too Many Requests',
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
|
||||
logger.debug(f'RateLimit passed key={redis_key} count={count}')
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
7
src/presentation/dependencies/__init__.py
Normal file
7
src/presentation/dependencies/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_create_purchase_request_command,
|
||||
get_get_purchase_request_command,
|
||||
get_list_purchase_requests_command,
|
||||
)
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
from src.presentation.dependencies.cache import get_redis, get_cache
|
||||
12
src/presentation/dependencies/cache.py
Normal file
12
src/presentation/dependencies/cache.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import Depends, Request
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ICache
|
||||
from src.infrastructure.cache import KeydbCache
|
||||
|
||||
|
||||
def get_redis(request: Request) -> Redis:
|
||||
return request.app.state.redis
|
||||
|
||||
|
||||
def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache:
|
||||
return KeydbCache(redis_client)
|
||||
31
src/presentation/dependencies/commands.py
Normal file
31
src/presentation/dependencies/commands.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import Depends
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands import (
|
||||
CreatePurchaseRequestCommand,
|
||||
GetPurchaseRequestCommand,
|
||||
ListPurchaseRequestsCommand,
|
||||
)
|
||||
from src.application.contracts import ILogger
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.dependencies.unit_of_work import get_unit_of_work
|
||||
|
||||
|
||||
def get_create_purchase_request_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
) -> CreatePurchaseRequestCommand:
|
||||
return CreatePurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger)
|
||||
|
||||
|
||||
def get_list_purchase_requests_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
) -> ListPurchaseRequestsCommand:
|
||||
return ListPurchaseRequestsCommand(unit_of_work=unit_of_work, logger=logger)
|
||||
|
||||
|
||||
def get_get_purchase_request_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
) -> GetPurchaseRequestCommand:
|
||||
return GetPurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger)
|
||||
7
src/presentation/dependencies/logger.py
Normal file
7
src/presentation/dependencies/logger.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from functools import lru_cache
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.logger import logger
|
||||
|
||||
@lru_cache
|
||||
def get_logger() -> ILogger:
|
||||
return logger
|
||||
8
src/presentation/dependencies/queue_messanger.py
Normal file
8
src/presentation/dependencies/queue_messanger.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from functools import lru_cache
|
||||
from src.application.contracts import IQueueMessanger
|
||||
from src.infrastructure.messanger import RabbitClient
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_rabbit() -> IQueueMessanger:
|
||||
return RabbitClient()
|
||||
32
src/presentation/dependencies/s3_storage.py
Normal file
32
src/presentation/dependencies/s3_storage.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.contracts import IS3
|
||||
from src.application.domain.exceptions import ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.storage.s3_service import S3Service
|
||||
|
||||
_s3singleton: IS3 | None = None
|
||||
|
||||
|
||||
def get_s3_storage() -> IS3:
|
||||
global _s3singleton
|
||||
if _s3singleton is not None:
|
||||
return _s3singleton
|
||||
if not settings.S3_BUCKET.strip():
|
||||
raise ServiceUnavailableException(message='S3 is not configured')
|
||||
endpoint = settings.S3_ENDPOINT_URL.strip()
|
||||
pub = settings.S3_PUBLIC_BASE_URL.strip()
|
||||
if not pub and not endpoint:
|
||||
raise ServiceUnavailableException(message='Set S3_ENDPOINT_URL (or S3_PUBLIC_BASE_URL for a custom CDN base)')
|
||||
ak = settings.S3_ACCESS_KEY_ID.strip() or None
|
||||
sk = settings.S3_SECRET_ACCESS_KEY.strip() or None
|
||||
_s3singleton = S3Service(
|
||||
bucket=settings.S3_BUCKET.strip(),
|
||||
region=settings.S3_REGION.strip() or 'us-east-1',
|
||||
access_key_id=ak,
|
||||
secret_access_key=sk,
|
||||
public_base_url=pub if pub else None,
|
||||
endpoint_url=endpoint if endpoint else None,
|
||||
use_reg_ru_website_public_host=settings.S3_REGRU_PUBLIC_WEBSITE_HOST,
|
||||
)
|
||||
return _s3singleton
|
||||
25
src/presentation/dependencies/security.py
Normal file
25
src/presentation/dependencies/security.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from functools import lru_cache
|
||||
from fastapi import Depends
|
||||
from src.application.contracts import IJwtService, ILogger, IHashService
|
||||
from src.infrastructure.security import JwtService, HashService
|
||||
from src.infrastructure.vault import JwtKeyStore
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _hash_service(logger: ILogger) -> IHashService:
|
||||
return HashService(logger=logger)
|
||||
|
||||
|
||||
def get_hash_service(logger: ILogger = Depends(get_logger)) -> IHashService:
|
||||
return _hash_service(logger)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _jwt_service(logger: ILogger) -> IJwtService:
|
||||
key_store = JwtKeyStore.get_instance()
|
||||
return JwtService(logger=logger, key_store=key_store)
|
||||
|
||||
|
||||
def get_jwt_service(logger: ILogger = Depends(get_logger)) -> IJwtService:
|
||||
return _jwt_service(logger)
|
||||
10
src/presentation/dependencies/unit_of_work.py
Normal file
10
src/presentation/dependencies/unit_of_work.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import Depends
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.database import UnitOfWork
|
||||
from src.infrastructure.database.context import async_session_maker
|
||||
from src.infrastructure.logger import get_logger
|
||||
|
||||
|
||||
def get_unit_of_work(logger: ILogger = Depends(get_logger)) -> IUnitOfWork:
|
||||
return UnitOfWork(session_factory=async_session_maker, logger=logger)
|
||||
4
src/presentation/handlers/__init__.py
Normal file
4
src/presentation/handlers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.presentation.handlers.unhandled_handler import unhandled_exception_handler
|
||||
from src.presentation.handlers.application_handler import application_exception_handler
|
||||
from src.presentation.handlers.http_exception_handler import http_exception_handler
|
||||
from src.presentation.handlers.validation_handler import validation_exception_handler
|
||||
17
src/presentation/handlers/application_handler.py
Normal file
17
src/presentation/handlers/application_handler.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from fastapi import Request
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
|
||||
|
||||
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse:
|
||||
detail = exc.message
|
||||
if 500 <= exc.status_code:
|
||||
detail = "Internal Server Error"
|
||||
|
||||
return ORJSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": detail},
|
||||
headers=dict(exc.headers) if exc.headers else None,
|
||||
)
|
||||
|
||||
|
||||
11
src/presentation/handlers/http_exception_handler.py
Normal file
11
src/presentation/handlers/http_exception_handler.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
|
||||
async def http_exception_handler(_request: Request, exc: HTTPException) -> ORJSONResponse:
|
||||
return ORJSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={'detail': exc.detail},
|
||||
headers=dict(exc.headers) if exc.headers else None,
|
||||
)
|
||||
12
src/presentation/handlers/unhandled_handler.py
Normal file
12
src/presentation/handlers/unhandled_handler.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from fastapi import Request
|
||||
from starlette import status
|
||||
from src.infrastructure.logger import logger
|
||||
|
||||
|
||||
async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJSONResponse:
|
||||
logger.exception(f'Unhandled exception: {type(exc).__name__}')
|
||||
return ORJSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'detail': 'Internal Server Error'},
|
||||
)
|
||||
10
src/presentation/handlers/validation_handler.py
Normal file
10
src/presentation/handlers/validation_handler.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
|
||||
async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> ORJSONResponse:
|
||||
return ORJSONResponse(
|
||||
status_code=422,
|
||||
content={'detail': exc.errors()},
|
||||
)
|
||||
2
src/presentation/middleware/__init__.py
Normal file
2
src/presentation/middleware/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.presentation.middleware.trace_id import TraceIDMiddleware
|
||||
from src.presentation.middleware.security_headers import SecurityHeadersMiddleware
|
||||
51
src/presentation/middleware/security_headers.py
Normal file
51
src/presentation/middleware/security_headers.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
*,
|
||||
hsts: bool = True,
|
||||
hsts_max_age: int = 31536000, # 1 год
|
||||
hsts_include_subdomains: bool = True,
|
||||
hsts_preload: bool = False,
|
||||
frame_options: str = 'DENY', # или 'SAMEORIGIN'
|
||||
referrer_policy: str = 'strict-origin-when-cross-origin',
|
||||
content_security_policy: str | None = None,
|
||||
):
|
||||
super().__init__(app)
|
||||
self.hsts = hsts
|
||||
self.hsts_max_age = hsts_max_age
|
||||
self.hsts_include_subdomains = hsts_include_subdomains
|
||||
self.hsts_preload = hsts_preload
|
||||
self.frame_options = frame_options
|
||||
self.referrer_policy = referrer_policy
|
||||
self.csp = content_security_policy
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response: Response = await call_next(request)
|
||||
|
||||
if request.url.path in ('/docs', '/redoc', '/openapi.json'):
|
||||
return response
|
||||
|
||||
if self.hsts and request.url.scheme == 'https':
|
||||
hsts = f'max-age={self.hsts_max_age}'
|
||||
if self.hsts_include_subdomains:
|
||||
hsts += '; includeSubDomains'
|
||||
if self.hsts_preload:
|
||||
hsts += '; preload'
|
||||
response.headers['Strict-Transport-Security'] = hsts
|
||||
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
|
||||
response.headers['X-Frame-Options'] = self.frame_options
|
||||
|
||||
response.headers['Referrer-Policy'] = self.referrer_policy
|
||||
|
||||
if self.csp:
|
||||
response.headers['Content-Security-Policy'] = self.csp
|
||||
|
||||
return response
|
||||
135
src/presentation/middleware/trace_id.py
Normal file
135
src/presentation/middleware/trace_id.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from contextvars import Token
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
from ulid import ULID
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
|
||||
|
||||
class TraceIDMiddleware:
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
logger: ILogger,
|
||||
response_header_name: str = "X-Trace-ID",
|
||||
attach_response_header: bool = True,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.logger = logger
|
||||
self.response_header_name = response_header_name
|
||||
self.attach_response_header = attach_response_header
|
||||
|
||||
def _is_excluded(self, path: str) -> bool:
|
||||
return any(path == p or path.startswith(p.rstrip("/") + "/") for p in settings.EXCLUDED_PATHS)
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
request = Request(scope)
|
||||
|
||||
if self._is_excluded(request.url.path):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
trace_id = request.headers.get("X-Trace-ID") or request.headers.get("X-Request-ID")
|
||||
if not trace_id:
|
||||
trace_id = str(ULID())
|
||||
|
||||
request.state.trace_id = trace_id
|
||||
|
||||
token: Token = trace_id_var.set(trace_id)
|
||||
|
||||
self.logger.debug(f"Request started: {request.method} {request.url} - TraceID: {trace_id}")
|
||||
|
||||
status_code_holder: dict[str, Optional[int]] = {"status": None}
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
status_code_holder["status"] = int(message["status"])
|
||||
|
||||
if self.attach_response_header:
|
||||
headers = list(message.get("headers", []))
|
||||
headers.append((self.response_header_name.lower().encode(), trace_id.encode()))
|
||||
message["headers"] = headers
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
finally:
|
||||
status = status_code_holder["status"]
|
||||
status_part = f"{status}" if status is not None else "unknown"
|
||||
self.logger.debug(
|
||||
f"Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}"
|
||||
)
|
||||
trace_id_var.reset(token)
|
||||
|
||||
|
||||
# from __future__ import annotations
|
||||
# from typing import Optional
|
||||
# from starlette.requests import Request
|
||||
# from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
# from ulid import ULID
|
||||
# from src.application.contracts import ILogger
|
||||
# from src.infrastructure.config.settings import settings
|
||||
#
|
||||
#
|
||||
# class TraceIDMiddleware:
|
||||
# def __init__(
|
||||
# self,
|
||||
# app: ASGIApp,
|
||||
# logger: ILogger,
|
||||
# response_header_name: str = 'X-Trace-ID',
|
||||
# attach_response_header: bool = True,
|
||||
# ) -> None:
|
||||
# self.app = app
|
||||
# self.logger = logger
|
||||
# self.response_header_name = response_header_name
|
||||
# self.attach_response_header = attach_response_header
|
||||
#
|
||||
# def _is_excluded(self, path: str) -> bool:
|
||||
# return any(path == p or path.startswith(p.rstrip('/') + '/') for p in settings.EXCLUDED_PATHS)
|
||||
#
|
||||
# async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
# if scope['type'] != 'http':
|
||||
# await self.app(scope, receive, send)
|
||||
# return
|
||||
#
|
||||
# request = Request(scope)
|
||||
#
|
||||
# if self._is_excluded(request.url.path):
|
||||
# await self.app(scope, receive, send)
|
||||
# return
|
||||
#
|
||||
# trace_id = request.headers.get('X-Trace-ID') or request.headers.get('X-Request-ID')
|
||||
# if not trace_id:
|
||||
# trace_id = str(ULID())
|
||||
#
|
||||
# request.state.trace_id = trace_id
|
||||
# self.logger.set_trace_id(trace_id)
|
||||
#
|
||||
# self.logger.debug(f'Request started: {request.method} {request.url} - TraceID: {trace_id}')
|
||||
#
|
||||
# status_code_holder: dict[str, Optional[int]] = {'status': None}
|
||||
#
|
||||
# async def send_wrapper(message: Message) -> None:
|
||||
# if message['type'] == 'http.response.start':
|
||||
# status_code_holder['status'] = int(message['status'])
|
||||
#
|
||||
# if self.attach_response_header:
|
||||
# headers = list(message.get('headers', []))
|
||||
# headers.append((self.response_header_name.lower().encode(), trace_id.encode()))
|
||||
# message['headers'] = headers
|
||||
# await send(message)
|
||||
#
|
||||
# try:
|
||||
# await self.app(scope, receive, send_wrapper)
|
||||
# finally:
|
||||
# status = status_code_holder['status']
|
||||
# status_part = f'{status}' if status is not None else 'unknown'
|
||||
# self.logger.debug(f'Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}')
|
||||
# self.logger.clear_trace_id()
|
||||
3
src/presentation/routing/__init__.py
Normal file
3
src/presentation/routing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from src.presentation.routing.purchase_requests import purchase_requests_router
|
||||
|
||||
__all__ = ['purchase_requests_router']
|
||||
55
src/presentation/routing/account.py
Normal file
55
src/presentation/routing/account.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
from src.application.commands.get_me import GetMeCommand
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators import require_access_token
|
||||
from src.presentation.dependencies.commands import get_get_me_command
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.decorators import csrf_protect
|
||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
from src.presentation.serializers import me_user_public
|
||||
|
||||
account_router = APIRouter()
|
||||
|
||||
|
||||
@account_router.get(
|
||||
path='/',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=MeUserPublicResponse,
|
||||
summary='Текущий пользователь',
|
||||
description=(
|
||||
'Возвращает профиль, в том числе поле avatar_link — публичный HTTPS URL '
|
||||
'текущего аватара (WebP в объектном хранилище), либо null, если аватар не задан. '
|
||||
'Изображение по этому URL отдаёт не Users API, а хранилище; отдельного эндпоинта загрузки байтов аватара нет. '
|
||||
'Загрузить или сменить аватар можно через PATCH /me/settings/avatar. '
|
||||
'Защита CSRF: cookie csrf_token и заголовок X-CSRF-Token с тем же значением.'
|
||||
),
|
||||
responses={
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
'description': 'Ошибка проверки CSRF (нет пары cookie/заголовка, несовпадение или просрочен токен).',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Ошибка валидации входных данных (например, заголовков).',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
},
|
||||
)
|
||||
@csrf_protect()
|
||||
async def me(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetMeCommand = Depends(get_get_me_command),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> MeUserPublicResponse:
|
||||
user = await command(user_id=auth.user_id)
|
||||
logger.info(f'Get user: {user.id}')
|
||||
return me_user_public(user)
|
||||
384
src/presentation/routing/account_settings.py
Normal file
384
src/presentation/routing/account_settings.py
Normal file
@@ -0,0 +1,384 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
from src.application.commands import (
|
||||
SetPhoneCommand,
|
||||
SetAvatarCommand,
|
||||
DeleteAvatarCommand,
|
||||
ChangePasswordStartCommand,
|
||||
ChangePasswordCompleteCommand,
|
||||
ForgotPasswordStartCommand,
|
||||
ForgotPasswordCompleteCommand,
|
||||
)
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators import require_access_token, csrf_protect, rate_limit, email_rl_key
|
||||
from src.presentation.dependencies import (
|
||||
get_delete_avatar_command,
|
||||
get_set_avatar_command,
|
||||
get_set_phone_command,
|
||||
get_change_password_start_command,
|
||||
get_change_password_complete_command,
|
||||
get_forgot_password_start_command,
|
||||
get_forgot_password_complete_command,
|
||||
)
|
||||
from src.presentation.schemas import (
|
||||
SetAvatarRequest,
|
||||
SetPhoneRequest,
|
||||
ChangePasswordConfirmRequest,
|
||||
ForgotPasswordStartRequest,
|
||||
ForgotPasswordCompleteRequest,
|
||||
)
|
||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse
|
||||
from src.presentation.serializers import me_user_public
|
||||
|
||||
|
||||
account_settings_router = APIRouter(prefix='/settings')
|
||||
|
||||
|
||||
_SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
'description': 'Битый или неподдерживаемый формат изображения, либо Base64 ошибочный.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
'description': 'Учётная запись не найдена.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Тело запроса не соответствует схеме (например, неверный Base64 или превышен размер).',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||||
'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||||
'description': 'S3 не сконфигурирован, ошибка записи в хранилище или временная недоступность сервиса.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
'description': 'Неверный или просроченный код, пароли не совпадают или совпадают с текущим.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
'description': 'Учётная запись не найдена или у пользователя нет email.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Тело запроса не соответствует схеме (код, длина пароля).',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
status.HTTP_429_TOO_MANY_REQUESTS: {
|
||||
'description': 'Код уже отправлен или слишком частые запросы.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||||
'description': 'Внутренняя ошибка сервера.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||||
'description': 'Временная ошибка отправки кода или сохранения в кеш.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_FORGOT_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
'description': 'Неверный или просроченный код, пароли не совпадают.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Тело запроса не соответствует схеме.',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
status.HTTP_429_TOO_MANY_REQUESTS: {
|
||||
'description': 'Код уже отправлен или слишком частые запросы.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||||
'description': 'Внутренняя ошибка сервера.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||||
'description': 'Временная ошибка отправки кода или сохранения в кеш.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
'description': 'Учётная запись не найдена.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||||
'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||||
'description': 'S3 не сконфигурирован или временная недоступность удаления объекта.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
@csrf_protect()
|
||||
async def set_phone(
|
||||
request: Request,
|
||||
body: SetPhoneRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: SetPhoneCommand = Depends(get_set_phone_command),
|
||||
):
|
||||
user = await command(user_id=auth.user_id, phone=body.phone)
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'phone': user.phone})
|
||||
|
||||
|
||||
@account_settings_router.patch(
|
||||
path='/avatar',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=SetAvatarPublicResponse,
|
||||
summary='Обновить аватар',
|
||||
description=(
|
||||
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. '
|
||||
'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).'
|
||||
),
|
||||
response_description=(
|
||||
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
|
||||
),
|
||||
responses=_SET_AVATAR_ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def set_avatar(
|
||||
request: Request,
|
||||
body: SetAvatarRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: SetAvatarCommand = Depends(get_set_avatar_command),
|
||||
) -> SetAvatarPublicResponse:
|
||||
user, webp_size = await command(user_id=auth.user_id, image_bytes=body.decoded_bytes)
|
||||
pub = me_user_public(user)
|
||||
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
|
||||
|
||||
|
||||
@account_settings_router.delete(
|
||||
path='/avatar',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=MeUserPublicResponse,
|
||||
summary='Удалить аватар',
|
||||
description=(
|
||||
'Удаляет файл в объектном хранилище при известном URL и обнуляет avatar_link в профиле.'
|
||||
),
|
||||
responses=_DELETE_AVATAR_ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def delete_avatar(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: DeleteAvatarCommand = Depends(get_delete_avatar_command),
|
||||
) -> MeUserPublicResponse:
|
||||
user = await command(user_id=auth.user_id)
|
||||
return me_user_public(user)
|
||||
|
||||
|
||||
@account_settings_router.post(
|
||||
path='/password/start',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary='Запросить код для смены пароля',
|
||||
description='Отправляет шестизначный код на email текущего пользователя. Повторный запрос возможен после истечения TTL.',
|
||||
responses=_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def change_password_start(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
|
||||
):
|
||||
result = await command(user_id=auth.user_id)
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||||
|
||||
|
||||
@account_settings_router.post(
|
||||
path='/password/complete',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary='Подтвердить смену пароля',
|
||||
description=(
|
||||
'Принимает код из письма, новый пароль и его подтверждение. '
|
||||
'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).'
|
||||
),
|
||||
responses=_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def change_password_complete(
|
||||
request: Request,
|
||||
body: ChangePasswordConfirmRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
|
||||
):
|
||||
result = await command(
|
||||
user_id=auth.user_id,
|
||||
code=body.code,
|
||||
new_password=body.new_password,
|
||||
confirm_password=body.confirm_password,
|
||||
)
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||||
|
||||
|
||||
@account_settings_router.post(
|
||||
path='/password/forgot/start',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary='Запросить код для восстановления пароля',
|
||||
description=(
|
||||
'Принимает email. Если учётная запись существует, отправляет шестизначный код. '
|
||||
'Ответ всегда успешный при валидном email (без раскрытия наличия аккаунта).'
|
||||
),
|
||||
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
@rate_limit(limit=5, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
|
||||
async def forgot_password_start(
|
||||
request: Request,
|
||||
body: ForgotPasswordStartRequest,
|
||||
command: ForgotPasswordStartCommand = Depends(get_forgot_password_start_command),
|
||||
):
|
||||
result = await command(email=body.email)
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||||
|
||||
|
||||
@account_settings_router.post(
|
||||
path='/password/forgot/complete',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary='Установить новый пароль по коду из письма',
|
||||
description=(
|
||||
'Принимает email, код из письма, новый пароль и подтверждение. '
|
||||
'Пароль: минимум 12 символов, строчная и заглавная буква, цифра, спецсимвол, без пробелов.'
|
||||
),
|
||||
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
@rate_limit(limit=10, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
|
||||
async def forgot_password_complete(
|
||||
request: Request,
|
||||
body: ForgotPasswordCompleteRequest,
|
||||
command: ForgotPasswordCompleteCommand = Depends(get_forgot_password_complete_command),
|
||||
):
|
||||
result = await command(
|
||||
email=body.email,
|
||||
code=body.code,
|
||||
new_password=body.new_password,
|
||||
confirm_password=body.confirm_password,
|
||||
)
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||||
|
||||
|
||||
#
|
||||
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def encrypted_mnemonic_start(
|
||||
# request: Request,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: SetEncryptedMnemonicStartCommand = Depends(get_set_encrypted_mnemonic_start_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/encrypted-mnemonic/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def encrypted_mnemonic_complete(
|
||||
# request: Request,
|
||||
# body: EncryptedMnemonicConfirmRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: SetEncryptedMnemonicCompleteCommand = Depends(get_set_encrypted_mnemonic_complete_command),
|
||||
# ):
|
||||
# user = await command(
|
||||
# user_id=auth.user_id,
|
||||
# code=body.code,
|
||||
# encrypted_mnemonic=body.encrypted_mnemonic,
|
||||
# )
|
||||
# return {'encrypted_mnemonic': user.encrypted_mnemonic}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/email/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_email_start(
|
||||
# request: Request,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangeEmailStartCommand = Depends(get_change_email_start_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/email/confirm-old', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_email_confirm_old(
|
||||
# request: Request,
|
||||
# body: ChangeEmailConfirmOldRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangeEmailConfirmOldCommand = Depends(get_change_email_confirm_old_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id, code=body.code, new_email=body.new_email)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/email/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_email_complete(
|
||||
# request: Request,
|
||||
# body: ChangeEmailCompleteRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangeEmailCompleteCommand = Depends(get_change_email_complete_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id, code=body.code)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def bank_details_start(
|
||||
# request: Request,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: UpdateBankDetailsStartCommand = Depends(get_update_bank_details_start_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/bank/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def bank_details_complete(
|
||||
# request: Request,
|
||||
# body: BankConfirmRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: UpdateBankDetailsCompleteCommand = Depends(get_update_bank_details_complete_command),
|
||||
# ):
|
||||
# user = await command(
|
||||
# user_id=auth.user_id,
|
||||
# code=body.code,
|
||||
# passport_data=body.passport_data,
|
||||
# inn=body.inn,
|
||||
# erc20=body.erc20,
|
||||
# )
|
||||
# return ORJSONResponse(
|
||||
# status_code=status.HTTP_200_OK,
|
||||
# content={
|
||||
# 'passport_data': user.passport_data,
|
||||
# 'inn': user.inn,
|
||||
# 'erc20': user.erc20,
|
||||
# },
|
||||
# )
|
||||
7
src/presentation/routing/deals.py
Normal file
7
src/presentation/routing/deals.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
deals_router = APIRouter(prefix='/deals')
|
||||
|
||||
@deals_router.get(path='')
|
||||
async def deals():
|
||||
pass
|
||||
11
src/presentation/routing/devices.py
Normal file
11
src/presentation/routing/devices.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
|
||||
devices_router = APIRouter(prefix='/devices')
|
||||
|
||||
@devices_router.get(path='/', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def devices(
|
||||
request: Request,
|
||||
):
|
||||
pass
|
||||
68
src/presentation/routing/purchase_requests.py
Normal file
68
src/presentation/routing/purchase_requests.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from src.application.commands import (
|
||||
CreatePurchaseRequestCommand,
|
||||
GetPurchaseRequestCommand,
|
||||
ListPurchaseRequestsCommand,
|
||||
)
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators.auth import require_access_token
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_create_purchase_request_command,
|
||||
get_get_purchase_request_command,
|
||||
get_list_purchase_requests_command,
|
||||
)
|
||||
from src.presentation.schemas.purchase_request import (
|
||||
CreatePurchaseRequestBody,
|
||||
PurchaseRequestListResponse,
|
||||
PurchaseRequestResponse,
|
||||
)
|
||||
from src.presentation.serializers.purchase_request import purchase_request_to_response
|
||||
|
||||
purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchase-requests'])
|
||||
|
||||
|
||||
@purchase_requests_router.post('', response_model=PurchaseRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_purchase_request(
|
||||
body: CreatePurchaseRequestBody,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: CreatePurchaseRequestCommand = Depends(get_create_purchase_request_command),
|
||||
):
|
||||
item = await command(
|
||||
auth.user_id,
|
||||
usdt_amount=body.usdt_amount,
|
||||
comment=body.comment,
|
||||
target_wallet_chain=body.target_wallet_chain,
|
||||
target_wallet_address=body.target_wallet_address,
|
||||
)
|
||||
return purchase_request_to_response(item)
|
||||
|
||||
|
||||
@purchase_requests_router.get('', response_model=PurchaseRequestListResponse)
|
||||
async def list_purchase_requests(
|
||||
status_filter: str | None = Query(default=None, alias='status'),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ListPurchaseRequestsCommand = Depends(get_list_purchase_requests_command),
|
||||
):
|
||||
items, total = await command(
|
||||
auth.user_id,
|
||||
status=status_filter,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return PurchaseRequestListResponse(
|
||||
items=[purchase_request_to_response(x) for x in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse)
|
||||
async def get_purchase_request(
|
||||
request_id: str,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command),
|
||||
):
|
||||
item = await command(auth.user_id, request_id)
|
||||
return purchase_request_to_response(item)
|
||||
10
src/presentation/schemas/__init__.py
Normal file
10
src/presentation/schemas/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from src.presentation.schemas.avatar import SetAvatarRequest
|
||||
from src.presentation.schemas.phone import SetPhoneRequest
|
||||
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
|
||||
from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest
|
||||
from src.presentation.schemas.password import (
|
||||
ChangePasswordConfirmRequest,
|
||||
ForgotPasswordStartRequest,
|
||||
ForgotPasswordCompleteRequest,
|
||||
)
|
||||
from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest
|
||||
19
src/presentation/schemas/api_errors.py
Normal file
19
src/presentation/schemas/api_errors.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ApiErrorPayload(BaseModel):
|
||||
detail: str = Field(description='Текстовое описание ошибки для клиента')
|
||||
|
||||
|
||||
class ValidationErrorDetailItem(BaseModel):
|
||||
loc: list[str | int] = Field(description='Путь к полю, вызвавшему ошибку')
|
||||
msg: str = Field(description='Сообщение')
|
||||
type: str = Field(description='Тип ошибки валидации')
|
||||
|
||||
|
||||
class ApiValidationErrorsPayload(BaseModel):
|
||||
detail: list[ValidationErrorDetailItem] = Field(
|
||||
description='Список ошибок валидации тела или параметров запроса'
|
||||
)
|
||||
69
src/presentation/schemas/avatar.py
Normal file
69
src/presentation/schemas/avatar.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
AVATAR_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
_IMAGE_SIGNATURES = (
|
||||
b'\xff\xd8\xff',
|
||||
b'\x89PNG\r\n\x1a\n',
|
||||
b'GIF87a',
|
||||
b'GIF89a',
|
||||
)
|
||||
|
||||
|
||||
def _avatar_payload_to_bytes(photo_base64: str) -> bytes:
|
||||
s = photo_base64.strip()
|
||||
if not s:
|
||||
raise ValueError('photo_base64 must not be empty')
|
||||
if s.startswith('data:'):
|
||||
parts = s.split(',', 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError('Invalid data URL')
|
||||
s = parts[1].strip()
|
||||
try:
|
||||
data = base64.b64decode(s, validate=True)
|
||||
except binascii.Error as exc:
|
||||
raise ValueError('Invalid base64') from exc
|
||||
if len(data) > AVATAR_MAX_BYTES:
|
||||
raise ValueError(f'Photo must not exceed {AVATAR_MAX_BYTES} bytes')
|
||||
if len(data) < 12:
|
||||
raise ValueError('Photo data is too small')
|
||||
ok = False
|
||||
for sig in _IMAGE_SIGNATURES:
|
||||
if data.startswith(sig):
|
||||
ok = True
|
||||
break
|
||||
if not ok and data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP':
|
||||
ok = True
|
||||
if not ok:
|
||||
raise ValueError('Photo must be JPEG, PNG, GIF or WebP')
|
||||
return data
|
||||
|
||||
|
||||
class SetAvatarRequest(BaseModel):
|
||||
photo_base64: str = Field(
|
||||
...,
|
||||
description='Изображение JPEG, PNG, GIF или WebP в Base64; допустим data URL (data:image/...;base64,...). Максимум 10 МБ после декодирования.',
|
||||
)
|
||||
decoded_bytes: bytes = Field(exclude=True)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def _decode_input(cls, data: Any):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
raw = data.get('photo_base64')
|
||||
if raw is None:
|
||||
return data
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError('photo_base64 must be a string')
|
||||
stripped = raw.strip()
|
||||
decoded = _avatar_payload_to_bytes(stripped)
|
||||
return {'photo_base64': stripped, 'decoded_bytes': decoded}
|
||||
116
src/presentation/schemas/bank.py
Normal file
116
src/presentation/schemas/bank.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import re
|
||||
from typing import Self
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
|
||||
class BankUpdateRequest(BaseModel):
|
||||
passport_data: str | None = None
|
||||
inn: str | None = None
|
||||
erc20: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def at_least_one(self) -> Self:
|
||||
if not any([self.passport_data, self.inn, self.erc20]):
|
||||
raise ValueError('At least one field is required')
|
||||
return self
|
||||
|
||||
@field_validator('passport_data', 'inn', 'erc20')
|
||||
@classmethod
|
||||
def strip_optional(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip()
|
||||
return s or None
|
||||
|
||||
@field_validator('inn')
|
||||
@classmethod
|
||||
def validate_inn(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{10}(\d{2})?$', v):
|
||||
raise ValueError('INN must be 10 or 12 digits')
|
||||
if len(v) > 12:
|
||||
raise ValueError('INN is too long')
|
||||
return v
|
||||
|
||||
@field_validator('erc20')
|
||||
@classmethod
|
||||
def validate_erc20(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^0x[a-fA-F0-9]{40}$', v):
|
||||
raise ValueError('ERC20 address must be 0x followed by 40 hex characters')
|
||||
return v
|
||||
|
||||
@field_validator('passport_data')
|
||||
@classmethod
|
||||
def validate_passport_data(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if len(v) > 255:
|
||||
raise ValueError('Passport data is too long')
|
||||
return v
|
||||
|
||||
|
||||
class BankConfirmRequest(BaseModel):
|
||||
code: str
|
||||
passport_data: str | None = None
|
||||
inn: str | None = None
|
||||
erc20: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def at_least_one_field(self) -> Self:
|
||||
if not any([self.passport_data, self.inn, self.erc20]):
|
||||
raise ValueError('At least one field is required')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('passport_data', 'inn', 'erc20')
|
||||
@classmethod
|
||||
def strip_optional(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip()
|
||||
return s or None
|
||||
|
||||
@field_validator('inn')
|
||||
@classmethod
|
||||
def validate_inn(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{10}(\d{2})?$', v):
|
||||
raise ValueError('INN must be 10 or 12 digits')
|
||||
if len(v) > 12:
|
||||
raise ValueError('INN is too long')
|
||||
return v
|
||||
|
||||
@field_validator('erc20')
|
||||
@classmethod
|
||||
def validate_erc20(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^0x[a-fA-F0-9]{40}$', v):
|
||||
raise ValueError('ERC20 address must be 0x followed by 40 hex characters')
|
||||
return v
|
||||
|
||||
@field_validator('passport_data')
|
||||
@classmethod
|
||||
def validate_passport_data(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if len(v) > 255:
|
||||
raise ValueError('Passport data is too long')
|
||||
return v
|
||||
35
src/presentation/schemas/email.py
Normal file
35
src/presentation/schemas/email.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import re
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class ChangeEmailConfirmOldRequest(BaseModel):
|
||||
code: str
|
||||
new_email: str
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_email')
|
||||
@classmethod
|
||||
def validate_new_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
|
||||
class ChangeEmailCompleteRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
23
src/presentation/schemas/encrypted_mnemonic.py
Normal file
23
src/presentation/schemas/encrypted_mnemonic.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import re
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class EncryptedMnemonicConfirmRequest(BaseModel):
|
||||
code: str
|
||||
encrypted_mnemonic: str
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('encrypted_mnemonic')
|
||||
@classmethod
|
||||
def validate_encrypted_mnemonic(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError('encrypted_mnemonic must not be empty')
|
||||
return v
|
||||
59
src/presentation/schemas/me_public.py
Normal file
59
src/presentation/schemas/me_public.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
|
||||
class MeUserPublicResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=False)
|
||||
|
||||
id: str | None = Field(None, description='Идентификатор пользователя')
|
||||
email: str | None = Field(None, description='Email')
|
||||
first_name: str | None = Field(None, description='Имя')
|
||||
middle_name: str | None = Field(None, description='Отчество')
|
||||
last_name: str | None = Field(None, description='Фамилия')
|
||||
birth_date: date | None = Field(None, description='Дата рождения')
|
||||
encrypted_mnemonic: str | None = Field(None, description='Шифрованная мнемоника')
|
||||
phone: str | None = Field(None, description='Телефон')
|
||||
passport_data: str | None = Field(None, description='Паспортные данные')
|
||||
inn: str | None = Field(None, description='ИНН')
|
||||
erc20: str | None = Field(None, description='ERC-20 адрес')
|
||||
avatar_link: str | None = Field(None, description='HTTPS-ссылка на текущий аватар в хранилище')
|
||||
kyc_verified: bool | None = Field(None, description='Признак пройденного KYC')
|
||||
is_deleted: bool | None = Field(None, description='Удалён ли аккаунт')
|
||||
created_at: datetime | None = Field(None, description='Время создания записи')
|
||||
updated_at: datetime | None = Field(None, description='Время последнего обновления')
|
||||
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
|
||||
|
||||
@classmethod
|
||||
def from_user(cls, user: UserEntity) -> MeUserPublicResponse:
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
middle_name=user.middle_name,
|
||||
last_name=user.last_name,
|
||||
birth_date=user.birth_date,
|
||||
encrypted_mnemonic=user.encrypted_mnemonic,
|
||||
phone=user.phone,
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
avatar_link=user.avatar_link,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
kyc_verified_at=user.kyc_verified_at,
|
||||
)
|
||||
|
||||
|
||||
class SetAvatarPublicResponse(MeUserPublicResponse):
|
||||
webp_size_bytes: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description='Размер сохранённого файла аватара в формате WebP, байты',
|
||||
)
|
||||
75
src/presentation/schemas/password.py
Normal file
75
src/presentation/schemas/password.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import re
|
||||
from typing import Self
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from src.application.domain.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class ForgotPasswordStartRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
|
||||
class ForgotPasswordCompleteRequest(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def passwords_match(self) -> Self:
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError('Passwords do not match')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
class ChangePasswordConfirmRequest(BaseModel):
|
||||
code: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
@model_validator(mode='after')
|
||||
def passwords_match(self) -> Self:
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError('Passwords do not match')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return validate_password_strength(v)
|
||||
16
src/presentation/schemas/phone.py
Normal file
16
src/presentation/schemas/phone.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import re
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class SetPhoneRequest(BaseModel):
|
||||
phone: str
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_russian_phone(cls, v: str) -> str:
|
||||
cleaned = re.sub(r'[\s\-\(\)]', '', v)
|
||||
pattern = r'^(\+7|8)\d{10}$'
|
||||
if not re.match(pattern, cleaned):
|
||||
raise ValueError('Invalid Russian phone number')
|
||||
normalized = '+7' + cleaned[-10:]
|
||||
return normalized
|
||||
35
src/presentation/schemas/purchase_request.py
Normal file
35
src/presentation/schemas/purchase_request.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreatePurchaseRequestBody(BaseModel):
|
||||
usdt_amount: Decimal = Field(gt=0)
|
||||
comment: str | None = None
|
||||
target_wallet_chain: str | None = Field(default='ETH', max_length=16)
|
||||
target_wallet_address: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class PurchaseRequestResponse(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
status: str
|
||||
usdt_amount: str
|
||||
rub_amount: str | None
|
||||
exchange_rate: str | None
|
||||
service_fee_percent: str | None
|
||||
comment: str | None
|
||||
admin_comment: str | None
|
||||
target_wallet_chain: str | None
|
||||
target_wallet_address: str | None
|
||||
tx_hash: str | None
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
completed_at: str | None
|
||||
|
||||
|
||||
class PurchaseRequestListResponse(BaseModel):
|
||||
items: list[PurchaseRequestResponse]
|
||||
total: int
|
||||
1
src/presentation/serializers/__init__.py
Normal file
1
src/presentation/serializers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.serializers.me_user import me_user_payload, me_user_public
|
||||
13
src/presentation/serializers/me_user.py
Normal file
13
src/presentation/serializers/me_user.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
|
||||
|
||||
def me_user_public(user: UserEntity) -> MeUserPublicResponse:
|
||||
return MeUserPublicResponse.from_user(user)
|
||||
|
||||
|
||||
def me_user_payload(user: UserEntity) -> dict:
|
||||
return me_user_public(user).model_dump(mode='json')
|
||||
24
src/presentation/serializers/purchase_request.py
Normal file
24
src/presentation/serializers/purchase_request.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.domain.entities.purchase_request import PurchaseRequestEntity
|
||||
from src.presentation.schemas.purchase_request import PurchaseRequestResponse
|
||||
|
||||
|
||||
def purchase_request_to_response(entity: PurchaseRequestEntity) -> PurchaseRequestResponse:
|
||||
return PurchaseRequestResponse(
|
||||
id=entity.id,
|
||||
organization_id=entity.organization_id,
|
||||
status=entity.status,
|
||||
usdt_amount=str(entity.usdt_amount),
|
||||
rub_amount=str(entity.rub_amount) if entity.rub_amount is not None else None,
|
||||
exchange_rate=str(entity.exchange_rate) if entity.exchange_rate is not None else None,
|
||||
service_fee_percent=str(entity.service_fee_percent) if entity.service_fee_percent is not None else None,
|
||||
comment=entity.comment,
|
||||
admin_comment=entity.admin_comment,
|
||||
target_wallet_chain=entity.target_wallet_chain,
|
||||
target_wallet_address=entity.target_wallet_address,
|
||||
tx_hash=entity.tx_hash,
|
||||
created_at=entity.created_at.isoformat() if entity.created_at else None,
|
||||
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
|
||||
completed_at=entity.completed_at.isoformat() if entity.completed_at else None,
|
||||
)
|
||||
Reference in New Issue
Block a user