auth fixes, search connected

This commit is contained in:
2025-05-22 04:34:30 +03:00
parent 32bc1276e0
commit ab39b534fe
23 changed files with 610 additions and 359 deletions

347
main.py
View File

@@ -5,35 +5,32 @@ from os.path import exists, join
from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL
from ariadne.asgi.handlers import GraphQLHTTPHandler
from auth.handler import EnhancedGraphQLHTTPHandler
from auth.internal import InternalAuthentication
from auth.middleware import auth_middleware, AuthMiddleware
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, Response
from starlette.responses import JSONResponse, Response
from starlette.routing import Route, Mount
from starlette.staticfiles import StaticFiles
from starlette.types import ASGIApp
from cache.precache import precache_data
from cache.revalidator import revalidation_manager
from services.exception import ExceptionHandlerMiddleware
from services.redis import redis
from services.schema import create_all_tables, resolvers
from services.search import search_service, initialize_search_index
from services.search import check_search_service, initialize_search_index_background, search_service
from services.viewed import ViewedStorage
from utils.logger import root_logger as logger
from auth.internal import InternalAuthentication
from auth.middleware import AuthMiddleware
from settings import (
SESSION_COOKIE_NAME,
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_MAX_AGE,
SESSION_TOKEN_HEADER,
)
from settings import DEV_SERVER_PID_FILE_NAME
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов
INDEX_HTML = join(os.path.dirname(__file__), "index.html")
# Импортируем резолверы
import_module("resolvers")
@@ -41,118 +38,6 @@ import_module("resolvers")
# Создаем схему GraphQL
schema = make_executable_schema(load_schema_from_path("schema/"), resolvers)
# Пути к клиентским файлам
DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов
INDEX_HTML = join(os.path.dirname(__file__), "index.html")
async def check_search_service():
"""Check if search service is available and log result"""
info = await search_service.info()
if info.get("status") in ["error", "unavailable"]:
print(f"[WARNING] Search service unavailable: {info.get('message', 'unknown reason')}")
else:
print(f"[INFO] Search service is available: {info}")
async def index_handler(request: Request):
"""
Раздача основного HTML файла
"""
return FileResponse(INDEX_HTML)
# Создаем единый экземпляр AuthMiddleware для использования с GraphQL
auth_middleware = AuthMiddleware(lambda scope, receive, send: None)
class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
"""
Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации
"""
async def get_context_for_request(self, request: Request, data: dict) -> dict:
"""
Расширяем контекст для GraphQL запросов
"""
# Получаем стандартный контекст от базового класса
context = await super().get_context_for_request(request, data)
# Создаем объект ответа для установки cookie
response = JSONResponse({})
context["response"] = response
# Интегрируем с AuthMiddleware
auth_middleware.set_context(context)
context["extensions"] = auth_middleware
logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса")
return context
async def process_result(self, request: Request, result: dict) -> Response:
"""
Обрабатывает результат GraphQL запроса, поддерживая установку cookie
"""
# Получаем контекст запроса
context = getattr(request, "context", {})
# Получаем заранее созданный response из контекста
response = context.get("response")
if not response or not isinstance(response, Response):
# Если response не найден или не является объектом Response, создаем новый
response = await super().process_result(request, result)
else:
# Обновляем тело ответа данными из результата GraphQL
response.body = self.encode_json(result)
response.headers["content-type"] = "application/json"
response.headers["content-length"] = str(len(response.body))
logger.debug(f"[graphql] Подготовлен ответ с типом {type(response).__name__}")
return response
# Функция запуска сервера
async def start():
"""Запуск сервера и инициализация данных"""
# Инициализируем соединение с Redis
await redis.connect()
logger.info("Установлено соединение с Redis")
# Создаем все таблицы в БД
create_all_tables()
# Запускаем предварительное кеширование данных
asyncio.create_task(precache_data())
# Запускаем задачу ревалидации кеша
asyncio.create_task(revalidation_manager.start())
# Выводим сообщение о запуске сервера и доступности API
logger.info("Сервер запущен и готов принимать запросы")
logger.info("GraphQL API доступно по адресу: /graphql")
logger.info("Админ-панель доступна по адресу: http://127.0.0.1:8000/")
# Функция остановки сервера
async def shutdown():
"""Остановка сервера и освобождение ресурсов"""
logger.info("Остановка сервера")
# Закрываем соединение с Redis
await redis.disconnect()
# Останавливаем поисковый сервис
search_service.close()
# Удаляем PID-файл, если он существует
from settings import DEV_SERVER_PID_FILE_NAME
if exists(DEV_SERVER_PID_FILE_NAME):
os.unlink(DEV_SERVER_PID_FILE_NAME)
# Создаем middleware с правильным порядком
middleware = [
# Начинаем с обработки ошибок
@@ -172,9 +57,9 @@ middleware = [
allow_headers=["*"],
allow_credentials=True,
),
# После CORS идёт обработка авторизации
# Сначала AuthMiddleware (для обработки токенов)
Middleware(AuthMiddleware),
# И затем аутентификация
# Затем AuthenticationMiddleware (для создания request.user на основе токена)
Middleware(AuthenticationMiddleware, backend=InternalAuthentication()),
]
@@ -182,81 +67,169 @@ middleware = [
# Создаем экземпляр GraphQL с улучшенным обработчиком
graphql_app = GraphQL(
schema,
debug=True,
debug=DEVMODE,
http_handler=EnhancedGraphQLHTTPHandler()
)
# Оборачиваем GraphQL-обработчик для лучшей обработки ошибок
async def graphql_handler(request: Request):
"""
Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок.
Выполняет:
1. Проверку метода запроса (GET, POST, OPTIONS)
2. Обработку GraphQL запроса через ariadne
3. Применение middleware для корректной обработки cookie и авторизации
4. Обработку исключений и формирование ответа
Args:
request: Starlette Request объект
Returns:
Response: объект ответа (обычно JSONResponse)
"""
if request.method not in ["GET", "POST", "OPTIONS"]:
return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405)
# Проверяем, что все необходимые middleware корректно отработали
if not hasattr(request, "scope") or "auth" not in request.scope:
logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком")
try:
# Обрабатываем CORS для OPTIONS запросов
if request.method == "OPTIONS":
response = JSONResponse({})
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Max-Age"] = "86400" # 24 hours
return response
# Обрабатываем запрос через GraphQL приложение
result = await graphql_app.handle_request(request)
# Если результат не является Response, преобразуем его в JSONResponse
if not isinstance(result, Response):
response = JSONResponse(result)
# Проверяем, был ли токен в запросе или ответе
if request.method == "POST" and isinstance(result, dict):
data = await request.json()
op_name = data.get("operationName", "").lower()
# Если это операция логина или обновления токена, и в ответе есть токен
if (op_name in ["login", "refreshtoken"]) and result.get("data", {}).get(op_name, {}).get("token"):
token = result["data"][op_name]["token"]
# Устанавливаем cookie с токеном
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
)
logger.debug(f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
# Если это операция logout, удаляем cookie
elif op_name == "logout":
response.delete_cookie(
key=SESSION_COOKIE_NAME,
secure=SESSION_COOKIE_SECURE,
httponly=SESSION_COOKIE_HTTPONLY,
samesite=SESSION_COOKIE_SAMESITE
)
logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}")
return response
return result
# Применяем middleware для установки cookie
# Используем метод process_result из auth_middleware для корректной обработки
# cookie на основе результатов операций login/logout
response = await auth_middleware.process_result(request, result)
return response
except asyncio.CancelledError:
return JSONResponse({"error": "Request cancelled"}, status_code=499)
except Exception as e:
logger.error(f"GraphQL error: {str(e)}")
# Логируем более подробную информацию для отладки
import traceback
logger.debug(f"GraphQL error traceback: {traceback.format_exc()}")
return JSONResponse({"error": str(e)}, status_code=500)
# Добавляем маршруты, порядок имеет значение
routes = [
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)),
]
# Создаем приложение Starlette с маршрутами и middleware
async def shutdown():
"""Остановка сервера и освобождение ресурсов"""
logger.info("Остановка сервера")
# Закрываем соединение с Redis
await redis.disconnect()
# Останавливаем поисковый сервис
search_service.close()
# Удаляем PID-файл, если он существует
from settings import DEV_SERVER_PID_FILE_NAME
if exists(DEV_SERVER_PID_FILE_NAME):
os.unlink(DEV_SERVER_PID_FILE_NAME)
async def dev_start():
"""
Инициализация сервера в DEV режиме.
Функция:
1. Проверяет наличие DEV режима
2. Создает PID-файл для отслеживания процесса
3. Логирует информацию о старте сервера
Используется только при запуске сервера с флагом "dev".
"""
try:
pid_path = DEV_SERVER_PID_FILE_NAME
# Если PID-файл уже существует, проверяем, не запущен ли уже сервер с этим PID
if exists(pid_path):
try:
with open(pid_path, "r", encoding="utf-8") as f:
old_pid = int(f.read().strip())
# Проверяем, существует ли процесс с таким PID
import signal
try:
os.kill(old_pid, 0) # Сигнал 0 только проверяет существование процесса
print(f"[warning] DEV server already running with PID {old_pid}")
except OSError:
print(f"[info] Stale PID file found, previous process {old_pid} not running")
except (ValueError, FileNotFoundError):
print(f"[warning] Invalid PID file found, recreating")
# Создаем или перезаписываем PID-файл
with open(pid_path, "w", encoding="utf-8") as f:
f.write(str(os.getpid()))
print(f"[main] process started in DEV mode with PID {os.getpid()}")
except Exception as e:
logger.error(f"[main] Error during server startup: {str(e)}")
# Не прерываем запуск сервера из-за ошибки в этой функции
print(f"[warning] Error during DEV mode initialization: {str(e)}")
async def lifespan(_app):
"""
Функция жизненного цикла приложения.
Обеспечивает:
1. Инициализацию всех необходимых сервисов и компонентов
2. Предзагрузку кеша данных
3. Подключение к Redis и поисковому сервису
4. Корректное завершение работы при остановке сервера
Args:
_app: экземпляр Starlette приложения
Yields:
None: генератор для управления жизненным циклом
"""
try:
print("[lifespan] Starting application initialization")
create_all_tables()
await asyncio.gather(
redis.connect(),
precache_data(),
ViewedStorage.init(),
check_search_service(),
revalidation_manager.start(),
)
if DEVMODE:
await dev_start()
print("[lifespan] Basic initialization complete")
# Add a delay before starting the intensive search indexing
print("[lifespan] Waiting for system stabilization before search indexing...")
await asyncio.sleep(10) # 10-second delay to let the system stabilize
# Start search indexing as a background task with lower priority
asyncio.create_task(initialize_search_index_background())
yield
finally:
print("[lifespan] Shutting down application services")
tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()]
await asyncio.gather(*tasks, return_exceptions=True)
print("[lifespan] Shutdown complete")
# Обновляем маршрут в Starlette
app = Starlette(
routes=routes,
middleware=middleware,
on_startup=[start],
on_shutdown=[shutdown],
routes=[
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
Mount("/", app=StaticFiles(directory=DIST_DIR, html=True))
],
lifespan=lifespan,
middleware=middleware, # Явно указываем список middleware
debug=True,
)
if DEVMODE:
# Для DEV режима регистрируем дополнительный CORS middleware только для localhost
app.add_middleware(
CORSMiddleware,
allow_origins=["https://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)