Skip to content

Build production-ready anonymous matchmaking backend (FastAPI, Redis, Postgres, Docker) - #43

Open
huzely wants to merge 1 commit into
masterfrom
codex/build-anonymous-matchmaking-chat-bot-system
Open

Build production-ready anonymous matchmaking backend (FastAPI, Redis, Postgres, Docker)#43
huzely wants to merge 1 commit into
masterfrom
codex/build-anonymous-matchmaking-chat-bot-system

Conversation

@huzely

@huzely huzely commented Feb 13, 2026

Copy link
Copy Markdown
Owner

Motivation

  • Provide a production-ready backend for an anonymous matchmaking chat service with JWT auth, WebSocket chat, Redis-based matchmaking queue, trust/reporting rules, ads, premium handling and admin APIs.
  • Centralize business rules (trust thresholds, session lengths, premium behavior, rate limits, DB/Redis urls) in configuration for easy tuning and safe defaults.
  • Ship database migrations and containerized deployment to make local and Docker-based deployment reproducible.

Description

  • Added full SQLAlchemy domain model implementations for User, ChatRoom, Message, Report, BlockedPair, Ad, DailyVerificationToken, and UserVerification in app/models/models.py and a baseline Alembic migration at alembic/versions/0001_initial.py.
  • Implemented auth, user, matchmaking, reporting, ads and admin REST routes (app/api/*.py), including daily verification (POST /user/verify) and admin daily-token management (POST /admin/daily-token).
  • Implemented matchmaking and queue logic in app/services/matchmaking.py using Redis lists with a premium priority queue, gender/trust/block checks, duplicate-session prevention and session expiry handling, plus app/services/trust.py for penalties and regeneration and app/services/ads.py for ad rotation and impression tracking.
  • Implemented anonymous WebSocket chat (/ws/chat/{room_id}) with JWT token validation, room membership & expiry enforcement, message persistence, and a simple connection manager in app/websocket/manager.py.
  • Added maintenance utilities (scripts/maintenance.py and app/services/maintenance.py) to auto-close expired rooms and release cooldowns/suspensions, plus app/core/* for config, Redis client and security helpers, Docker artifacts (Dockerfile, docker-compose.yml), requirements.txt, and a comprehensive README.md with run and deploy instructions.
  • Configuration locations: trust thresholds, penalties and regen are in app/core/config.py (overridable via .env / .env.example), daily token is managed via POST /admin/daily-token, ad selection logic is in app/services/ads.py and ad CRUD is exposed in admin routes, and premium benefits are controlled via config and POST /user/premium/activate.

Testing

  • Ran automated bytecode compilation with python -m compileall app scripts alembic which completed successfully to validate module-level syntax and imports.
  • No unit tests were added in this change; runtime validation expected via integration (DB/Redis) and recommended next steps are to run alembic upgrade head and exercise endpoints (or add automated integration tests).

Codex Task

Summary by CodeRabbit

Release Notes

  • New Features

    • Anonymous user matching and real-time WebSocket-based chat
    • User authentication with JWT tokens
    • Daily verification system with trust score management
    • User reporting and blocking functionality
    • Premium features with extended session duration
    • Admin analytics dashboard with user statistics and ad management
    • Rate limiting for API protection
  • Documentation

    • Added comprehensive setup and deployment instructions
  • Chores

    • Initial database schema and migrations
    • Docker containerization for simplified deployment

@coderabbitai

coderabbitai Bot commented Feb 13, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a production-ready FastAPI backend for an anonymous matchmaking chat application, featuring JWT authentication, Redis-based matchmaking queues, trust/reporting system, ad delivery, premium features, WebSocket chat, admin dashboard APIs, and Docker containerization with database migrations.

Changes

Cohort / File(s) Summary
Configuration & Environment
.env.example, requirements.txt, Dockerfile, docker-compose.yml, alembic.ini
Adds environment variable templates, Python dependencies (FastAPI, SQLAlchemy, Redis, Alembic), Docker setup with api/db/redis services, and Alembic configuration for PostgreSQL migrations.
Database Schema & Migrations
app/models/models.py, app/db/base.py, alembic/.../*
Defines SQLAlchemy ORM models (User, ChatRoom, Message, Report, BlockedPair, Ad, DailyVerificationToken, UserVerification) with enums (Gender, AccountStatus, RoomStatus). Includes Alembic setup and initial migration creating all tables, indexes, and constraints.
Core Framework & Configuration
app/core/config.py, app/core/security.py, app/core/deps.py, app/core/redis_client.py, app/db/session.py, app/main.py
Establishes settings from environment, JWT token creation/validation, password hashing via bcrypt, async Redis client, SQLAlchemy async session factory, and FastAPI app with lifespan management and router registration.
Authentication & Authorization APIs
app/api/auth.py
Provides /auth/register and /auth/login endpoints with password hashing, JWT token generation, and credential validation.
User & Verification APIs
app/api/user.py
Implements /user/me, /user/verify (daily token validation), and /user/premium/activate endpoints with user session context and verification tracking.
Matchmaking & Chat APIs
app/api/match.py, app/api/ws.py, app/websocket/manager.py
Adds /match/find (enqueue/dequeue via Redis), /match/cancel, /match/rooms/{id}/report, /match/rooms/{id}/close with ad injection. Implements WebSocket chat via JWT token validation, message persistence, and bidirectional broadcasting through ConnectionManager.
Admin APIs
app/api/admin.py
Provides admin endpoints for user/chat statistics, trust distribution, report listing, blocked pairs, daily token management, and ad CRUD operations.
Business Service Layer
app/services/matchmaking.py, app/services/trust.py, app/services/ads.py, app/services/maintenance.py
Implements Redis-backed matchmaking queues with gender/trust/block validation, trust penalty/regeneration logic on reports, ad rotation by impressions, and maintenance tasks (expire rooms, release user restrictions).
Data Schemas
app/schemas/auth.py, app/schemas/common.py, app/models/__init__.py
Defines Pydantic models for API requests/responses: RegisterRequest, LoginRequest, TokenResponse, UserOut, MatchResponse, VerifyRequest, ReportRequest, PremiumActivateRequest.
Maintenance & Documentation
scripts/maintenance.py, README.md
Adds async maintenance runner orchestrating room expiry and user restriction cleanup. Updates README from Decrypt tool documentation to comprehensive FastAPI matchmaking backend guide with architecture, features, models, setup, and deployment instructions.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant API as FastAPI<br/>Match API
    participant Redis as Redis<br/>Queues
    participant DB as Database
    participant WS as WebSocket<br/>Manager

    Client->>API: POST /match/find<br/>(JWT token)
    activate API
    API->>DB: Check active ChatRoom
    alt Existing Room
        DB-->>API: Return room
        API-->>Client: MatchResponse(room_id)
    else No Active Room
        API->>Redis: Check premium queue
        API->>Redis: Check standard queue
        alt Candidate Found
            Redis-->>API: Pop candidate
            API->>DB: Validate candidate<br/>(status, trust, gender, blocks)
            alt Valid Match
                DB-->>API: ✓ All checks pass
                API->>DB: Create ChatRoom<br/>(set expiry)
                DB-->>API: Return room
                API-->>Client: MatchResponse(room_id)
            else Invalid Candidate
                API->>Redis: Try next candidate
            end
        else No Candidate
            API->>Redis: Enqueue requester
            API-->>Client: {"message": "queued"}
        end
    end
    deactivate API

    Client->>WS: WebSocket connect<br/>/ws/chat/{room_id}<br/>?token=JWT
    activate WS
    WS->>DB: Decode JWT & validate room/user
    DB-->>WS: ✓ Room & user valid
    WS->>WS: Register connection
    WS-->>Client: Connection established
    
    Client->>WS: Send message
    activate WS
    WS->>DB: Persist Message
    DB-->>WS: ✓ Saved
    WS->>WS: Broadcast to other participant
    deactivate WS
    
    Client->>API: POST /match/rooms/{id}/report
    activate API
    API->>DB: Record Report
    API->>DB: Create BlockedPair
    API->>DB: Apply trust penalty
    DB-->>API: ✓ Saved
    API-->>Client: {"message": "reported"}
    deactivate API
    
    Client->>API: POST /match/rooms/{id}/close
    activate API
    API->>DB: Mark room closed
    alt User not Premium
        API->>DB: Fetch next ad
        API-->>Client: {close + ad payload}
    else Premium User
        API-->>Client: {close only}
    end
    deactivate API
    deactivate WS
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A whisker-twitching tale of code so grand,
Where matches bloom through Redis' command,
WebSockets dance and trust tales unfold,
A matchmaking bot worth more than gold!
With schemas, migrations, and Docker so neat,
This backend hop is delightfully sweet! 🎉

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Build production-ready anonymous matchmaking backend (FastAPI, Redis, Postgres, Docker)' accurately summarizes the main objective and scope of this large, multi-faceted changeset, clearly conveying the primary intent of delivering a complete backend system.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into master

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/build-anonymous-matchmaking-chat-bot-system

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68b3e3f866

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

for queue in [MatchmakingService.premium_queue_key, MatchmakingService.queue_key]:
size = await redis.llen(queue)
for _ in range(size):
raw_id = await redis.lpop(queue)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Requeue skipped candidates during matchmaking scan

try_match pops each queued user with lpop before eligibility checks and never pushes skipped users back. That means anyone rejected for this requester (gender mismatch, trust threshold, blocked pair, etc.) is silently removed from matchmaking, so users can disappear from the queue and never get matched unless they manually enqueue again.

Useful? React with 👍 / 👎.

Comment on lines +68 to +70
await db.commit()
await db.refresh(room)
return room

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove requester from queue after room creation

When a match is found, the function returns immediately after creating the room without clearing the requester's existing queue entry. If a user calls /match/find while already queued and is matched against someone ahead of them, their stale queue item remains and can be matched again later, resulting in multiple active rooms for the same user.

Useful? React with 👍 / 👎.

Comment thread alembic/env.py
Comment on lines +24 to +28
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Switch Alembic migration runner to async engine

This Alembic environment still uses the synchronous engine_from_config path even though alembic.ini is configured with postgresql+asyncpg. With asyncpg URLs, alembic upgrade head requires the async migration pattern (async_engine_from_config with run_sync), so migrations cannot run successfully with the current setup.

Useful? React with 👍 / 👎.

Comment thread app/api/match.py
raise HTTPException(status_code=403, detail="Not your room")

target_id = room.user_b_id if user.id == room.user_a_id else room.user_a_id
db.add(Report(room_id=room_id, reporter_id=user.id, reported_id=target_id, reason=payload.reason))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate reports before applying trust penalties

The report endpoint inserts a new report unconditionally and later applies trust penalties, but there is no check for an existing report by the same reporter against the same user in the same room. Repeating this call allows one client to stack penalties and push another account into cooldown/ban from a single chat session.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@alembic/env.py`:
- Around line 23-32: run_migrations_online currently builds a synchronous engine
with engine_from_config and uses connectable.connect(), which breaks with the
postgresql+asyncpg URL; replace the sync flow in run_migrations_online with the
async pattern: use async_engine_from_config(...) to create an AsyncEngine, open
it with "async with connectable.connect() as connection", and inside await
connection.run_sync(lambda sync_conn: context.configure(connection=sync_conn,
target_metadata=target_metadata)) followed by await connection.run_sync(lambda
_: context.run_migrations()) (or equivalent use of connection.run_sync for
context.begin_transaction()/run_migrations); also update imports to include
async_engine_from_config and change the existing noqa comment to "# noqa: F401"
to clarify intentional unused imports for model registration.

In `@alembic/versions/0001_initial.py`:
- Around line 105-115: The downgrade() in alembic/versions/0001_initial.py
currently drops tables and indexes but leaves the Postgres enum types (gender,
roomstatus, accountstatus) behind; update downgrade() to drop these enum types
after dropping dependent tables/indexes by executing DROP TYPE IF EXISTS for
each enum name (gender, roomstatus, accountstatus) or using the corresponding
sa.Enum(...).drop(...) pattern against the migration bind so a subsequent
upgrade can recreate them cleanly. Ensure you reference the enum names exactly
and perform the drops at the end of the downgrade() function.

In `@app/api/user.py`:
- Around line 37-47: The activate_premium endpoint currently flips user.premium
without verifying payment; change activate_premium to first check idempotency
(if user.premium is already True return 200/no-op), then call a payment
verification function (e.g., verify_payment(payment_reference) — implement or
inject a service) to validate payload.payment_reference and raise an HTTP error
if verification fails, and only on successful verification set user.premium =
True and persist inside the AsyncSession transaction (await db.commit()); also
create and insert an audit record (e.g., PremiumActivationLog with user id,
payment_reference, status, timestamp) or at minimum log the activation via the
logger, and if payment integration is not ready restrict the route to admins or
disable the endpoint until verify_payment is implemented.

In `@app/api/ws.py`:
- Around line 34-56: The WebSocket handler chat_socket currently holds an
AsyncSessionLocal for the entire connection and only catches
WebSocketDisconnect, which can leak manager state on other exceptions; change it
to ensure manager.disconnect(room_id, user_id) is always called by wrapping the
receive/send loop in try/finally (or try/except/finally) and move the DB work
into short-lived sessions per message (use async with AsyncSessionLocal() as db:
db.add(msg); await db.commit()) inside the loop; also catch JSON decoding errors
(e.g., ValueError/JSONDecodeError around websocket.receive_json()) so they don't
escape and leave stale connections, and ensure websocket is closed on fatal
errors as appropriate while still calling manager.disconnect in the finally
block.

In `@app/core/config.py`:
- Line 7: The config currently defines a hardcoded default secret_key
("change-me") in app/core/config.py which is insecure; remove the insecure
default so SECRET_KEY must be provided via environment or, if you keep a
dev-friendly default, add a startup validation in your application bootstrap
(e.g., in app/main.py or the FastAPI startup event) that checks settings.env and
settings.secret_key and raises an error if settings.env != "dev" and
settings.secret_key == "change-me" (or is unset), ensuring the app fails to
start in non-dev environments without a real secret.

In `@app/core/deps.py`:
- Around line 30-33: decode_token can return a non-numeric "sub" string which
will cause int(user_id) to raise ValueError and produce a 500; change the code
around decode_token / user_id to validate and safely parse the subject before
calling db.get(User, ...). Specifically, replace the direct int(user_id) call in
the auth flow (the user_id variable returned by decode_token) with a safe parse:
try to convert to int (or check isdigit() for your use case) and if conversion
fails raise an HTTPException (use status.HTTP_401_UNAUTHORIZED or the
appropriate auth status) so a tampered or non-numeric sub results in a 401
instead of an uncaught 500; ensure this validation occurs before calling
db.get(User, int(...)).

In `@app/services/matchmaking.py`:
- Around line 41-71: The current loop uses redis.lpop on
MatchmakingService.premium_queue_key and MatchmakingService.queue_key, which
removes every candidate even if they fail checks; fix by performing a
non-destructive scan: for each queue, read all IDs (e.g., LRANGE) or pop while
collecting skipped raw_ids, evaluate each candidate (using _gender_compatible,
_is_blocked, requester, db, trust checks) and only remove the matched candidate
from the queue; re-enqueue any skipped IDs back to the same queue in their
original order (or use LREM to remove only the matched candidate after selecting
it) so invalid candidates are not permanently lost.

In `@docker-compose.yml`:
- Around line 8-11: The docker-compose environment block currently hardcodes
SECRET_KEY ("super-secret-key"), which is insecure; update the compose to accept
the secret via an environment variable or Docker secret (e.g., reference an env
var like ${SECRET_KEY} or use a Docker secret named SECRET_KEY) and remove the
literal value, add documentation or a compose comment that the SECRET_KEY must
be overridden in production, and ensure any service startup (JWT code) reads the
same env var; look for the environment: SECRET_KEY entry in the compose file and
replace it with the env-var/secret reference and accompanying note.

In `@requirements.txt`:
- Line 7: The requirements pin uses python-jose[cryptography]==3.3.0 which has
critical vulnerabilities (CVE-2024-33663/CVE-2024-33664); update the dependency
in requirements.txt to python-jose[cryptography]>=3.4.0 to pull the patched
release, or if you prefer to switch libraries, replace python-jose with
PyJWT[crypto]==2.11.0 and update JWT usages: change imports and calls from jose
(used in app/core/security.py and app/api/ws.py) to jwt and adjust any
encode()/decode() call sites accordingly (APIs are compatible for signed JWTs),
then run tests and regenerate lock files.
🟠 Major comments (15)
Dockerfile-1-14 (1)

1-14: ⚠️ Potential issue | 🟠 Major

Add a non-root user to the container.

Running the container as root is a security risk. If the application is compromised, the attacker gains root privileges inside the container. Trivy correctly flags this (DS-0002).

🔒 Proposed fix
 FROM python:3.11-slim
 
 WORKDIR /app
 
 ENV PYTHONDONTWRITEBYTECODE=1
 ENV PYTHONUNBUFFERED=1
 
 COPY requirements.txt .
 RUN pip install --no-cache-dir -r requirements.txt
 
 COPY . .
 
+RUN addgroup --system app && adduser --system --ingroup app app
+USER app
+
 EXPOSE 8000
 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
requirements.txt-1-1 (1)

1-1: ⚠️ Potential issue | 🟠 Major

Upgrade fastapi and python-multipart to patch known DoS and file-write vulnerabilities.

All four vulnerabilities are confirmed:

  • python-multipart==0.0.17: CVE-2024-53981 (DoS via malformed boundary) and CVE-2026-24486 (arbitrary file write with non-default UPLOAD_KEEP_FILENAME=True config). Fixed in 0.0.22.
  • starlette==0.41.3 (transitive via fastapi==0.115.5): CVE-2025-54121 (DoS on large file uploads; fixed in 0.47.2) and CVE-2025-62727 (Range header CPU exhaustion; fixed in 0.49.1).

Bump to latest patched versions: fastapi>=0.129.0, python-multipart>=0.0.22, and starlette>=0.49.1 (automatically resolved by upgrading fastapi).

app/websocket/manager.py-17-24 (1)

17-24: ⚠️ Potential issue | 🟠 Major

Unhandled exceptions during broadcast will skip remaining recipients.

If any ws.send_json(payload) call raises (e.g., the peer disconnected), the loop aborts and remaining users in the room never receive the message. Wrap individual sends in a try/except to isolate failures.

🛡️ Proposed fix
     async def send_to_other(self, room_id: int, sender_id: int, payload: dict):
         for uid, ws in self.room_connections.get(room_id, {}).items():
             if uid != sender_id:
-                await ws.send_json(payload)
+                try:
+                    await ws.send_json(payload)
+                except Exception:
+                    pass  # peer already disconnected
 
     async def send_to_room(self, room_id: int, payload: dict):
         for ws in self.room_connections.get(room_id, {}).values():
-            await ws.send_json(payload)
+            try:
+                await ws.send_json(payload)
+            except Exception:
+                pass
app/models/models.py-53-53 (1)

53-53: ⚠️ Potential issue | 🟠 Major

datetime.utcnow produces naive datetimes for timezone-aware columns and is deprecated since Python 3.12.

All created_at columns (and this pattern repeats on lines 63, 75, 86, 96, 107, 116, 126) use default=datetime.utcnow, which returns a naive datetime (no tzinfo). Storing naive datetimes in DateTime(timezone=True) columns means PostgreSQL will silently assume the server's local timezone, which may not be UTC.

Use datetime.now(timezone.utc) (via a lambda or functools.partial) instead:

Proposed fix (apply to all `created_at` columns)
-from datetime import datetime, date
+from datetime import datetime, date, timezone
 ...
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+    )
alembic.ini-3-3 (1)

3-3: ⚠️ Potential issue | 🟠 Major

Remove hardcoded database credentials from alembic.ini and use environment variables instead.

alembic/env.py does not override the sqlalchemy.url setting from alembic.ini—it reads directly from it (lines 17, 25). The hardcoded credentials postgresql+asyncpg://postgres:postgres@db:5432/matchbot will always be used for migrations, duplicating the value in app/core/config.py. While config.py supports loading from .env via Pydantic, Alembic's configuration bypasses this entirely.

Move credentials to environment variables and update both alembic.ini and config.py to read from ${DATABASE_URL} or similar, ensuring the same source of truth for all database connections.

app/services/ads.py-9-14 (1)

9-14: ⚠️ Potential issue | 🟠 Major

Missing .limit(1) — fetches all active ads into memory.

db.scalar(select(...)) executes the full query and returns the first row in Python, but without .limit(1) the database will return every active ad row. As the ads table grows, this becomes an unnecessary full-table scan and memory hog.

Proposed fix
-        ad = await db.scalar(select(Ad).where(Ad.active.is_(True)).order_by(Ad.impressions.asc(), Ad.id.asc()))
+        ad = await db.scalar(
+            select(Ad)
+            .where(Ad.active.is_(True))
+            .order_by(Ad.impressions.asc(), Ad.id.asc())
+            .limit(1)
+        )
app/services/trust.py-22-29 (1)

22-29: ⚠️ Potential issue | 🟠 Major

db.commit() inside try_regenerate breaks atomicity when called from release_user_restrictions.

release_user_restrictions (in app/services/maintenance.py, Line 42) calls try_regenerate inside a loop that mutates multiple users, then issues its own db.commit() at Line 43. When try_regenerate commits mid-loop, it flushes all pending session changes — including partially processed users — destroying the all-or-nothing guarantee. If an error occurs after some iterations, you get a partial update with no way to roll back.

Let the caller own the transaction boundary. Remove the commit from try_regenerate and let apply_report_penalty remain the only committing method (or push commits to callers there too for consistency).

Proposed fix
     `@staticmethod`
     async def try_regenerate(user: User, db: AsyncSession) -> None:
         if not user.last_report_at:
             return
         delta = datetime.now(timezone.utc) - user.last_report_at.replace(tzinfo=timezone.utc)
         if delta.days >= settings.trust_regen_days:
             user.trust_score = min(100, user.trust_score + settings.trust_regen_amount)
-            await db.commit()
app/api/admin.py-33-33 (1)

33-33: ⚠️ Potential issue | 🟠 Major

String literal "active" instead of RoomStatus.active enum.

Line 33 compares ChatRoom.status == "active" while Line 26 correctly uses AccountStatus.active. Depending on the DB driver and enum configuration, the raw string may not match the stored enum value, silently returning 0 for active rooms.

Proposed fix
-    active_rooms = await db.scalar(select(func.count(ChatRoom.id)).where(ChatRoom.status == "active"))
+    active_rooms = await db.scalar(select(func.count(ChatRoom.id)).where(ChatRoom.status == RoomStatus.active))

Add the missing import:

 from app.models.models import (
     AccountStatus,
     Ad,
     BlockedPair,
     ChatRoom,
     DailyVerificationToken,
     Report,
+    RoomStatus,
     User,
 )
app/services/matchmaking.py-17-21 (1)

17-21: ⚠️ Potential issue | 🟠 Major

enqueue only deduplicates in the target queue — user can exist in both queues.

lrem on Line 20 only cleans the queue being pushed to. If a user's premium status changes between calls, their old entry in the other queue persists. try_match could then pop the same user from both queues, or the user could be matched while already in a room.

Proposed fix
     `@staticmethod`
     async def enqueue(user: User) -> None:
         queue = MatchmakingService.premium_queue_key if user.premium and settings.premium_matchmaking_boost else MatchmakingService.queue_key
-        await redis.lrem(queue, 0, str(user.id))
+        await redis.lrem(MatchmakingService.queue_key, 0, str(user.id))
+        await redis.lrem(MatchmakingService.premium_queue_key, 0, str(user.id))
         await redis.rpush(queue, str(user.id))
app/services/matchmaking.py-29-39 (1)

29-39: ⚠️ Potential issue | 🟠 Major

Active-room check doesn't verify expires_at, inconsistent with caller.

try_match returns an active room without checking expiry (Lines 30–39), but the caller find_match in app/api/match.py Lines 27–35 explicitly filters ChatRoom.expires_at > datetime.now(timezone.utc). An expired-but-not-yet-cleaned-up room will be returned by try_match, blocking the user from getting a new match.

Proposed fix
         active_room = await db.scalar(
             select(ChatRoom).where(
                 and_(
                     ChatRoom.status == RoomStatus.active,
+                    ChatRoom.expires_at > datetime.now(timezone.utc),
                     or_(ChatRoom.user_a_id == requester.id, ChatRoom.user_b_id == requester.id),
                 )
             )
         )
app/api/admin.py-55-58 (1)

55-58: ⚠️ Potential issue | 🟠 Major

GET /blocked-pairs returns all rows with no pagination.

This loads the entire blocked_pairs table into memory. As the system scales, this becomes a performance and memory issue. Add limit/offset pagination similar to the reports endpoint.

Proposed fix
 `@router.get`("/blocked-pairs")
-async def blocked_pairs(db: AsyncSession = Depends(get_db)):
-    rows = (await db.execute(select(BlockedPair))).scalars().all()
+async def blocked_pairs(limit: int = 100, offset: int = 0, db: AsyncSession = Depends(get_db)):
+    rows = (await db.execute(select(BlockedPair).offset(offset).limit(limit))).scalars().all()
app/api/match.py-20-44 (1)

20-44: ⚠️ Potential issue | 🟠 Major

response_model=MatchResponse | dict causes unreliable response serialization.

FastAPI's union response_model handling can produce unexpected validation errors or silently drop fields depending on which branch matches first. When the queued dict is returned, the MatchResponse validator runs first and may emit confusing validation warnings in logs.

Return a single response schema or drop response_model to let FastAPI pass through the raw dict:

Proposed fix (simplest)
-@router.post("/find", response_model=MatchResponse | dict)
+@router.post("/find")
 async def find_match(user: User = Depends(ensure_verified_today), db: AsyncSession = Depends(get_db)):
app/api/auth.py-15-29 (1)

15-29: ⚠️ Potential issue | 🟠 Major

Blocking bcrypt calls on the async event loop.

hash_password (Line 22) uses passlib/bcrypt which is CPU-intensive and synchronous. Calling it directly in an async def handler blocks the event loop for the duration of the hash computation (~200-400ms per call), stalling all concurrent requests. The same applies to verify_password on Line 35.

Run these in a thread pool:

Proposed fix
+import asyncio
 ...
 async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
     existing = await db.scalar(select(User).where(User.username == payload.username))
     if existing:
         raise HTTPException(status_code=400, detail="Username already exists")
+    hashed = await asyncio.to_thread(hash_password, payload.password)
     user = User(
         username=payload.username,
-        password_hash=hash_password(payload.password),
+        password_hash=hashed,
         gender=payload.gender,
         preferred_match_gender=payload.preferred_match_gender,
     )
 async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
     user = await db.scalar(select(User).where(User.username == payload.username))
-    if not user or not verify_password(payload.password, user.password_hash):
+    if not user or not await asyncio.to_thread(verify_password, payload.password, user.password_hash):
         raise HTTPException(status_code=401, detail="Invalid credentials")
app/api/admin.py-61-70 (1)

61-70: ⚠️ Potential issue | 🟠 Major

POST endpoints receive data as query parameters instead of request body.

token: str on Line 62 (and similarly title, payload, active on Lines 80 and 89) are bare typed parameters, which FastAPI interprets as query parameters. For POST/PATCH endpoints, secrets and payloads should be in the request body. The daily token in particular is being passed in the URL/query string, which gets logged in access logs and browser history.

Wrap these in Pydantic models or annotate with Body(...):

Proposed fix for daily-token (apply same pattern to ads endpoints)
+from pydantic import BaseModel
+
+class DailyTokenRequest(BaseModel):
+    token: str
+
 `@router.post`("/daily-token")
-async def set_daily_token(token: str, db: AsyncSession = Depends(get_db)):
+async def set_daily_token(payload: DailyTokenRequest, db: AsyncSession = Depends(get_db)):
     today = date.today()
     existing = await db.scalar(select(DailyVerificationToken).where(DailyVerificationToken.effective_date == today))
     if existing:
-        existing.token = token
+        existing.token = payload.token
     else:
-        db.add(DailyVerificationToken(token=token, effective_date=today))
+        db.add(DailyVerificationToken(token=payload.token, effective_date=today))
app/api/ws.py-16-20 (1)

16-20: 🛠️ Refactor suggestion | 🟠 Major

Duplicate JWT decode logic — reuse decode_token from app/core/security.py.

get_room_and_user manually calls jwt.decode(...) replicating what decode_token already does. This creates a maintenance burden: any change to token validation (e.g., adding audience or issuer claims) must be duplicated here.

Proposed fix
-from jose import JWTError, jwt
+from app.core.security import decode_token
 ...
 async def get_room_and_user(token: str, room_id: int, db: AsyncSession):
-    try:
-        user_id = int(jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])["sub"])
-    except (JWTError, KeyError, ValueError):
+    sub = decode_token(token)
+    if not sub:
+        return None, None
+    try:
+        user_id = int(sub)
+    except (ValueError, TypeError):
         return None, None
🟡 Minor comments (8)
docker-compose.yml-5-7 (1)

5-7: ⚠️ Potential issue | 🟡 Minor

depends_on should use health checks to avoid startup race conditions.

Without condition: service_healthy, the API container starts as soon as db and redis containers are created — not when they are ready to accept connections. This causes connection errors on startup.

Proposed fix
     depends_on:
-      - db
-      - redis
+      db:
+        condition: service_healthy
+      redis:
+        condition: service_healthy

And add healthchecks to the db and redis services:

  db:
    image: postgres:16
    # ...
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7
    # ...
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5
app/core/deps.py-45-57 (1)

45-57: ⚠️ Potential issue | 🟡 Minor

date.today() uses server-local time — could mismatch UTC-based verification records.

If verification tokens are created or verified_date values are stored relative to UTC, but date.today() resolves to a different calendar day in the server's local timezone, users near midnight boundaries will fail verification or pass when they shouldn't.

Proposed fix
-    from datetime import date
+    from datetime import date, datetime, timezone
     from app.models.models import UserVerification

     verified = await db.scalar(
         select(UserVerification).where(
             UserVerification.user_id == user.id,
-            UserVerification.verified_date == date.today(),
+            UserVerification.verified_date == datetime.now(timezone.utc).date(),
         )
     )
app/core/deps.py-16-23 (1)

16-23: ⚠️ Potential issue | 🟡 Minor

Rate-limit race between INCR and EXPIRE; shared "unknown" bucket.

Two concerns:

  1. If the process dies between INCR (which auto-creates the key) and EXPIRE, the key lives forever and the counter grows unboundedly. Using a single INCR + EXPIRE pipeline (or Lua script, or SET NX EX for the first hit) eliminates the window.

  2. When request.client is None, all such requests share the ratelimit:unknown bucket. Behind a reverse proxy that doesn't set request.client, this means a single bucket for all traffic. Consider reading X-Forwarded-For (with caution) or rejecting requests with no identifiable client.

Atomic INCR + EXPIRE using a pipeline
 async def rate_limit(request: Request):
     ip = request.client.host if request.client else "unknown"
     key = f"ratelimit:{ip}"
-    count = await redis.incr(key)
-    if count == 1:
-        await redis.expire(key, 60)
+    async with redis.pipeline(transaction=True) as pipe:
+        pipe.incr(key)
+        pipe.expire(key, 60)
+        results = await pipe.execute()
+    count = results[0]
     if count > settings.rate_limit_per_minute:
         raise HTTPException(status_code=429, detail="Rate limit exceeded")
app/services/trust.py-26-26 (1)

26-26: ⚠️ Potential issue | 🟡 Minor

replace(tzinfo=...) silently overwrites any existing timezone — prefer conversion.

last_report_at is stored in a DateTime(timezone=True) column and written with datetime.now(timezone.utc), so asyncpg will return it as a timezone-aware datetime. .replace(tzinfo=timezone.utc) doesn't convert — it discards the original offset and stamps UTC on top. While the value is currently always UTC, this will silently produce wrong results if a non-UTC offset ever appears.

Safer alternative
-        delta = datetime.now(timezone.utc) - user.last_report_at.replace(tzinfo=timezone.utc)
+        last_report = user.last_report_at
+        if last_report.tzinfo is None:
+            last_report = last_report.replace(tzinfo=timezone.utc)
+        delta = datetime.now(timezone.utc) - last_report
app/api/match.py-53-72 (1)

53-72: ⚠️ Potential issue | 🟡 Minor

Reports are restricted to active rooms only.

Line 56 rejects reports when room.status != RoomStatus.active. Users cannot report abusive behavior after a room has been closed or expired. This seems overly restrictive since most users would report after the conversation ends.

Consider allowing reports on recently-closed rooms (e.g., within a time window).

app/api/match.py-76-92 (1)

76-92: ⚠️ Potential issue | 🟡 Minor

Room can be closed multiple times — no idempotency guard.

If close_room is called twice for the same room, it succeeds both times, overwriting closed_at and potentially serving duplicate ads to non-premium users. Consider returning early or 409 if the room is already closed.

Proposed fix
     room = await db.get(ChatRoom, room_id)
     if not room:
         raise HTTPException(status_code=404, detail="Room not found")
+    if room.status == RoomStatus.closed:
+        return {"message": "Room already closed"}
     if user.id not in [room.user_a_id, room.user_b_id]:
         raise HTTPException(status_code=403, detail="Not your room")
app/api/auth.py-17-28 (1)

17-28: ⚠️ Potential issue | 🟡 Minor

Race condition on username uniqueness yields 500 instead of 400.

Between await db.scalar(...) and await db.commit(), a concurrent request can insert the same username. The DB unique constraint prevents corruption, but the unhandled IntegrityError surfaces as a 500. Catch it to return a clean 400.

Proposed fix
+from sqlalchemy.exc import IntegrityError
 ...
     db.add(user)
-    await db.commit()
+    try:
+        await db.commit()
+    except IntegrityError:
+        await db.rollback()
+        raise HTTPException(status_code=400, detail="Username already exists")
     await db.refresh(user)
app/api/user.py-21-34 (1)

21-34: ⚠️ Potential issue | 🟡 Minor

date.today() uses server-local time — prefer UTC.

date.today() returns the date in the server's local timezone. If the server isn't in UTC, the verification date can be off by a day relative to other UTC-based logic in the codebase (e.g., token expiry, session durations). The same concern applies to the admin set_daily_token endpoint.

Proposed fix
-from datetime import date
+from datetime import date, datetime, timezone
 ...
-    today = date.today()
+    today = datetime.now(timezone.utc).date()
🧹 Nitpick comments (13)
requirements.txt (1)

8-8: passlib is effectively unmaintained — consider future migration.

passlib==1.7.4 hasn't had a release since 2020 and has open compatibility issues with newer bcrypt versions. No critical CVEs currently, but for a "production-ready" system, consider migrating to bcrypt directly or argon2-cffi in a follow-up.

app/db/session.py (1)

10-12: Technically incorrect return type for async generator.

get_db uses yield, making it an AsyncGenerator, not a plain AsyncSession. FastAPI's Depends() handles this correctly at runtime, but the type hint may confuse static analysis tools and IDEs.

Proposed fix
+from collections.abc import AsyncGenerator
+
-async def get_db() -> AsyncSession:
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
     async with AsyncSessionLocal() as session:
         yield session
alembic/env.py (1)

7-7: Replace blanket # noqa with specific rule code.

Ruff (RUF100) flags the blanket noqa. The import is intentional (registers models on Base.metadata), so use the specific suppression code.

Proposed fix
-from app.models import models  # noqa
+from app.models import models  # noqa: F401
alembic/versions/0001_initial.py (3)

35-35: ix_users_id index on the primary key is redundant.

Postgres automatically creates a unique index for the primary key column. This explicit index is a no-op that adds clutter.

Proposed fix
-    op.create_index("ix_users_id", "users", ["id"])

And remove the corresponding drop in downgrade:

-    op.drop_index("ix_users_id", table_name="users")

37-46: Consider adding indexes on chat_rooms.user_a_id and chat_rooms.user_b_id.

Queries that look up active rooms for a given user (e.g., matchmaking duplicate-session prevention, user history) will scan the full table without indexes on these foreign key columns. Postgres does not auto-index FK columns.


87-93: Consider adding an index on daily_verification_tokens.token.

Token lookups during user verification will require a sequential scan without an index on this column. The effective_date has a unique constraint (which creates an index), but the token column — which is the value users submit — does not.

app/websocket/manager.py (1)

27-27: In-memory singleton won't work with multiple worker processes.

manager is process-local, so running with multiple Uvicorn workers (or multiple replicas) means WebSocket connections in different processes can't communicate. This is fine for a single-worker deployment, but worth documenting as a known limitation. For multi-process support, a Redis pub/sub broadcast layer would be needed.

app/models/models.py (1)

38-53: Consider adding an index on User.status and User.trust_score.

The matchmaking service filters users by status and trust_score. As the user base grows, queries filtering on these columns without indexes will degrade. Consider adding index=True to status and trust_score columns, or a composite index.

app/core/redis_client.py (1)

6-6: Variable redis shadows the redis package name.

Naming the client instance redis shadows the imported module, which can cause confusion. A name like redis_client would be clearer and avoid accidental misuse.

Proposed rename
-redis = Redis.from_url(settings.redis_url, decode_responses=True)
+redis_client = Redis.from_url(settings.redis_url, decode_responses=True)

This would require updating all import sites (e.g., from app.core.redis_client import redisimport redis_client).

app/services/trust.py (1)

11-20: Same commit-ownership concern applies here — apply_report_penalty commits eagerly.

If this method is ever composed into a larger transactional flow (e.g., reporting also creates a Report row in match.py), the db.commit() here will flush the partial state. For now it works because the API endpoint doesn't add further DB writes after calling this, but pushing commit responsibility to callers would make both trust methods consistently composable.

app/api/ws.py (1)

24-28: Fragile timezone handling on expires_at.

room.expires_at.replace(tzinfo=timezone.utc) silently overwrites any existing timezone info. Since the model declares DateTime(timezone=True), the DB should return tz-aware datetimes. If the driver returns UTC-aware values, replace is harmless but misleading; if it returns a different offset, replace silently corrupts the value. Prefer an explicit conversion or assert the value is already tz-aware.

Suggested approach
-    if datetime.now(timezone.utc) > room.expires_at.replace(tzinfo=timezone.utc):
+    expires_at = room.expires_at if room.expires_at.tzinfo else room.expires_at.replace(tzinfo=timezone.utc)
+    if datetime.now(timezone.utc) > expires_at:
app/api/match.py (1)

10-10: Unused import: Message.

Message is imported but not referenced anywhere in this file.

Proposed fix
-from app.models.models import AccountStatus, BlockedPair, ChatRoom, Message, Report, RoomStatus, User
+from app.models.models import AccountStatus, BlockedPair, ChatRoom, Report, RoomStatus, User
app/api/admin.py (1)

73-76: GET /ads also lacks pagination.

Same unbounded query concern as blocked-pairs. Consider adding limit/offset.

Comment thread alembic/env.py
Comment on lines +23 to +32
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check alembic.ini for the database URL configuration
fd 'alembic.ini' --exec cat {}

Repository: huzely/Decrypt

Length of output: 614


🏁 Script executed:

# Also verify the current state of alembic/env.py to confirm it matches the review
fd 'alembic/env.py' -x head -40 {}

Repository: huzely/Decrypt

Length of output: 40


🏁 Script executed:

# Display the full content of alembic/env.py to verify current state
cat -n alembic/env.py | head -50

Repository: huzely/Decrypt

Length of output: 1397


🏁 Script executed:

# Also check imports to confirm the current driver setup
grep -n "engine_from_config\|async_engine_from_config\|import asyncio" alembic/env.py

Repository: huzely/Decrypt

Length of output: 148


run_migrations_online() uses synchronous connection — incompatible with postgresql+asyncpg:// driver.

The alembic.ini configures sqlalchemy.url = postgresql+asyncpg://..., but alembic/env.py uses synchronous operations: engine_from_config() with connectable.connect(). This is incompatible; asyncpg requires async/await patterns and will raise a runtime error when migrations run in online mode.

Update to the async migration pattern using async_engine_from_config, async with connectable.connect(), and connection.run_sync():

Async-compatible online migration
 from logging.config import fileConfig
 
 from alembic import context
-from sqlalchemy import engine_from_config, pool
+from sqlalchemy import pool
+from sqlalchemy.ext.asyncio import async_engine_from_config
+import asyncio
 
 from app.db.base import Base
-from app.models import models  # noqa
+from app.models import models  # noqa: F401
 
 config = context.config
 if config.config_file_name is not None:
     fileConfig(config.config_file_name)
 
 target_metadata = Base.metadata
 
 
 def run_migrations_offline() -> None:
     url = config.get_main_option("sqlalchemy.url")
     context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
     with context.begin_transaction():
         context.run_migrations()
 
 
-def run_migrations_online() -> None:
-    connectable = engine_from_config(
+async def run_async_migrations() -> None:
+    connectable = async_engine_from_config(
         config.get_section(config.config_ini_section, {}),
         prefix="sqlalchemy.",
         poolclass=pool.NullPool,
     )
-    with connectable.connect() as connection:
-        context.configure(connection=connection, target_metadata=target_metadata)
-        with context.begin_transaction():
-            context.run_migrations()
+    async with connectable.connect() as connection:
+        await connection.run_sync(do_run_migrations)
+    await connectable.dispose()
+
+
+def do_run_migrations(connection) -> None:
+    context.configure(connection=connection, target_metadata=target_metadata)
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    asyncio.run(run_async_migrations())
 
 
 if context.is_offline_mode():
     run_migrations_offline()
 else:
     run_migrations_online()

Also clarify the noqa directive on line 7 to # noqa: F401 (unused import suppression is intentional for model registration).

🤖 Prompt for AI Agents
In `@alembic/env.py` around lines 23 - 32, run_migrations_online currently builds
a synchronous engine with engine_from_config and uses connectable.connect(),
which breaks with the postgresql+asyncpg URL; replace the sync flow in
run_migrations_online with the async pattern: use async_engine_from_config(...)
to create an AsyncEngine, open it with "async with connectable.connect() as
connection", and inside await connection.run_sync(lambda sync_conn:
context.configure(connection=sync_conn, target_metadata=target_metadata))
followed by await connection.run_sync(lambda _: context.run_migrations()) (or
equivalent use of connection.run_sync for
context.begin_transaction()/run_migrations); also update imports to include
async_engine_from_config and change the existing noqa comment to "# noqa: F401"
to clarify intentional unused imports for model registration.

Comment on lines +105 to +115
def downgrade() -> None:
op.drop_table("user_verifications")
op.drop_table("daily_verification_tokens")
op.drop_table("ads")
op.drop_table("blocked_pairs")
op.drop_table("reports")
op.drop_index("ix_messages_room_id", table_name="messages")
op.drop_table("messages")
op.drop_table("chat_rooms")
op.drop_index("ix_users_id", table_name="users")
op.drop_table("users")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

downgrade() does not drop Postgres enum types — re-running upgrade after downgrade will fail.

Postgres enum types (gender, roomstatus, accountstatus) are created by sa.Enum(...) during upgrade but are not dropped in downgrade. After a downgrade, these types persist, and a subsequent upgrade will fail with ProgrammingError: type "gender" already exists.

Proposed fix — drop enums in downgrade
 def downgrade() -> None:
     op.drop_table("user_verifications")
     op.drop_table("daily_verification_tokens")
     op.drop_table("ads")
     op.drop_table("blocked_pairs")
     op.drop_table("reports")
     op.drop_index("ix_messages_room_id", table_name="messages")
     op.drop_table("messages")
     op.drop_table("chat_rooms")
     op.drop_index("ix_users_id", table_name="users")
     op.drop_table("users")
+    sa.Enum(name="gender").drop(op.get_bind(), checkfirst=True)
+    sa.Enum(name="roomstatus").drop(op.get_bind(), checkfirst=True)
+    sa.Enum(name="accountstatus").drop(op.get_bind(), checkfirst=True)
🤖 Prompt for AI Agents
In `@alembic/versions/0001_initial.py` around lines 105 - 115, The downgrade() in
alembic/versions/0001_initial.py currently drops tables and indexes but leaves
the Postgres enum types (gender, roomstatus, accountstatus) behind; update
downgrade() to drop these enum types after dropping dependent tables/indexes by
executing DROP TYPE IF EXISTS for each enum name (gender, roomstatus,
accountstatus) or using the corresponding sa.Enum(...).drop(...) pattern against
the migration bind so a subsequent upgrade can recreate them cleanly. Ensure you
reference the enum names exactly and perform the drops at the end of the
downgrade() function.

Comment thread app/api/user.py
Comment on lines +37 to +47
@router.post("/premium/activate")
async def activate_premium(
payload: PremiumActivateRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not payload.payment_reference:
raise HTTPException(status_code=400, detail="Payment reference required")
user.premium = True
await db.commit()
return {"message": "Premium activated", "premium": True}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Premium activation performs no payment verification — any user can self-grant premium.

The endpoint accepts any non-empty payment_reference string and immediately sets user.premium = True. There is no call to a payment gateway, no validation of the reference, no idempotency check, and no audit log. Any authenticated user can activate premium for free.

At minimum, log the activation and gate it behind actual payment verification. If the payment integration isn't ready yet, this endpoint should be disabled or admin-only.

🧰 Tools
🪛 Ruff (0.15.0)

[warning] 40-40: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


[warning] 41-41: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
In `@app/api/user.py` around lines 37 - 47, The activate_premium endpoint
currently flips user.premium without verifying payment; change activate_premium
to first check idempotency (if user.premium is already True return 200/no-op),
then call a payment verification function (e.g.,
verify_payment(payment_reference) — implement or inject a service) to validate
payload.payment_reference and raise an HTTP error if verification fails, and
only on successful verification set user.premium = True and persist inside the
AsyncSession transaction (await db.commit()); also create and insert an audit
record (e.g., PremiumActivationLog with user id, payment_reference, status,
timestamp) or at minimum log the activation via the logger, and if payment
integration is not ready restrict the route to admins or disable the endpoint
until verify_payment is implemented.

Comment thread app/api/ws.py
Comment on lines +34 to +56
@router.websocket("/ws/chat/{room_id}")
async def chat_socket(websocket: WebSocket, room_id: int, token: str):
async with AsyncSessionLocal() as db:
room, user_id = await get_room_and_user(token, room_id, db)
if not room:
await websocket.close(code=1008)
return

await manager.connect(room_id, user_id, websocket)
await manager.send_to_room(room_id, {"type": "system", "message": "Stranger connected"})

try:
while True:
data = await websocket.receive_json()
content = str(data.get("message", "")).strip()
if not content:
continue
msg = Message(room_id=room_id, sender_id=user_id, content=content)
db.add(msg)
await db.commit()
await manager.send_to_other(room_id, user_id, {"type": "message", "from": "Stranger", "message": content})
except WebSocketDisconnect:
manager.disconnect(room_id, user_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Unhandled exceptions in the message loop leave stale connections in the manager.

Only WebSocketDisconnect is caught. If the client sends malformed JSON (receive_json raises JSONDecodeError), or a DB error occurs during db.commit(), the exception propagates without calling manager.disconnect, leaking the entry. Additionally, the AsyncSession is held open for the entire WebSocket lifetime, tying up a connection-pool slot for what may be a very long-lived session.

Wrap the loop in a broader try/finally for cleanup, and consider creating short-lived sessions for each DB write instead of reusing one session across the full connection:

Proposed fix
 `@router.websocket`("/ws/chat/{room_id}")
 async def chat_socket(websocket: WebSocket, room_id: int, token: str):
     async with AsyncSessionLocal() as db:
         room, user_id = await get_room_and_user(token, room_id, db)
         if not room:
             await websocket.close(code=1008)
             return
 
         await manager.connect(room_id, user_id, websocket)
         await manager.send_to_room(room_id, {"type": "system", "message": "Stranger connected"})
 
-        try:
-            while True:
-                data = await websocket.receive_json()
-                content = str(data.get("message", "")).strip()
-                if not content:
-                    continue
-                msg = Message(room_id=room_id, sender_id=user_id, content=content)
-                db.add(msg)
-                await db.commit()
-                await manager.send_to_other(room_id, user_id, {"type": "message", "from": "Stranger", "message": content})
-        except WebSocketDisconnect:
-            manager.disconnect(room_id, user_id)
+    try:
+        while True:
+            data = await websocket.receive_json()
+            content = str(data.get("message", "")).strip()
+            if not content:
+                continue
+            if len(content) > 2000:
+                continue
+            async with AsyncSessionLocal() as db:
+                msg = Message(room_id=room_id, sender_id=user_id, content=content)
+                db.add(msg)
+                await db.commit()
+            await manager.send_to_other(room_id, user_id, {"type": "message", "from": "Stranger", "message": content})
+    except WebSocketDisconnect:
+        pass
+    except Exception:
+        await websocket.close(code=1011)
+    finally:
+        manager.disconnect(room_id, user_id)
🤖 Prompt for AI Agents
In `@app/api/ws.py` around lines 34 - 56, The WebSocket handler chat_socket
currently holds an AsyncSessionLocal for the entire connection and only catches
WebSocketDisconnect, which can leak manager state on other exceptions; change it
to ensure manager.disconnect(room_id, user_id) is always called by wrapping the
receive/send loop in try/finally (or try/except/finally) and move the DB work
into short-lived sessions per message (use async with AsyncSessionLocal() as db:
db.add(msg); await db.commit()) inside the loop; also catch JSON decoding errors
(e.g., ValueError/JSONDecodeError around websocket.receive_json()) so they don't
escape and leave stale connections, and ensure websocket is closed on fatal
errors as appropriate while still calling manager.disconnect in the finally
block.

Comment thread app/core/config.py
class Settings(BaseSettings):
app_name: str = "Anonymous Matchmaking"
env: str = "dev"
secret_key: str = "change-me"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Hardcoded default secret_key is a security risk.

If the SECRET_KEY environment variable is unset, the app silently falls back to "change-me", making all JWTs trivially forgeable. Remove the default to force explicit configuration, or add a startup check that rejects the placeholder in non-dev environments.

Proposed fix
-    secret_key: str = "change-me"
+    secret_key: str  # no default — must be set via env var

Or, if you want to keep a dev-friendly default, validate at startup:

# in app/main.py or a startup event
if settings.env != "dev" and settings.secret_key == "change-me":
    raise RuntimeError("SECRET_KEY must be set in production")
🧰 Tools
🪛 Ruff (0.15.0)

[error] 7-7: Possible hardcoded password assigned to: "secret_key"

(S105)

🤖 Prompt for AI Agents
In `@app/core/config.py` at line 7, The config currently defines a hardcoded
default secret_key ("change-me") in app/core/config.py which is insecure; remove
the insecure default so SECRET_KEY must be provided via environment or, if you
keep a dev-friendly default, add a startup validation in your application
bootstrap (e.g., in app/main.py or the FastAPI startup event) that checks
settings.env and settings.secret_key and raises an error if settings.env !=
"dev" and settings.secret_key == "change-me" (or is unset), ensuring the app
fails to start in non-dev environments without a real secret.

Comment thread app/core/deps.py
Comment on lines +30 to +33
user_id = decode_token(credentials.credentials)
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = await db.get(User, int(user_id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

int(user_id) will raise an unhandled ValueError → HTTP 500 on a tampered token.

decode_token returns the "sub" claim as a str | None. A valid signature with a non-numeric sub (e.g., a username accidentally stored there, or a maliciously crafted claim) passes the if not user_id check, then int(user_id) blows up with an uncaught ValueError, surfacing as a 500 to the client.

Proposed fix
     user_id = decode_token(credentials.credentials)
     if not user_id:
         raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
-    user = await db.get(User, int(user_id))
+    try:
+        uid = int(user_id)
+    except (ValueError, TypeError):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+    user = await db.get(User, uid)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
user_id = decode_token(credentials.credentials)
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = await db.get(User, int(user_id))
user_id = decode_token(credentials.credentials)
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
try:
uid = int(user_id)
except (ValueError, TypeError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = await db.get(User, uid)
🤖 Prompt for AI Agents
In `@app/core/deps.py` around lines 30 - 33, decode_token can return a non-numeric
"sub" string which will cause int(user_id) to raise ValueError and produce a
500; change the code around decode_token / user_id to validate and safely parse
the subject before calling db.get(User, ...). Specifically, replace the direct
int(user_id) call in the auth flow (the user_id variable returned by
decode_token) with a safe parse: try to convert to int (or check isdigit() for
your use case) and if conversion fails raise an HTTPException (use
status.HTTP_401_UNAUTHORIZED or the appropriate auth status) so a tampered or
non-numeric sub results in a 401 instead of an uncaught 500; ensure this
validation occurs before calling db.get(User, int(...)).

Comment on lines +41 to +71
for queue in [MatchmakingService.premium_queue_key, MatchmakingService.queue_key]:
size = await redis.llen(queue)
for _ in range(size):
raw_id = await redis.lpop(queue)
if not raw_id:
continue
candidate_id = int(raw_id)
if candidate_id == requester.id:
continue
candidate = await db.get(User, candidate_id)
if not candidate or candidate.status != AccountStatus.active:
continue
if candidate.trust_score < settings.matchmaking_min_trust_score:
continue
if not MatchmakingService._gender_compatible(requester, candidate):
continue
if await MatchmakingService._is_blocked(db, requester.id, candidate.id):
continue

expires_minutes = settings.premium_session_minutes if requester.premium or candidate.premium else settings.base_session_minutes
room = ChatRoom(
user_a_id=requester.id,
user_b_id=candidate.id,
status=RoomStatus.active,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
)
db.add(room)
await db.commit()
await db.refresh(room)
return room
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: lpop permanently removes skipped candidates from the queue.

The loop pops each candidate via lpop (Line 44), then filters through validity checks (Lines 48–58). Candidates that fail any check (self-match, inactive, low trust, gender mismatch, blocked) are consumed and never re-enqueued. Over time, the queues drain to empty even when valid users are waiting.

Skipped candidates must be pushed back into the queue, or use a non-destructive iteration strategy (e.g., LRANGE + LREM only the matched candidate).

Proposed fix — collect skipped candidates and re-enqueue
         for queue in [MatchmakingService.premium_queue_key, MatchmakingService.queue_key]:
             size = await redis.llen(queue)
+            skipped = []
             for _ in range(size):
                 raw_id = await redis.lpop(queue)
                 if not raw_id:
                     continue
                 candidate_id = int(raw_id)
                 if candidate_id == requester.id:
+                    skipped.append(raw_id)
                     continue
                 candidate = await db.get(User, candidate_id)
                 if not candidate or candidate.status != AccountStatus.active:
                     continue
                 if candidate.trust_score < settings.matchmaking_min_trust_score:
                     continue
                 if not MatchmakingService._gender_compatible(requester, candidate):
+                    skipped.append(raw_id)
                     continue
                 if await MatchmakingService._is_blocked(db, requester.id, candidate.id):
+                    skipped.append(raw_id)
                     continue
 
                 expires_minutes = settings.premium_session_minutes if requester.premium or candidate.premium else settings.base_session_minutes
                 room = ChatRoom(
                     user_a_id=requester.id,
                     user_b_id=candidate.id,
                     status=RoomStatus.active,
                     expires_at=datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
                 )
                 db.add(room)
                 await db.commit()
                 await db.refresh(room)
+                # Re-enqueue skipped candidates
+                for s in skipped:
+                    await redis.rpush(queue, s)
                 return room
+            # No match found in this queue — re-enqueue all skipped
+            for s in skipped:
+                await redis.rpush(queue, s)
         return None
🤖 Prompt for AI Agents
In `@app/services/matchmaking.py` around lines 41 - 71, The current loop uses
redis.lpop on MatchmakingService.premium_queue_key and
MatchmakingService.queue_key, which removes every candidate even if they fail
checks; fix by performing a non-destructive scan: for each queue, read all IDs
(e.g., LRANGE) or pop while collecting skipped raw_ids, evaluate each candidate
(using _gender_compatible, _is_blocked, requester, db, trust checks) and only
remove the matched candidate from the queue; re-enqueue any skipped IDs back to
the same queue in their original order (or use LREM to remove only the matched
candidate after selecting it) so invalid candidates are not permanently lost.

Comment thread docker-compose.yml
Comment on lines +8 to +11
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/matchbot
REDIS_URL: redis://redis:6379/0
SECRET_KEY: super-secret-key

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Hardcoded SECRET_KEY is a critical security risk.

super-secret-key is trivially guessable. If this compose file is used as-is (or as a template) in any non-local environment, all JWTs are forgeable. Use an environment variable or Docker secret, and document that the value must be overridden.

Proposed fix
     environment:
       DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/matchbot
       REDIS_URL: redis://redis:6379/0
-      SECRET_KEY: super-secret-key
+      SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
🧰 Tools
🪛 Checkov (3.2.334)

[medium] 9-10: Basic Auth Credentials

(CKV_SECRET_4)

🤖 Prompt for AI Agents
In `@docker-compose.yml` around lines 8 - 11, The docker-compose environment block
currently hardcodes SECRET_KEY ("super-secret-key"), which is insecure; update
the compose to accept the secret via an environment variable or Docker secret
(e.g., reference an env var like ${SECRET_KEY} or use a Docker secret named
SECRET_KEY) and remove the literal value, add documentation or a compose comment
that the SECRET_KEY must be overridden in production, and ensure any service
startup (JWT code) reads the same env var; look for the environment: SECRET_KEY
entry in the compose file and replace it with the env-var/secret reference and
accompanying note.

Comment thread requirements.txt
asyncpg==0.30.0
alembic==1.14.0
redis==5.2.0
python-jose[cryptography]==3.3.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

python-jose 3.3.0 GHSA-6c5p-j8vq-pqhj algorithm confusion vulnerability

💡 Result:

GHSA-6c5p-j8vq-pqhj (CVE-2024-33663) – python-jose “algorithm confusion” (affects 3.3.0)

  • What it is: An algorithm-confusion / signature-verification flaw where python-jose (through 3.3.0) can incorrectly accept/verify JWTs when using certain key formats—specifically OpenSSH ECDSA keys (and other key formats)—in a way that can enable HMAC verification with an asymmetric public key under some misuse/edge conditions. This is noted as similar to CVE-2022-29217. [1][2][3]
  • Affected versions: python-jose < 3.4.0 (i.e., 3.3.0 is vulnerable). [1][2][3]
  • Fix / patched version: Upgrade to python-jose 3.4.0 or later. [1][2][3]
  • Severity: Reported as Critical (CVSS 9.3, v4) on GitHub’s advisory; OSV also lists severity entries and confirms affected/fixed versions. [1][3]

Sources:
[1] GitHub Advisory Database (GHSA-6c5p-j8vq-pqhj)
[2] OSV / PyPA advisory entry (PYSEC-2024-232)
[3] OSV entry for GHSA-6c5p-j8vq-pqhj


🌐 Web query:

python-jose GHSA-cjwg-qfpm-7377 DoS compressed JWE

💡 Result:

GHSA-cjwg-qfpm-7377 (CVE-2024-33664): python-jose DoS via compressed JWE (“JWT bomb”)

  • What it is: python-jose can be forced into resource exhaustion (CPU/memory) during JWE decode/decrypt by supplying a crafted JWE with an extremely high compression ratio (“JWT bomb”). This can cause a Denial of Service. [1][2]
  • Affected versions: python-jose < 3.4.0 (i.e., through 3.3.0). [1][2]
  • Fixed version: 3.4.0. [1][2]
  • Severity (CVSS v3.1): 5.3 (Medium); network exploitable, no auth/user interaction required; impacts availability. [1]
  • Action: Upgrade: pip install -U python-jose==3.4.0 (or later). [1][2]
  • Distro note (example): Debian’s tracker has listed the Debian-packaged python-jose (e.g., bookworm’s 3.3.0+dfsg-4) as vulnerable (fix status may depend on your distro packaging). [3]

Sources: GitHub Advisory Database entry for GHSA-cjwg-qfpm-7377/CVE-2024-33664 [1], OSV PYSEC-2024-233 [2], Debian security tracker [3].

References:
[1] GHSA-cjwg-qfpm-7377
[2] https://osv.dev/PYSEC-2024-233
[3] https://security-tracker.debian.org/tracker/CVE-2024-29370


🌐 Web query:

python-jose unmaintained maintenance status 2024 2025

💡 Result:

python-jose does not appear to be “unmaintained” in 2024–2025 based on release and security-fix activity:

  • New releases shipped in 2025: PyPI shows 3.4.0 (Feb 18, 2025) and 3.5.0 (May 28, 2025). [1]
  • Security response / CVE fix landed: GitHub Advisory CVE-2024-33664 lists versions < 3.4.0 as affected and 3.4.0 as the patched version (advisory updated Feb 18, 2025). [2]
  • GitHub release notes confirm active maintenance work: the 3.4.0 release explicitly includes “Fix for CVE-2024-33664” and other compatibility/housekeeping updates; 3.5.0 adds Python version support changes and dependency updates. [3]
  • There was community concern about inactivity in late 2023 (“Is python-jose still supported?”), but subsequent 2025 releases indicate the project resumed maintenance. [4]

Bottom line: it may have looked unmaintained during the 2021–2023 gap, but as of Feb–May 2025 it shows clear signs of active maintenance (including security patches). [1][3]

Sources: [1] PyPI release history, [2] GitHub Advisory (GHSA-cjwg-qfpm-7377 / CVE-2024-33664), [3] GitHub Releases page, [4] GitHub issue #340.


🌐 Web query:

PyJWT replacement for python-jose JWT library comparison

💡 Result:

Can PyJWT replace python-jose?

Sometimes. It depends on which JOSE features you rely on.

Quick comparison (PyJWT vs python-jose)

Capability PyJWT python-jose
Core focus JWT (RFC 7519) encode/decode Broader JOSE toolkit
JWS (signed JWTs) Yes Yes (HS*/RS*/ES* listed in docs) [2]
JWE (encrypted JWTs) Not a focus (PyJWT is primarily JWT/JWS) Yes (has JWE module) [1]
JWK / key management Limited compared to full JOSE stacks Yes (has JWK module) [1]
Cryptography backends Uses cryptography-based ecosystem (PyJWT project) Explicit multi-backend design (cryptography, pycryptodome, native-python) [1]
Typical API jwt.encode(payload, key, algorithm=...) / jwt.decode(...) Similar high-level jwt.encode(...) / jwt.decode(...) [1][3]

