Merge pull request #4 from IgorVolochay/frontend
This commit is contained in:
+286
-141
@@ -1,191 +1,336 @@
|
||||
import os
|
||||
import secrets
|
||||
|
||||
import uvicorn
|
||||
import asyncio
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Depends, Response, status
|
||||
from fastapi import FastAPI, Depends, Response, Header, HTTPException, status
|
||||
from guard import SecurityMiddleware, SecurityConfig, SecurityDecorator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from schemas.api_schemas import *
|
||||
from schemas.base_schemas import *
|
||||
from typing import Optional
|
||||
|
||||
from schemas.api_schemas import BaseResponse, AddUserBody, AddCardBody, SelectChoice, ReactionCard, AddCommentBody
|
||||
from schemas.base_schemas import Card
|
||||
from mongo_worker import MongoWorker
|
||||
from rabbit_worker import RabbitWorker
|
||||
from tools.base_moderation import moderate_text
|
||||
from logger import logger, setup_logging
|
||||
from middleware import RequestLoggingMiddleware
|
||||
from tg_auth import get_current_user_id
|
||||
|
||||
|
||||
setup_logging()
|
||||
load_dotenv()
|
||||
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
|
||||
|
||||
app: FastAPI = FastAPI(title="This OR That",
|
||||
summary="OpenAPI schema for \"This OR That\" project!",
|
||||
version="0.1",
|
||||
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
|
||||
docs_url=None if disable_docs else "/docs",
|
||||
redoc_url=None if disable_docs else "/redoc",
|
||||
openapi_url=None if disable_docs else "/openapi.json")
|
||||
DEV_MODE: bool = os.getenv("DEV_MODE", "false").lower() == "true"
|
||||
mongo_worker = MongoWorker()
|
||||
_rabbit_worker: Optional[RabbitWorker] = None
|
||||
|
||||
|
||||
def get_rabbit_worker() -> RabbitWorker:
|
||||
"""Returns a singleton instance of the RabbitWorker."""
|
||||
global _rabbit_worker
|
||||
if _rabbit_worker is None:
|
||||
_rabbit_worker = RabbitWorker()
|
||||
return _rabbit_worker
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manages application startup and shutdown events, such as database index creation and cleanup."""
|
||||
await mongo_worker.create_indexes()
|
||||
if DEV_MODE:
|
||||
logger.warning("⚠️ DEV_MODE is enabled — docs are exposed and Telegram initData auth is DISABLED")
|
||||
logger.info("Application started on :5000")
|
||||
yield
|
||||
mongo_worker.client.close()
|
||||
logger.info("Application shutdown completed.")
|
||||
|
||||
|
||||
app: FastAPI = FastAPI(
|
||||
title="This OR That",
|
||||
summary="OpenAPI schema for \"This OR That\" project!",
|
||||
version="0.1",
|
||||
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
|
||||
docs_url="/docs" if DEV_MODE else None,
|
||||
redoc_url="/redoc" if DEV_MODE else None,
|
||||
openapi_url="/openapi.json" if DEV_MODE else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
config = SecurityConfig(
|
||||
enable_rate_limiting=True,
|
||||
rate_limit=10, # TODO: check rate limits in real usage
|
||||
rate_limit_window=3, # TODO: check rate limits in real usage
|
||||
enable_redis=False,
|
||||
enable_ip_banning=True,
|
||||
|
||||
enable_penetration_detection=True,
|
||||
auto_ban_threshold=3,
|
||||
auto_ban_duration=3600,
|
||||
|
||||
detection_compiler_timeout=2.0,
|
||||
detection_max_content_length=10000,
|
||||
detection_preserve_attack_patterns=True,
|
||||
detection_semantic_threshold=0.7,
|
||||
|
||||
detection_anomaly_threshold=3.0,
|
||||
detection_slow_pattern_threshold=0.1,
|
||||
detection_monitor_history_size=1000,
|
||||
detection_max_tracked_patterns=1000,
|
||||
)
|
||||
guard_deco = SecurityDecorator(config)
|
||||
|
||||
_security_middleware = SecurityMiddleware(app.router, config=config)
|
||||
app.add_middleware(SecurityMiddleware, config=config)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
app.state.guard_decorator = guard_deco
|
||||
app.state._security_middleware = _security_middleware
|
||||
|
||||
MODERATION_SECRET = os.getenv("MODERATION_SECRET", "change-me-in-production")
|
||||
|
||||
|
||||
async def verify_moderation_secret(
|
||||
x_moderation_secret: str = Header(..., alias="X-Moderation-Secret"),
|
||||
) -> str:
|
||||
"""Verifies the moderation secret provided in the request headers."""
|
||||
if not secrets.compare_digest(x_moderation_secret, MODERATION_SECRET):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid moderation secret",
|
||||
)
|
||||
return x_moderation_secret
|
||||
|
||||
@app.get("/check_user", status_code=200)
|
||||
async def check_user(user_id: NonNegativeInt,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
result = mongo.check_user(user_id)
|
||||
async def check_user(
|
||||
user_id: int,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Checks if a user exists in the database by their user_id."""
|
||||
result = await mongo.check_user(user_id)
|
||||
return BaseResponse(result=result)
|
||||
|
||||
@app.get("/get_user", status_code=200)
|
||||
async def get_user(user_id: NonNegativeInt,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
if mongo.check_user(user_id):
|
||||
result = mongo.get_user(user_id)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Retrieves a user's details by their user_id."""
|
||||
if await mongo.check_user(user_id):
|
||||
result = await mongo.get_user(user_id)
|
||||
return BaseResponse(result=result)
|
||||
else:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
|
||||
@app.post("/add_user", status_code=201)
|
||||
@guard_deco.rate_limit(requests=3, window=60)
|
||||
async def add_user(
|
||||
new_user: AddUserBody,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Registers a new user in the database if they do not already exist."""
|
||||
user_id = auth_user_id if auth_user_id is not None else new_user.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
if not await mongo.check_user(user_id):
|
||||
result = await mongo.add_user(
|
||||
user_id,
|
||||
new_user.username,
|
||||
new_user.first_name,
|
||||
new_user.last_name,
|
||||
new_user.photo_url,
|
||||
)
|
||||
return BaseResponse(result=result)
|
||||
response.status_code = status.HTTP_409_CONFLICT
|
||||
return BaseResponse(result="User already exist", error=True)
|
||||
|
||||
|
||||
@app.get("/get_card", status_code=200)
|
||||
async def get_card(
|
||||
card_id: int,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Retrieves a card's details by its card_id."""
|
||||
card = await mongo.get_card(card_id)
|
||||
if card:
|
||||
return BaseResponse(result=card)
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="There is no card with this card_id", error=True)
|
||||
|
||||
@app.get("/get_random_cards", status_code=200)
|
||||
@guard_deco.rate_limit(requests=5, window=60)
|
||||
async def get_random_cards(
|
||||
response: Response,
|
||||
user_id: Optional[int] = None,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Fetches a set of random active cards that the user has not yet visited."""
|
||||
resolved_user_id = auth_user_id if auth_user_id is not None else user_id
|
||||
if resolved_user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
cards_visited = await mongo.get_visited_cards(resolved_user_id)
|
||||
|
||||
if cards_visited.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return cards_visited
|
||||
exclude_ids = cards_visited.result.cards_visited or None
|
||||
random_cards = await mongo.get_random_cards(10, True, exclude_ids=exclude_ids)
|
||||
|
||||
if not random_cards:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="No active cards for this user", error=True)
|
||||
|
||||
return BaseResponse(result=random_cards)
|
||||
|
||||
@app.post("/add_card", status_code=201)
|
||||
@guard_deco.rate_limit(requests=3, window=60)
|
||||
async def add_card(
|
||||
new_card: AddCardBody,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Adds a new card to the database and sends it for moderation."""
|
||||
author_id = auth_user_id if auth_user_id is not None else new_card.author_id
|
||||
if author_id is None:
|
||||
raise HTTPException(status_code=422, detail="author_id is required")
|
||||
if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
|
||||
card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, author_id)
|
||||
try:
|
||||
await get_rabbit_worker().send_to_moderation(card)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send card {} to moderation queue: {}", card.card_id, exc)
|
||||
|
||||
return BaseResponse(result=card)
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
return BaseResponse(result="Card has not passed base moderation", error=True)
|
||||
|
||||
@app.patch("/card_accept", status_code=200, dependencies=[Depends(verify_moderation_secret)])
|
||||
async def card_accept(
|
||||
card_id: int,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Accepts a card after moderation, making it active and visible to users."""
|
||||
result = await mongo.accept_card(card_id)
|
||||
if result.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
|
||||
@app.patch("/card_reject", status_code=200, dependencies=[Depends(verify_moderation_secret)])
|
||||
async def card_reject(
|
||||
card_id: int,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Rejects a card during moderation and removes it from the database."""
|
||||
result = await mongo.reject_card(card_id)
|
||||
if result.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
|
||||
@app.patch("/select_choice", status_code=200)
|
||||
async def select_choice(
|
||||
choice_data: SelectChoice,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Records a user's choice (A or B) for a specific card."""
|
||||
user_id = auth_user_id if auth_user_id is not None else choice_data.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
# Verify that the user exists before proceeding.
|
||||
if not await mongo.check_user(user_id):
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="User doesn't exist", error=True)
|
||||
|
||||
@app.post("/add_user", status_code=201)
|
||||
async def add_user(new_user: AddUserBody,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
if not mongo.check_user(new_user.user_id):
|
||||
result = mongo.add_user(new_user.user_id,
|
||||
new_user.username,
|
||||
new_user.first_name,
|
||||
new_user.last_name,
|
||||
new_user.photo_url)
|
||||
return BaseResponse(result=result)
|
||||
else:
|
||||
response.status_code = status.HTTP_409_CONFLICT
|
||||
return BaseResponse(result="User already exist", error=True)
|
||||
|
||||
|
||||
@app.get("/get_card", status_code=200)
|
||||
async def get_card(card_id: NonNegativeInt,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
card = mongo.get_card(card_id)
|
||||
if card:
|
||||
return BaseResponse(result=card)
|
||||
else:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="There is no card with this card_id", error=True)
|
||||
|
||||
@app.get("/get_random_cards", status_code=200)
|
||||
async def get_random_cards(user_id: NonNegativeInt,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
cards_visited = mongo.get_visited_cards(user_id)
|
||||
|
||||
if cards_visited.error:
|
||||
response.status_code = status.HTTP_401_UNAUTHORIZED
|
||||
return cards_visited
|
||||
elif not cards_visited.result.cards_visited:
|
||||
random_cards = mongo.get_random_cards(10, True)
|
||||
if random_cards:
|
||||
return BaseResponse(result=random_cards)
|
||||
else:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="No active cards", error=True)
|
||||
|
||||
result: list[Card] = list()
|
||||
trys = 3
|
||||
while len(result) < 10 and trys != 0:
|
||||
random_cards = mongo.get_random_cards(10, True)
|
||||
if not random_cards:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="No active cards", error=True)
|
||||
filtered_cards, filtered_cards_id = mongo.filter_cards(random_cards, cards_visited.result.cards_visited)
|
||||
trys -= 1
|
||||
if not filtered_cards:
|
||||
continue
|
||||
else:
|
||||
result.extend(filtered_cards)
|
||||
cards_visited.result.cards_visited.update(filtered_cards_id)
|
||||
|
||||
if not result:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return BaseResponse(result="No active cards fo this user", error=True)
|
||||
else:
|
||||
return BaseResponse(result=result)
|
||||
|
||||
@app.post("/add_card", status_code=201)
|
||||
async def add_card(new_card: AddCardBody,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
|
||||
card = mongo.add_card_by_api(new_card.choice_A,
|
||||
new_card.choice_B,
|
||||
new_card.author_id)
|
||||
return BaseResponse(result=card)
|
||||
else:
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
return BaseResponse(result="Card has not passed base moderation", error=True)
|
||||
|
||||
|
||||
@app.patch("/select_choice", status_code=200)
|
||||
async def select_choice(choice_data: SelectChoice,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
check_visited = mongo.get_visited_cards(choice_data.user_id)
|
||||
if check_visited.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return check_visited
|
||||
elif not check_visited.error and choice_data.card_id in check_visited.result.cards_visited:
|
||||
# Atomically mark the card as visited.
|
||||
newly_visited = await mongo.try_mark_visited(user_id, choice_data.card_id)
|
||||
if not newly_visited:
|
||||
response.status_code = status.HTTP_403_FORBIDDEN
|
||||
return BaseResponse(result="Card already visited!", error=True)
|
||||
else:
|
||||
select_choice_result = mongo.select_choice(choice_data.card_id, choice_data.choice)
|
||||
if select_choice_result.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return select_choice_result
|
||||
else:
|
||||
update_visited_result = mongo.update_visited_cards(choice_data.user_id, choice_data.card_id)
|
||||
return BaseResponse(result="Select choice complite!")
|
||||
|
||||
|
||||
select_choice_result = await mongo.select_choice(choice_data.card_id, choice_data.choice)
|
||||
if select_choice_result.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return select_choice_result
|
||||
|
||||
return BaseResponse(result="Select choice complete!")
|
||||
|
||||
|
||||
@app.patch("/like_card", status_code=200)
|
||||
async def like_card(like_data: ReactionCard,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
result = mongo.like_card(like_data.card_id, like_data.user_id)
|
||||
async def like_card(
|
||||
like_data: ReactionCard,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Adds a like to a specific card from a user."""
|
||||
user_id = auth_user_id if auth_user_id is not None else like_data.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
result = await mongo.like_card(like_data.card_id, user_id)
|
||||
if not result.error and result.result:
|
||||
return BaseResponse(result="Added like to card")
|
||||
else:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
|
||||
@app.patch("/dislike_card", status_code=200)
|
||||
async def dislike_card(dislike_data: ReactionCard,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
result = mongo.dislike_card(dislike_data.card_id, dislike_data.user_id)
|
||||
async def dislike_card(
|
||||
dislike_data: ReactionCard,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Adds a dislike to a specific card from a user."""
|
||||
user_id = auth_user_id if auth_user_id is not None else dislike_data.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=422, detail="user_id is required")
|
||||
result = await mongo.dislike_card(dislike_data.card_id, user_id)
|
||||
if not result.error and result.result:
|
||||
return BaseResponse(result="Added dislike to card")
|
||||
else:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
|
||||
@app.post("/comment", status_code=201)
|
||||
async def comment(comment_info: AddCommentBody,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||
@guard_deco.rate_limit(requests=5, window=20)
|
||||
async def comment(
|
||||
comment_info: AddCommentBody,
|
||||
response: Response,
|
||||
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Adds a comment to a specific card after passing basic moderation."""
|
||||
author_id = auth_user_id if auth_user_id is not None else comment_info.author_id
|
||||
if author_id is None:
|
||||
raise HTTPException(status_code=422, detail="author_id is required")
|
||||
if not moderate_text(comment_info.comment_text):
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
return BaseResponse(result="Comment has not passed base moderation", error=True)
|
||||
|
||||
result = mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text)
|
||||
|
||||
result = await mongo.add_comment(author_id, comment_info.card_id, comment_info.comment_text)
|
||||
if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
elif result.error:
|
||||
if result.error:
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
return result
|
||||
else:
|
||||
return result
|
||||
|
||||
@app.get("/get_comments", status_code=200)
|
||||
async def get_comments(
|
||||
card_id: int,
|
||||
response: Response,
|
||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
||||
"""Retrieves all comments for a specific card."""
|
||||
result = await mongo.get_comments(card_id)
|
||||
if result.error:
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def main():
|
||||
config = uvicorn.Config("main:app", port=5000, log_level="debug")
|
||||
"""Starts the Uvicorn web server running the FastAPI application."""
|
||||
config = uvicorn.Config("main:app", host="0.0.0.0", port=5000, log_level="warning")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user