Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import asyncio
import json
import logging
import sys
from typing import Any, List, Optional

from engines.ai_engine import get_ai_engine
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from utils.cache import (
cache_result,
github_commits_cache,
github_repos_cache,
make_github_commits_key,
make_github_repos_key,
make_social_key,
social_cache,
)
from utils.security import is_safe_url

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S",
)

# Resilient Windows subprocess execution configuration for asyncio
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
Expand Down Expand Up @@ -74,13 +90,22 @@ async def handle_scan(request: ScanRequest):
raise HTTPException(status_code=400, detail="Target cannot be empty.")

if _is_github_input(input_data):
git_data: dict | None = await analyze_github_target(input_data)
git_data: dict | None = await cache_result(
github_repos_cache,
make_github_repos_key(input_data),
analyze_github_target,
input_data,
)

if "error" in git_data:
raise HTTPException(status_code=400, detail=git_data["error"])

social_result = await scan_username(
git_data["username"], include_variations=request.include_variations
social_result = await cache_result(
social_cache,
make_social_key(git_data["username"], request.include_variations),
scan_username,
git_data["username"],
include_variations=request.include_variations,
)

owner_name = git_data.get("username", "")
Expand All @@ -103,8 +128,12 @@ async def handle_scan(request: ScanRequest):
}

else:
social_result = await scan_username(
input_data, include_variations=request.include_variations
social_result = await cache_result(
social_cache,
make_social_key(input_data, request.include_variations),
scan_username,
input_data,
include_variations=request.include_variations,
)

if "error" in social_result:
Expand All @@ -121,7 +150,16 @@ async def handle_scan(request: ScanRequest):
git_data = None

if github_targets:
git_tasks = [analyze_github_target(user) for user in github_targets if user]
git_tasks = [
cache_result(
github_repos_cache,
make_github_repos_key(user),
analyze_github_target,
user,
)
for user in github_targets
if user
]
git_results = await asyncio.gather(*git_tasks)

aggregated_interesting = []
Expand Down Expand Up @@ -190,7 +228,13 @@ async def handle_scan_commits(request: CommitRequest):
status_code=400, detail="Repository name and username are required."
)

result = await fetch_repo_commits(username, repo_name)
result = await cache_result(
github_commits_cache,
make_github_commits_key(username, repo_name),
fetch_repo_commits,
username,
repo_name,
)

if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"cachetools>=5.5.0",
"curl-cffi>=0.15.0",
"fastapi[standard]>=0.138.0",
"httpx>=0.28.1",
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
cachetools>=5.5.0
fastapi[standard]
uvicorn
httpx
Expand Down
48 changes: 48 additions & 0 deletions backend/utils/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import logging
from typing import Any

from cachetools import TTLCache

logger = logging.getLogger(__name__)

SOCIAL_TTL = 600
REPOS_TTL = 1800
COMMITS_TTL = 3600

social_cache: TTLCache = TTLCache(maxsize=256, ttl=SOCIAL_TTL)
github_repos_cache: TTLCache = TTLCache(maxsize=128, ttl=REPOS_TTL)
github_commits_cache: TTLCache = TTLCache(maxsize=256, ttl=COMMITS_TTL)


def make_social_key(username: str, include_variations: bool) -> tuple:
return ("social", username, include_variations)


def make_github_repos_key(target_input: str) -> tuple:
return ("repos", target_input)


def make_github_commits_key(username: str, repo_name: str) -> tuple:
return ("commits", username, repo_name)


async def cache_result(cache: TTLCache, key: tuple, func, *args, **kwargs) -> Any:
if key in cache:
logger.info("Cache HIT for %s", key)
return cache[key]

result = await func(*args, **kwargs)

if isinstance(result, dict) and "error" in result:
logger.info("Cache MISS for %s (not caching — error response)", key)
return result

cache[key] = result
logger.info("Cache MISS for %s", key)
return result


def clear_all_caches() -> None:
social_cache.clear()
github_repos_cache.clear()
github_commits_cache.clear()
11 changes: 11 additions & 0 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.