When PyJWT is a good replacement

Use PyJWT if you only need signed JWTs (JWS)—e.g., HS256/RS256/ES256 style tokens—and you don’t need JWE encryption or broader JOSE/JWK workflows. PyJWT’s basic usage matches this well (encode/decode). [3]

(PyJWT is actively maintained; the GitHub repo shows a “Latest” release tag 2.11.0 (Jan 30, 2026).) [3]

When PyJWT is not a drop-in replacement

If your python-jose usage includes any of the following, PyJWT alone usually won’t cover it:

  • JWE (token encryption)
  • A more complete JOSE surface area (JWS+JWE+JWK with richer primitives)

Practical alternatives if you need “python-jose-like” breadth

  • joserfc (Authlib’s JOSE-focused library): explicitly supports JWS, JWE, JWK, JWT, and includes migration notes from both PyJWT and python-jose. [5][4]
  • Authlib (authlib.jose.jwt): mature JOSE/JWT implementation with encode/decode and claim validation patterns. [6]

Rule of thumb

  • Signed JWTs onlyPyJWT [3]
  • Signed + encrypted tokens (JWE) or full JOSE/JWK needsjoserfc or Authlib [5][6]

Sources: python-jose README/backends [1], python-jose JWS algorithms [2], PyJWT README/releases [3], joserfc overview + migration notes [5][4], Authlib JWT docs [6].


