From 686da8a36d600643b0253411ac3c63e31da959ee Mon Sep 17 00:00:00 2001 From: Drobysh Date: Sun, 20 Sep 2026 17:55:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(main):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BE=D1=82=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D1=8B=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B0=20rate=20limiter=20(20.09.2026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 95 ++++++++++++++++++++++++++------------------------------- 1 file changed, 44 insertions(+), 51 deletions(-) diff --git a/main.py b/main.py index 5d9ebad..6d51c41 100644 --- a/main.py +++ b/main.py @@ -20,30 +20,26 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %( logger = logging.getLogger(__name__) # ============ Версия ============ -# Читается из .env (переменная APP_VERSION). Если не задана — fallback "1.0.0". -# Фронт живёт на Cloudflare Pages, там своя версия в index.html — они не обязаны совпадать. -# При деплое новой версии фронта обнови APP_VERSION в .env и перезапусти filwords-api. APP_VERSION = os.getenv("APP_VERSION", "1.0.0") -# ============ Sentry (мониторинг ошибок) ============ -# ВАЖНО: инициализация ДО создания FastAPI-приложения +# ============ Sentry ============ SENTRY_DSN = os.getenv("SENTRY_DSN", "") if SENTRY_DSN: try: import sentry_sdk from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.starlette import StarletteIntegration - + sentry_sdk.init( dsn=SENTRY_DSN, integrations=[ StarletteIntegration(transaction_style="endpoint"), FastApiIntegration(transaction_style="endpoint"), ], - traces_sample_rate=0.2, # 20% запросов для performance monitoring + traces_sample_rate=0.2, environment=os.getenv("ENV", "production"), release=f"filwords@{APP_VERSION}", - send_default_pii=False, # НЕ отправляем IP/headers/cookies пользователей + send_default_pii=False, ) logger.info(f"Sentry initialized (release=filwords@{APP_VERSION})") except Exception as e: @@ -53,6 +49,7 @@ else: app = FastAPI(title="Filwords PL API") + # ============ Rate Limiting ============ class RateLimiter: def __init__(self): @@ -76,40 +73,35 @@ class RateLimiter: "/support/send": {"max_requests": 10, "window": 60}, "/support/admin-reply": {"max_requests": 30, "window": 60}, "/support/translate": {"max_requests": 20, "window": 60}, - # 🔧 НОВОЕ (18.09.2026): лимиты для feed "/admin/feed/post": {"max_requests": 20, "window": 60}, "/admin/feed/upload-image": {"max_requests": 10, "window": 60}, "/admin/feed/translate": {"max_requests": 10, "window": 60}, } self._cleanup_task = None - + def _cleanup(self): now = time.time() for key in list(self.requests.keys()): self.requests[key] = [t for t in self.requests[key] if now - t < 60] if not self.requests[key]: del self.requests[key] - + async def start_cleanup(self): while True: await asyncio.sleep(60) self._cleanup() - + def is_allowed(self, key: str, path: str) -> bool: now = time.time() - limit_config = self.limits.get(path, self.limits["default"]) max_requests = limit_config["max_requests"] window = limit_config["window"] - self.requests[key] = [t for t in self.requests[key] if now - t < window] - if len(self.requests[key]) >= max_requests: return False - self.requests[key].append(now) return True - + def get_retry_after(self, key: str, path: str) -> int: if not self.requests[key]: return 0 @@ -118,27 +110,29 @@ class RateLimiter: oldest = min(self.requests[key]) return max(0, int(window - (time.time() - oldest))) + rate_limiter = RateLimiter() + # ============ Rate Limiting Middleware ============ @app.middleware("http") async def rate_limit_middleware(request: Request, call_next): path = request.url.path - + if path in ["/", "/health"]: return await call_next(request) - + # 🔧 ФИКС (20.09.2026): за Cloudflare/nginx request.client.host — IP прокси. -# Берём реальный IP из X-Forwarded-For (первый адрес). -forwarded = request.headers.get("x-forwarded-for") -if forwarded: - client_ip = forwarded.split(",")[0].strip() -elif request.headers.get("cf-connecting-ip"): - client_ip = request.headers.get("cf-connecting-ip") -else: - client_ip = request.client.host if request.client else "unknown" -rate_key = f"{client_ip}:{path}" - + # Берём реальный IP из X-Forwarded-For (первый адрес). + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + client_ip = forwarded.split(",")[0].strip() + elif request.headers.get("cf-connecting-ip"): + client_ip = request.headers.get("cf-connecting-ip") + else: + client_ip = request.client.host if request.client else "unknown" + rate_key = f"{client_ip}:{path}" + if not rate_limiter.is_allowed(rate_key, path): retry_after = rate_limiter.get_retry_after(rate_key, path) return JSONResponse( @@ -149,18 +143,18 @@ rate_key = f"{client_ip}:{path}" }, headers={"Retry-After": str(retry_after)} ) - + start_time = time.time() response = await call_next(request) duration = time.time() - start_time - + if duration > 1.0: logger.warning(f"Slow request: {request.method} {path} - {duration:.2f}s") - + return response + # ============ CORS ============ -# 🔧 ОБНОВЛЕНО: добавлен PUT — нужен для редактирования feed-постов app.add_middleware( CORSMiddleware, allow_origins=[FRONTEND_ORIGIN], @@ -169,9 +163,7 @@ app.add_middleware( allow_headers=["*"], ) -# ============ 🔧 НОВОЕ (18.09.2026): статика для картинок feed-постов ============ -# Файлы лежат в /root/filwordspl/backend/uploads/feed/{filename} -# URL на фронте: https://api.filwordspl.com/uploads/feed/{filename} +# ============ Статика для картинок feed-постов ============ app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads") # ============ Роутеры ============ @@ -191,11 +183,10 @@ app.include_router(typing.router) app.include_router(teacher.router) app.include_router(support.router) app.include_router(admin.router) -# 🔧 НОВОЕ (18.09.2026): feed — посты карусели (публичный + админский) app.include_router(feed.router) -# 🔧 НОВОЕ (19.09.2026): admin content — контент-менеджер (только для админов) app.include_router(admin_content.router) + # ============ Фоновое обновление кэша рейтинга ============ async def refresh_leaderboard_cache_loop(): await asyncio.sleep(10) @@ -209,6 +200,7 @@ async def refresh_leaderboard_cache_loop(): logger.error(f"Failed to refresh leaderboard cache: {e}") await asyncio.sleep(30) + # ============ Startup ============ @app.on_event("startup") async def startup(): @@ -216,17 +208,16 @@ async def startup(): await init_sentences() await init_achievements() await init_learning_data() - + asyncio.create_task(rate_limiter.start_cleanup()) - + from database import get_pool pool = await get_pool() async with pool.acquire() as conn: - # Пересоздаём materialized view с новой структурой (с level) await conn.execute("DROP MATERIALIZED VIEW IF EXISTS leaderboard_cache") await conn.execute(""" CREATE MATERIALIZED VIEW IF NOT EXISTS leaderboard_cache AS - SELECT + SELECT u.telegram_id, u.username, u.current_level, @@ -240,14 +231,14 @@ async def startup(): FROM users u LEFT JOIN ( SELECT user_id, SUM(score) as total - FROM user_progress - WHERE completed_at IS NOT NULL + FROM user_progress + WHERE completed_at IS NOT NULL GROUP BY user_id ) ws ON u.telegram_id = ws.user_id LEFT JOIN ( SELECT user_id, SUM(score) as total - FROM sentence_progress - WHERE completed_at IS NOT NULL + FROM sentence_progress + WHERE completed_at IS NOT NULL GROUP BY user_id ) s ON u.telegram_id = s.user_id LEFT JOIN ( @@ -261,23 +252,25 @@ async def startup(): WHERE COALESCE(ws.total, 0) + COALESCE(s.total, 0) + COALESCE(wq.total, 0) + COALESCE(bw.total, 0) > 0 ORDER BY total_score DESC """) - + await conn.execute(""" - CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_cache_user + CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_cache_user ON leaderboard_cache (telegram_id) """) - + logger.info("Leaderboard cache created") - + asyncio.create_task(refresh_leaderboard_cache_loop()) - + logger.info(f"Filwords API v{APP_VERSION} started") + # ============ Базовые эндпоинты ============ @app.get("/") async def root(): return {"status": "ok", "app": "Filwords PL", "version": APP_VERSION} + @app.get("/health") async def health(): try: