import asyncio import logging import os import re import time from collections import defaultdict from pathlib import Path from fastapi import FastAPI, Request, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from config import FRONTEND_ORIGIN, UPLOADS_DIR from database import init_db from routers import users, game, hints, leaderboard, sentences, achievements, wordquiz, bubblewords, pronunciation, assistant, learning, tts, typing, teacher, support, admin, feed, admin_content from routers.sentences import init_sentences from routers.achievements import init_achievements from routers.learning_data import init_learning_data logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)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_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 environment=os.getenv("ENV", "production"), release=f"filwords@{APP_VERSION}", send_default_pii=False, # НЕ отправляем IP/headers/cookies пользователей ) logger.info(f"Sentry initialized (release=filwords@{APP_VERSION})") except Exception as e: logger.error(f"Failed to initialize Sentry: {e}") else: logger.warning("SENTRY_DSN not set — Sentry disabled") app = FastAPI(title="Filwords PL API") # ============ Rate Limiting ============ class RateLimiter: def __init__(self): self.requests = defaultdict(list) self.limits = { "default": {"max_requests": 60, "window": 60}, "/game/check-word": {"max_requests": 30, "window": 60}, "/game/complete-level": {"max_requests": 10, "window": 60}, "/sentences/check": {"max_requests": 20, "window": 60}, "/leaderboard": {"max_requests": 10, "window": 60}, "/assistant/chat": {"max_requests": 20, "window": 60}, "/learning/answer": {"max_requests": 30, "window": 60}, "/tts": {"max_requests": 30, "window": 60}, "/review": {"max_requests": 10, "window": 60}, "/typing/check": {"max_requests": 20, "window": 60}, "/teacher/answer": {"max_requests": 30, "window": 60}, "/teacher/start": {"max_requests": 10, "window": 60}, "/teacher/explain": {"max_requests": 10, "window": 60}, "/teacher/translate": {"max_requests": 10, "window": 60}, "/teacher/example": {"max_requests": 10, "window": 60}, "/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 limit_config = self.limits.get(path, self.limits["default"]) window = limit_config["window"] 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) 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( status_code=429, content={ "error": "Too many requests", "retry_after": retry_after }, 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], allow_credentials=False, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], ) # ============ 🔧 НОВОЕ (18.09.2026): статика для картинок 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.include_router(users.router) app.include_router(game.router) app.include_router(hints.router) app.include_router(leaderboard.router) app.include_router(sentences.router) app.include_router(achievements.router) app.include_router(wordquiz.router) app.include_router(bubblewords.router) app.include_router(pronunciation.router) app.include_router(assistant.router) app.include_router(learning.router) app.include_router(tts.router) 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) while True: try: from database import get_pool pool = await get_pool() async with pool.acquire() as conn: await conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_cache") except Exception as e: logger.error(f"Failed to refresh leaderboard cache: {e}") await asyncio.sleep(30) # ============ Startup ============ @app.on_event("startup") async def startup(): await init_db() 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 u.telegram_id, u.username, u.current_level, COALESCE(ws.total, 0) + COALESCE(s.total, 0) + COALESCE(wq.total, 0) + COALESCE(bw.total, 0) as total_score, COALESCE(ws.total, 0) as wordsearch_score, COALESCE(s.total, 0) as sentence_score, COALESCE(wq.total, 0) as wordquiz_score, COALESCE(wq.words_guessed, 0) as words_guessed, COALESCE(bw.total, 0) as bubblewords_score, COALESCE(bw.levels_completed, 0) as bubble_levels_completed FROM users u LEFT JOIN ( SELECT user_id, SUM(score) as total 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 GROUP BY user_id ) s ON u.telegram_id = s.user_id LEFT JOIN ( SELECT user_id, total_score as total, words_guessed FROM word_quiz_progress ) wq ON u.telegram_id = wq.user_id LEFT JOIN ( SELECT user_id, total_score as total, levels_completed FROM bubble_words_progress ) bw ON u.telegram_id = bw.user_id 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 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: from database import get_pool pool = await get_pool() async with pool.acquire() as conn: await conn.fetchval("SELECT 1") return {"status": "ok", "database": "connected"} except Exception as e: logger.error(f"Healthcheck failed: {e}") return JSONResponse( status_code=503, content={"status": "error", "database": "disconnected"} )