🏁 Script executed:

# Check how python-jose is used in the codebase
find . -type f -name "*.py" | head -20

Repository: huzely/Decrypt

Length of output: 478


🏁 Script executed:

# Search for imports and usage of jose
rg "from jose|import jose|python-jose" --type py -A 2

Repository: huzely/Decrypt

Length of output: 318


🏁 Script executed:

# Check if security.py exists and examine it
find . -type f -name "security.py" | xargs cat 2>/dev/null | head -50

Repository: huzely/Decrypt

Length of output: 995


python-jose 3.3.0 has critical vulnerabilities — upgrade to 3.4.0+

Two security advisories affect python-jose==3.3.0:

Both are patched in python-jose 3.4.0 (released Feb 18, 2025).

Recommended fix: Upgrade python-jose[cryptography] to 3.4.0 or later. This requires no code changes—encode() and decode() APIs are identical.

Alternatively, migrate to PyJWT[crypto]==2.11.0 if preferred; the codebase uses only signed JWTs (JWS), which PyJWT fully supports. This would require changing imports (josejwt) in app/core/security.py and app/api/ws.py.

🧰 Tools
🪛 OSV Scanner (2.3.2)

[CRITICAL] 7-7: python-jose 3.3.0: undefined

(PYSEC-2024-232)


[CRITICAL] 7-7: python-jose 3.3.0: undefined

(PYSEC-2024-233)


[CRITICAL] 7-7: python-jose 3.3.0: python-jose algorithm confusion with OpenSSH ECDSA keys

(GHSA-6c5p-j8vq-pqhj)


[CRITICAL] 7-7: python-jose 3.3.0: python-jose denial of service via compressed JWE content

(GHSA-cjwg-qfpm-7377)

🤖 Prompt for AI Agents
In `@requirements.txt` at line 7, The requirements pin uses
python-jose[cryptography]==3.3.0 which has critical vulnerabilities
(CVE-2024-33663/CVE-2024-33664); update the dependency in requirements.txt to
python-jose[cryptography]>=3.4.0 to pull the patched release, or if you prefer
to switch libraries, replace python-jose with PyJWT[crypto]==2.11.0 and update
JWT usages: change imports and calls from jose (used in app/core/security.py and
app/api/ws.py) to jwt and adjust any encode()/decode() call sites accordingly
(APIs are compatible for signed JWTs), then run tests and regenerate lock files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant