fix(main): правильные отступы после фикса rate limiter (20.09.2026)

This commit is contained in:
2026-09-20 17:55:35 +00:00
parent e15a228f80
commit 686da8a36d
+44 -51
View File
@@ -20,30 +20,26 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(
logger = logging.getLogger(__name__) 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") APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
# ============ Sentry (мониторинг ошибок) ============ # ============ Sentry ============
# ВАЖНО: инициализация ДО создания FastAPI-приложения
SENTRY_DSN = os.getenv("SENTRY_DSN", "") SENTRY_DSN = os.getenv("SENTRY_DSN", "")
if SENTRY_DSN: if SENTRY_DSN:
try: try:
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration from sentry_sdk.integrations.starlette import StarletteIntegration
sentry_sdk.init( sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
integrations=[ integrations=[
StarletteIntegration(transaction_style="endpoint"), StarletteIntegration(transaction_style="endpoint"),
FastApiIntegration(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"), environment=os.getenv("ENV", "production"),
release=f"filwords@{APP_VERSION}", 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})") logger.info(f"Sentry initialized (release=filwords@{APP_VERSION})")
except Exception as e: except Exception as e:
@@ -53,6 +49,7 @@ else:
app = FastAPI(title="Filwords PL API") app = FastAPI(title="Filwords PL API")
# ============ Rate Limiting ============ # ============ Rate Limiting ============
class RateLimiter: class RateLimiter:
def __init__(self): def __init__(self):
@@ -76,40 +73,35 @@ class RateLimiter:
"/support/send": {"max_requests": 10, "window": 60}, "/support/send": {"max_requests": 10, "window": 60},
"/support/admin-reply": {"max_requests": 30, "window": 60}, "/support/admin-reply": {"max_requests": 30, "window": 60},
"/support/translate": {"max_requests": 20, "window": 60}, "/support/translate": {"max_requests": 20, "window": 60},
# 🔧 НОВОЕ (18.09.2026): лимиты для feed
"/admin/feed/post": {"max_requests": 20, "window": 60}, "/admin/feed/post": {"max_requests": 20, "window": 60},
"/admin/feed/upload-image": {"max_requests": 10, "window": 60}, "/admin/feed/upload-image": {"max_requests": 10, "window": 60},
"/admin/feed/translate": {"max_requests": 10, "window": 60}, "/admin/feed/translate": {"max_requests": 10, "window": 60},
} }
self._cleanup_task = None self._cleanup_task = None
def _cleanup(self): def _cleanup(self):
now = time.time() now = time.time()
for key in list(self.requests.keys()): for key in list(self.requests.keys()):
self.requests[key] = [t for t in self.requests[key] if now - t < 60] self.requests[key] = [t for t in self.requests[key] if now - t < 60]
if not self.requests[key]: if not self.requests[key]:
del self.requests[key] del self.requests[key]
async def start_cleanup(self): async def start_cleanup(self):
while True: while True:
await asyncio.sleep(60) await asyncio.sleep(60)
self._cleanup() self._cleanup()
def is_allowed(self, key: str, path: str) -> bool: def is_allowed(self, key: str, path: str) -> bool:
now = time.time() now = time.time()
limit_config = self.limits.get(path, self.limits["default"]) limit_config = self.limits.get(path, self.limits["default"])
max_requests = limit_config["max_requests"] max_requests = limit_config["max_requests"]
window = limit_config["window"] window = limit_config["window"]
self.requests[key] = [t for t in self.requests[key] if now - t < window] self.requests[key] = [t for t in self.requests[key] if now - t < window]
if len(self.requests[key]) >= max_requests: if len(self.requests[key]) >= max_requests:
return False return False
self.requests[key].append(now) self.requests[key].append(now)
return True return True
def get_retry_after(self, key: str, path: str) -> int: def get_retry_after(self, key: str, path: str) -> int:
if not self.requests[key]: if not self.requests[key]:
return 0 return 0
@@ -118,27 +110,29 @@ class RateLimiter:
oldest = min(self.requests[key]) oldest = min(self.requests[key])
return max(0, int(window - (time.time() - oldest))) return max(0, int(window - (time.time() - oldest)))
rate_limiter = RateLimiter() rate_limiter = RateLimiter()
# ============ Rate Limiting Middleware ============ # ============ Rate Limiting Middleware ============
@app.middleware("http") @app.middleware("http")
async def rate_limit_middleware(request: Request, call_next): async def rate_limit_middleware(request: Request, call_next):
path = request.url.path path = request.url.path
if path in ["/", "/health"]: if path in ["/", "/health"]:
return await call_next(request) return await call_next(request)
# 🔧 ФИКС (20.09.2026): за Cloudflare/nginx request.client.host — IP прокси. # 🔧 ФИКС (20.09.2026): за Cloudflare/nginx request.client.host — IP прокси.
# Берём реальный IP из X-Forwarded-For (первый адрес). # Берём реальный IP из X-Forwarded-For (первый адрес).
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: if forwarded:
client_ip = forwarded.split(",")[0].strip() client_ip = forwarded.split(",")[0].strip()
elif request.headers.get("cf-connecting-ip"): elif request.headers.get("cf-connecting-ip"):
client_ip = request.headers.get("cf-connecting-ip") client_ip = request.headers.get("cf-connecting-ip")
else: else:
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
rate_key = f"{client_ip}:{path}" rate_key = f"{client_ip}:{path}"
if not rate_limiter.is_allowed(rate_key, path): if not rate_limiter.is_allowed(rate_key, path):
retry_after = rate_limiter.get_retry_after(rate_key, path) retry_after = rate_limiter.get_retry_after(rate_key, path)
return JSONResponse( return JSONResponse(
@@ -149,18 +143,18 @@ rate_key = f"{client_ip}:{path}"
}, },
headers={"Retry-After": str(retry_after)} headers={"Retry-After": str(retry_after)}
) )
start_time = time.time() start_time = time.time()
response = await call_next(request) response = await call_next(request)
duration = time.time() - start_time duration = time.time() - start_time
if duration > 1.0: if duration > 1.0:
logger.warning(f"Slow request: {request.method} {path} - {duration:.2f}s") logger.warning(f"Slow request: {request.method} {path} - {duration:.2f}s")
return response return response
# ============ CORS ============ # ============ CORS ============
# 🔧 ОБНОВЛЕНО: добавлен PUT — нужен для редактирования feed-постов
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[FRONTEND_ORIGIN], allow_origins=[FRONTEND_ORIGIN],
@@ -169,9 +163,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ============ 🔧 НОВОЕ (18.09.2026): статика для картинок feed-постов ============ # ============ Статика для картинок feed-постов ============
# Файлы лежат в /root/filwordspl/backend/uploads/feed/{filename}
# URL на фронте: https://api.filwordspl.com/uploads/feed/{filename}
app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads") 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(teacher.router)
app.include_router(support.router) app.include_router(support.router)
app.include_router(admin.router) app.include_router(admin.router)
# 🔧 НОВОЕ (18.09.2026): feed — посты карусели (публичный + админский)
app.include_router(feed.router) app.include_router(feed.router)
# 🔧 НОВОЕ (19.09.2026): admin content — контент-менеджер (только для админов)
app.include_router(admin_content.router) app.include_router(admin_content.router)
# ============ Фоновое обновление кэша рейтинга ============ # ============ Фоновое обновление кэша рейтинга ============
async def refresh_leaderboard_cache_loop(): async def refresh_leaderboard_cache_loop():
await asyncio.sleep(10) await asyncio.sleep(10)
@@ -209,6 +200,7 @@ async def refresh_leaderboard_cache_loop():
logger.error(f"Failed to refresh leaderboard cache: {e}") logger.error(f"Failed to refresh leaderboard cache: {e}")
await asyncio.sleep(30) await asyncio.sleep(30)
# ============ Startup ============ # ============ Startup ============
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
@@ -216,17 +208,16 @@ async def startup():
await init_sentences() await init_sentences()
await init_achievements() await init_achievements()
await init_learning_data() await init_learning_data()
asyncio.create_task(rate_limiter.start_cleanup()) asyncio.create_task(rate_limiter.start_cleanup())
from database import get_pool from database import get_pool
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
# Пересоздаём materialized view с новой структурой (с level)
await conn.execute("DROP MATERIALIZED VIEW IF EXISTS leaderboard_cache") await conn.execute("DROP MATERIALIZED VIEW IF EXISTS leaderboard_cache")
await conn.execute(""" await conn.execute("""
CREATE MATERIALIZED VIEW IF NOT EXISTS leaderboard_cache AS CREATE MATERIALIZED VIEW IF NOT EXISTS leaderboard_cache AS
SELECT SELECT
u.telegram_id, u.telegram_id,
u.username, u.username,
u.current_level, u.current_level,
@@ -240,14 +231,14 @@ async def startup():
FROM users u FROM users u
LEFT JOIN ( LEFT JOIN (
SELECT user_id, SUM(score) as total SELECT user_id, SUM(score) as total
FROM user_progress FROM user_progress
WHERE completed_at IS NOT NULL WHERE completed_at IS NOT NULL
GROUP BY user_id GROUP BY user_id
) ws ON u.telegram_id = ws.user_id ) ws ON u.telegram_id = ws.user_id
LEFT JOIN ( LEFT JOIN (
SELECT user_id, SUM(score) as total SELECT user_id, SUM(score) as total
FROM sentence_progress FROM sentence_progress
WHERE completed_at IS NOT NULL WHERE completed_at IS NOT NULL
GROUP BY user_id GROUP BY user_id
) s ON u.telegram_id = s.user_id ) s ON u.telegram_id = s.user_id
LEFT JOIN ( 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 WHERE COALESCE(ws.total, 0) + COALESCE(s.total, 0) + COALESCE(wq.total, 0) + COALESCE(bw.total, 0) > 0
ORDER BY total_score DESC ORDER BY total_score DESC
""") """)
await conn.execute(""" 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) ON leaderboard_cache (telegram_id)
""") """)
logger.info("Leaderboard cache created") logger.info("Leaderboard cache created")
asyncio.create_task(refresh_leaderboard_cache_loop()) asyncio.create_task(refresh_leaderboard_cache_loop())
logger.info(f"Filwords API v{APP_VERSION} started") logger.info(f"Filwords API v{APP_VERSION} started")
# ============ Базовые эндпоинты ============ # ============ Базовые эндпоинты ============
@app.get("/") @app.get("/")
async def root(): async def root():
return {"status": "ok", "app": "Filwords PL", "version": APP_VERSION} return {"status": "ok", "app": "Filwords PL", "version": APP_VERSION}
@app.get("/health") @app.get("/health")
async def health(): async def health():
try: try: