From 71dd48f5f512cc86696170efb7e445195f81d5b1 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 19 Mar 2026 04:07:22 +0100 Subject: [PATCH 01/26] feat: update Dockerfile to include build-essential --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1f4f394..2a5a81d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12-slim # Install PostgreSQL client libraries and build tools RUN apt-get update \ - && apt-get install -y --no-install-recommends libpq-dev gcc \ + && apt-get install -y --no-install-recommends libpq-dev build-essential \ && rm -rf /var/lib/apt/lists/* ENV PYTHONDONTWRITEBYTECODE=1 From cf721d2a888fc0e54ca4044a71982e1692638347 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 19 Mar 2026 04:07:26 +0100 Subject: [PATCH 02/26] feat: add GitHub Actions workflow for Docker image build and push --- .github/workflows/docker-publish.yml | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b9415a0 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,44 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set image name + shell: bash + run: | + echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_PAT }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} From 161eff1a048e1b7313e6b116318cc371aa178170 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 19 Mar 2026 21:13:00 +0100 Subject: [PATCH 03/26] chore: exclude env files from Docker build context --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index f8ffc8f..84a857e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,4 +13,6 @@ __pycache__ *.db *.sqlite3 .env +.env.staging +.env.* db/schema.sql From a3daa627a1ac3820b0843f0a808630390091349e Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 19 Mar 2026 21:13:10 +0100 Subject: [PATCH 04/26] build: update system libs for headless OpenCV --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 2a5a81d..076b031 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ FROM python:3.12-slim # Install PostgreSQL client libraries and build tools RUN apt-get update \ && apt-get install -y --no-install-recommends libpq-dev build-essential \ + libglib2.0-0 libgfortran5 \ && rm -rf /var/lib/apt/lists/* ENV PYTHONDONTWRITEBYTECODE=1 From 5ee394ecfd5e57436495971e2375136387b99253 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 19 Mar 2026 21:13:21 +0100 Subject: [PATCH 05/26] deps: switch to opencv-python-headless --- pyproject.toml | 2 +- uv.lock | 22 ++-------------------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f87619..ddf1649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "pyotp>=2.9.0", "redis>=7.2.1", "setuptools>=82.0.0", - "opencv-python>=4.13.0.92", + "opencv-python-headless>=4.13.0.92", "numpy>=2.4.3", "insightface>=0.7.3", "onnxruntime>=1.24.4", diff --git a/uv.lock b/uv.lock index c5f1f36..e758c3d 100644 --- a/uv.lock +++ b/uv.lock @@ -311,7 +311,7 @@ dependencies = [ { name = "nats-py" }, { name = "numpy" }, { name = "onnxruntime" }, - { name = "opencv-python" }, + { name = "opencv-python-headless" }, { name = "passlib", extra = ["bcrypt"] }, { name = "psycopg" }, { name = "pydantic" }, @@ -342,7 +342,7 @@ requires-dist = [ { name = "nats-py", specifier = ">=2.14.0" }, { name = "numpy", specifier = ">=2.4.3" }, { name = "onnxruntime", specifier = ">=1.24.4" }, - { name = "opencv-python", specifier = ">=4.13.0.92" }, + { name = "opencv-python-headless", specifier = ">=4.13.0.92" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "psycopg", specifier = ">=3.3.3" }, { name = "pydantic", specifier = ">=2.12.5" }, @@ -1852,24 +1852,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] -[[package]] -name = "opencv-python" -version = "4.13.0.92" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, - { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, - { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, - { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, - { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, -] - [[package]] name = "opencv-python-headless" version = "4.13.0.92" From 62ab60a511a3408bcdb8f02849831f7d2b93e414 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 19 Mar 2026 21:22:43 +0100 Subject: [PATCH 06/26] ci: publish linux/amd64 and linux/arm64 images --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b9415a0..840f3c8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -24,6 +24,9 @@ jobs: run: | echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -39,6 +42,7 @@ jobs: with: context: . push: true + platforms: linux/amd64,linux/arm64 tags: | ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:${{ github.sha }} From d1ab59907476d2902022f6ae5e5f47977f50a989 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 19 Mar 2026 22:20:02 +0100 Subject: [PATCH 07/26] ci: update GHCR workflow and silence insightface mypy warning --- .github/workflows/docker-publish.yml | 25 ++++++++++++++++++------- app/service/face_embedding.py | 4 ++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 840f3c8..5f14801 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -14,7 +14,8 @@ jobs: build-and-push: runs-on: ubuntu-latest env: - IMAGE_NAME: ghcr.io/${{ github.repository }} + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} steps: - name: Checkout uses: actions/checkout@v4 @@ -22,7 +23,7 @@ jobs: - name: Set image name shell: bash run: | - echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -33,9 +34,18 @@ jobs: - name: Login to GHCR uses: docker/login-action@v3 with: - registry: ghcr.io + registry: ${{ env.REGISTRY }} username: ${{ github.actor }} - password: ${{ secrets.GHCR_PAT }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix= - name: Build and push uses: docker/build-push-action@v5 @@ -43,6 +53,7 @@ jobs: context: . push: true platforms: linux/amd64,linux/arm64 - tags: | - ${{ env.IMAGE_NAME }}:latest - ${{ env.IMAGE_NAME }}:${{ github.sha }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index 6f5b6a4..d4541a8 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -3,9 +3,9 @@ import asyncio from typing import List, Literal, Optional, Sequence, Tuple, TypedDict -import cv2 +import cv2 # type: ignore import numpy as np -from insightface.app import FaceAnalysis +from insightface.app import FaceAnalysis # type: ignore[import-untyped] from app.core.exceptions import AppException From 91f358d2ed44453bd00227cfd685620c847481b9 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 06:35:27 +0100 Subject: [PATCH 08/26] Add mobile session settings to config --- app/core/config.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/core/config.py b/app/core/config.py index 3267862..011970f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -31,6 +31,11 @@ class Settings(BaseSettings): POSTGRES_HOST: str = "localhost" POSTGRES_PORT: int = 5432 + # Mobile auth/session defaults + MOBILE_SESSION_LIMIT: int = 3 + MOBILE_SESSION_TTL_SECONDS: int = 180 + MOBILE_SESSION_DAYS: int = 7 + # Security jwt_secret: str jwt_algorithm: str = "HS256" From 42c48262a817ad7a1f946f13456de81048f3161d Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 06:35:31 +0100 Subject: [PATCH 09/26] Use config for mobile session limits and TTL --- app/service/users.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index ecfaf91..a7b3434 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -12,6 +12,7 @@ decode_refresh_mobile_token, Get_expiry_time, ) +from app.core.config import settings from app.infra.redis import RedisClient from app.schema.request.mobile.auth import MobileAuthRequest @@ -28,8 +29,8 @@ class AuthService: user_querier: user_queries.AsyncQuerier device_querier: device_queries.AsyncQuerier session_querier: session_queries.AsyncQuerier - SESSION_LIMIT = 3 - REDIS_SESSION_TTL = 180 + SESSION_LIMIT = settings.MOBILE_SESSION_LIMIT + REDIS_SESSION_TTL = settings.MOBILE_SESSION_TTL_SECONDS def __init__( self, @@ -84,7 +85,9 @@ async def mobile_register_login( raise AppException.forbidden("Maximum session limit reached") device_id = req.device_id - expires_at = datetime.now(timezone.utc) + timedelta(days=7) + expires_at = datetime.now(timezone.utc) + timedelta( + days=settings.MOBILE_SESSION_DAYS + ) device = await self.device_querier.create_device( arg=device_queries.CreateDeviceParams( From 80e7f0a4e8132113fd456d6ad84346981bef376c Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:38 +0100 Subject: [PATCH 10/26] Add blocked column migration and sqlc updates --- db/generated/models.py | 1 + db/generated/session.py | 21 ++++- db/generated/user.py | 87 ++++++++++++++++--- db/queries/session.sql | 5 ++ db/queries/user.sql | 16 ++++ migrations/sql/down/add-blocked-to-users.sql | 2 + migrations/sql/up/add-blocked-to-users.sql | 2 + .../versions/5b6615c9ab1d_merge_heads.py | 28 ++++++ .../9f1c3c6e9c1a_add_blocked_to_users.py | 25 ++++++ 9 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 migrations/sql/down/add-blocked-to-users.sql create mode 100644 migrations/sql/up/add-blocked-to-users.sql create mode 100644 migrations/versions/5b6615c9ab1d_merge_heads.py create mode 100644 migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py diff --git a/db/generated/models.py b/db/generated/models.py index 28a9da1..482ab59 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -203,6 +203,7 @@ class User: updated_at: datetime.datetime display_name: Optional[str] face_embedding: Optional[Any] + blocked: bool deleted_at: Optional[datetime.datetime] diff --git a/db/generated/session.py b/db/generated/session.py index 1b8e026..bc7b427 100644 --- a/db/generated/session.py +++ b/db/generated/session.py @@ -4,7 +4,7 @@ # source: session.sql import dataclasses import datetime -from typing import Optional +from typing import AsyncIterator, Optional import uuid import sqlalchemy @@ -51,6 +51,13 @@ """ +LIST_SESSIONS_BY_USER = """-- name: list_sessions_by_user \\:many +SELECT id, user_id, device_id, created_at, last_active, expires_at +FROM user_sessions +WHERE user_id = :p1 +""" + + UPDATE_SESSION_ACTIVITY = """-- name: update_session_activity \\:exec UPDATE user_sessions SET last_active = NOW() @@ -135,6 +142,18 @@ async def get_session_by_id(self, *, id: uuid.UUID) -> Optional[models.UserSessi expires_at=row[5], ) + async def list_sessions_by_user(self, *, user_id: uuid.UUID) -> AsyncIterator[models.UserSession]: + result = await self._conn.stream(sqlalchemy.text(LIST_SESSIONS_BY_USER), {"p1": user_id}) + async for row in result: + yield models.UserSession( + id=row[0], + user_id=row[1], + device_id=row[2], + created_at=row[3], + last_active=row[4], + expires_at=row[5], + ) + async def update_session_activity(self, *, id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(UPDATE_SESSION_ACTIVITY), {"p1": id}) diff --git a/db/generated/user.py b/db/generated/user.py index 2599d3a..823be6a 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -14,7 +14,7 @@ CREATE_USER = """-- name: create_user \\:one INSERT INTO users (email, hashed_password) VALUES (:p1, :p2) -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -25,33 +25,53 @@ GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at FROM users WHERE email = :p1 """ GET_USER_BY_ID = """-- name: get_user_by_id \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at FROM users WHERE id = :p1 """ LIST_USERS = """-- name: list_users \\:many -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 """ +SET_USER_BLOCKED = """-- name: set_user_blocked \\:one +UPDATE users +SET blocked = :p1, + updated_at = NOW() +WHERE id = :p2 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +""" + + SET_USER_EMBEDDING = """-- name: set_user_embedding \\:one UPDATE users SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +""" + + +UPDATE_USER = """-- name: update_user \\:one +UPDATE users +SET email = COALESCE(:p1, email), + display_name = COALESCE(:p2, display_name), + blocked = COALESCE(:p3, blocked), + updated_at = NOW() +WHERE id = :p4 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -60,7 +80,7 @@ SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -80,7 +100,8 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) async def delete_user(self, *, id: uuid.UUID) -> None: @@ -98,7 +119,8 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -113,7 +135,8 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -127,9 +150,26 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) + async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(SET_USER_BLOCKED), {"p1": blocked, "p2": id})).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + blocked=row[7], + deleted_at=row[8], + ) + async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[models.User]: row = (await self._conn.execute(sqlalchemy.text(SET_USER_EMBEDDING), {"p1": dollar_1, "p2": id})).first() if row is None: @@ -142,7 +182,29 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], + ) + + async def update_user(self, *, email: str, display_name: Optional[str], blocked: bool, id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_USER), { + "p1": email, + "p2": display_name, + "p3": blocked, + "p4": id, + })).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + blocked=row[7], + deleted_at=row[8], ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -157,5 +219,6 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) diff --git a/db/queries/session.sql b/db/queries/session.sql index 2a5b859..b22911e 100644 --- a/db/queries/session.sql +++ b/db/queries/session.sql @@ -28,6 +28,11 @@ SELECT * FROM user_sessions WHERE id = $1; +-- name: ListSessionsByUser :many +SELECT * +FROM user_sessions +WHERE user_id = $1; + -- name: UpdateSessionActivity :exec UPDATE user_sessions SET last_active = NOW() diff --git a/db/queries/user.sql b/db/queries/user.sql index b9e984e..bc3fdd8 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -20,6 +20,22 @@ SET hashed_password = $1, WHERE id = $2 RETURNING *; +-- name: UpdateUser :one +UPDATE users +SET email = COALESCE($1, email), + display_name = COALESCE($2, display_name), + blocked = COALESCE($3, blocked), + updated_at = NOW() +WHERE id = $4 +RETURNING *; + +-- name: SetUserBlocked :one +UPDATE users +SET blocked = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING *; + -- name: DeleteUser :exec DELETE FROM users WHERE id = $1; diff --git a/migrations/sql/down/add-blocked-to-users.sql b/migrations/sql/down/add-blocked-to-users.sql new file mode 100644 index 0000000..d9bcfd4 --- /dev/null +++ b/migrations/sql/down/add-blocked-to-users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +DROP COLUMN blocked; diff --git a/migrations/sql/up/add-blocked-to-users.sql b/migrations/sql/up/add-blocked-to-users.sql new file mode 100644 index 0000000..c35e6fd --- /dev/null +++ b/migrations/sql/up/add-blocked-to-users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN blocked BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/versions/5b6615c9ab1d_merge_heads.py b/migrations/versions/5b6615c9ab1d_merge_heads.py new file mode 100644 index 0000000..cea5228 --- /dev/null +++ b/migrations/versions/5b6615c9ab1d_merge_heads.py @@ -0,0 +1,28 @@ +"""merge_heads + +Revision ID: 5b6615c9ab1d +Revises: 9f1c3c6e9c1a, c3b8d0f1e2a4 +Create Date: 2026-03-20 02:33:56.591359 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5b6615c9ab1d' +down_revision: Union[str, Sequence[str], None] = ('9f1c3c6e9c1a', 'c3b8d0f1e2a4') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py b/migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py new file mode 100644 index 0000000..21b14d1 --- /dev/null +++ b/migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py @@ -0,0 +1,25 @@ +"""add-blocked-to-users + +Revision ID: 9f1c3c6e9c1a +Revises: 5ead72a95638 +Create Date: 2026-03-20 12:50:00.000000 + +""" +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +# revision identifiers, used by Alembic. +revision: str = "9f1c3c6e9c1a" +down_revision: Union[str, Sequence[str], None] = "5ead72a95638" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add-blocked-to-users") + + +def downgrade() -> None: + run_sql_down("add-blocked-to-users") From dc72df6e915df160bd6a583f3235e232221ba911 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:43 +0100 Subject: [PATCH 11/26] Add token blacklist and blocked checks in auth --- app/core/constant.py | 1 + app/core/token_blacklist.py | 23 ++++++++ app/deps/token_auth.py | 6 ++ app/service/users.py | 114 ++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 app/core/token_blacklist.py diff --git a/app/core/constant.py b/app/core/constant.py index 7d3f191..5188f40 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -4,6 +4,7 @@ class RedisKey(str, Enum): UserSession = "user_session" UserSessionByUser = "user_session:{user_id}" + BlacklistedSession = "blacklist:session:{session_id}" IMAGE_ALLOWED_TYPES = { "image/jpeg", diff --git a/app/core/token_blacklist.py b/app/core/token_blacklist.py new file mode 100644 index 0000000..3f22038 --- /dev/null +++ b/app/core/token_blacklist.py @@ -0,0 +1,23 @@ +from datetime import datetime, timezone + +from app.core.constant import RedisKey +from app.infra.redis import RedisClient + + +async def blacklist_session( + redis: RedisClient, + session_id: str, + expires_at: datetime | None = None, +) -> None: + ttl: int | None = None + if expires_at is not None: + ttl = int((expires_at - datetime.now(timezone.utc)).total_seconds()) + if ttl < 0: + ttl = 0 + key = RedisKey.BlacklistedSession.value.format(session_id=session_id) + await redis.set(key, "1", expire=ttl) + + +async def is_session_blacklisted(redis: RedisClient, session_id: str) -> bool: + key = RedisKey.BlacklistedSession.value.format(session_id=session_id) + return await redis.exists(key) diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index c5fe522..527e28b 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -5,6 +5,7 @@ from pydantic import BaseModel from app.container import get_container, Container from app.core.securite import decode_access_mobile_token +from app.core.token_blacklist import is_session_blacklisted security = HTTPBearer() @@ -31,6 +32,9 @@ async def get_current_mobile_user( session_id = uuid.UUID(session_id_str) + if await is_session_blacklisted(container.redis, session_id_str): + raise HTTPException(status_code=401, detail="Token is blacklisted") + # Validate session via SessionService session = await container.session_service.session_querier.get_session_by_id(id=session_id) if not session: @@ -43,6 +47,8 @@ async def get_current_mobile_user( user = await container.auth_service.user_querier.get_user_by_id(id=session.user_id) if not user: raise HTTPException(status_code=401, detail="User not found") + if user.blocked: + raise HTTPException(status_code=403, detail="User is blocked") return MobileUserSchema( user_id=user.id, diff --git a/app/service/users.py b/app/service/users.py index a7b3434..bcb6489 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -13,6 +13,7 @@ Get_expiry_time, ) from app.core.config import settings +from app.core.token_blacklist import blacklist_session, is_session_blacklisted from app.infra.redis import RedisClient from app.schema.request.mobile.auth import MobileAuthRequest @@ -54,6 +55,8 @@ async def mobile_register_login( user: User | None = None if existing_user is not None: + if existing_user.blocked: + raise AppException.forbidden("User is blocked") if not verify_password(req.password, existing_user.hashed_password or ""): raise AppException.unauthorized("Invalid credentials") user = existing_user @@ -140,6 +143,9 @@ async def refresh_token( if not session_id: raise AppException.unauthorized("Invalid refresh token") + if await is_session_blacklisted(redis, session_id): + raise AppException.unauthorized("Token is blacklisted") + session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) if not session: @@ -156,6 +162,12 @@ async def refresh_token( if not redis_session or redis_session != session_id: raise AppException.unauthorized("Session invalidated") + user = await self.user_querier.get_user_by_id(id=session.user_id) + if not user: + raise AppException.unauthorized("User not found") + if user.blocked: + raise AppException.forbidden("User is blocked") + await redis.expire(session_key, AuthService.REDIS_SESSION_TTL) new_access_token = create_acces_mobile_token(session_id) @@ -207,6 +219,9 @@ async def validate_session( redis: RedisClient, session_id: str, ) -> bool: + if await is_session_blacklisted(redis, session_id): + return False + session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) if not session: @@ -224,3 +239,102 @@ async def validate_session( async def get_user_by_id(self, user_id: uuid.UUID) -> User | None: return await self.user_querier.get_user_by_id(id=user_id) + + async def create_user( + self, + *, + email: str, + password: str, + display_name: str | None = None, + blocked: bool = False, + ) -> User: + hashed = hash_password(password) + user = await self.user_querier.create_user( + email=email, + hashed_password=hashed, + ) + if not user: + raise AppException.internal_error("Failed to create user") + + if display_name is not None or blocked: + updated = await self.user_querier.update_user( + email=user.email, + display_name=display_name, + blocked=blocked, + id=user.id, + ) + if not updated: + raise AppException.internal_error("Failed to update user") + return updated + + return user + + async def get_user(self, *, user_id: uuid.UUID) -> User: + user = await self.user_querier.get_user_by_id(id=user_id) + if not user: + raise AppException.not_found("User not found") + return user + + async def list_users(self, *, limit: int, offset: int) -> list[User]: + users: list[User] = [] + async for user in self.user_querier.list_users(limit=limit, offset=offset): + users.append(user) + return users + + async def update_user( + self, + *, + user_id: uuid.UUID, + email: str | None = None, + display_name: str | None = None, + blocked: bool | None = None, + ) -> User: + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + + new_email = email if email is not None else existing.email + new_display_name = ( + display_name if display_name is not None else existing.display_name + ) + new_blocked = blocked if blocked is not None else existing.blocked + + user = await self.user_querier.update_user( + email=new_email, + display_name=new_display_name, + blocked=new_blocked, + id=user_id, + ) + if not user: + raise AppException.internal_error("Failed to update user") + return user + + async def delete_user(self, *, user_id: uuid.UUID) -> User: + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + await self.user_querier.delete_user(id=user_id) + return existing + + async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: + user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) + if not user: + raise AppException.not_found("User not found") + + async for session in self.session_querier.list_sessions_by_user(user_id=user_id): + await blacklist_session( + redis=redis, + session_id=str(session.id), + expires_at=session.expires_at, + ) + + session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) + await redis.delete(session_key) + + return user + + async def unblock_user(self, *, user_id: uuid.UUID) -> User: + user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) + if not user: + raise AppException.not_found("User not found") + return user From b6ab722dde966cab69053b23afc4296989dfd340 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:47 +0100 Subject: [PATCH 12/26] Add admin user CRUD and block/unblock endpoints --- app/router/web/__init__.py | 2 + app/router/web/users.py | 155 ++++++++++++++++++++++++++++++++ app/schema/request/web/user.py | 15 ++++ app/schema/response/web/user.py | 13 +++ 4 files changed, 185 insertions(+) create mode 100644 app/router/web/users.py create mode 100644 app/schema/request/web/user.py create mode 100644 app/schema/response/web/user.py diff --git a/app/router/web/__init__.py b/app/router/web/__init__.py index 396add6..c6f639e 100644 --- a/app/router/web/__init__.py +++ b/app/router/web/__init__.py @@ -2,8 +2,10 @@ from app.router.web.staff_users import router as staff_users_router from app.router.web.event import router as event_router from app.router.web.auth import router as auth_routes +from app.router.web.users import router as users_router router = APIRouter(prefix="/admin", tags=["admin"]) router.include_router(staff_users_router) router.include_router(event_router) router.include_router(auth_routes) +router.include_router(users_router) diff --git a/app/router/web/users.py b/app/router/web/users.py new file mode 100644 index 0000000..118a8d4 --- /dev/null +++ b/app/router/web/users.py @@ -0,0 +1,155 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status + +from app.container import Container, get_container +from app.core.logger import logger +from app.deps.cookie_auth import get_current_staff_user +from app.schema.request.web.user import AdminUserCreateRequest, AdminUserUpdateRequest +from app.schema.response.web.user import AdminUserSchema +from db.generated.models import StaffUser + + +router = APIRouter(prefix="/users") + + +@router.post("/", response_model=AdminUserSchema, status_code=status.HTTP_201_CREATED) +async def create_user( + req: AdminUserCreateRequest, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.create_user( + email=req.email, + password=req.password, + display_name=req.display_name, + blocked=req.blocked, + ) + logger.info("admin %s created user %s", current_staff_user.id, user.id) + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.get("/", response_model=list[AdminUserSchema]) +async def list_users( + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> list[AdminUserSchema]: + users = await container.auth_service.list_users(limit=limit, offset=offset) + return [ + AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + for user in users + ] + + +@router.get("/{user_id}", response_model=AdminUserSchema) +async def get_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.get_user(user_id=user_id) + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.put("/{user_id}", response_model=AdminUserSchema) +async def update_user( + user_id: UUID, + req: AdminUserUpdateRequest, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.update_user( + user_id=user_id, + email=req.email, + display_name=req.display_name, + blocked=req.blocked, + ) + logger.info("admin %s updated user %s", current_staff_user.id, user_id) + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.delete("/{user_id}", response_model=AdminUserSchema) +async def delete_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.delete_user(user_id=user_id) + logger.info("admin %s deleted user %s", current_staff_user.id, user_id) + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.post("/{user_id}/block", response_model=AdminUserSchema) +async def block_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.block_user( + redis=container.redis, + user_id=user_id, + ) + logger.info("admin %s blocked user %s", current_staff_user.id, user_id) + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.post("/{user_id}/unblock", response_model=AdminUserSchema) +async def unblock_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.unblock_user(user_id=user_id) + logger.info("admin %s unblocked user %s", current_staff_user.id, user_id) + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) diff --git a/app/schema/request/web/user.py b/app/schema/request/web/user.py new file mode 100644 index 0000000..2b41695 --- /dev/null +++ b/app/schema/request/web/user.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import BaseModel, EmailStr, Field + + +class AdminUserCreateRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + display_name: Optional[str] = None + blocked: bool = False + + +class AdminUserUpdateRequest(BaseModel): + email: Optional[EmailStr] = None + display_name: Optional[str] = None + blocked: Optional[bool] = None diff --git a/app/schema/response/web/user.py b/app/schema/response/web/user.py new file mode 100644 index 0000000..4df356e --- /dev/null +++ b/app/schema/response/web/user.py @@ -0,0 +1,13 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class AdminUserSchema(BaseModel): + id: UUID + email: str + display_name: str | None + blocked: bool + created_at: datetime + updated_at: datetime From 058e1e44fbcc8b25f19a4fb48401fd666e66f5e4 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:51 +0100 Subject: [PATCH 13/26] Fix staff login crash on missing user --- app/service/staff_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/service/staff_user.py b/app/service/staff_user.py index 1da37f5..6241818 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -110,8 +110,8 @@ async def admin_login( ) -> WebAuthResponse: print("hello") staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=email) - if staff is None or not verify_password(password, staff.password): - logger.info(f'user:{staff.email}') # type: ignore + if staff is None or not verify_password(password, staff.password): + logger.info("admin login failed for email %s", email) raise AppException.unauthorized("Invalid email or password") From df123d8da93801e898872f2faed01d4f94a57393 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:17:42 +0100 Subject: [PATCH 14/26] Add admin and mobile session defaults to settings --- app/core/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/config.py b/app/core/config.py index 011970f..47a6725 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -35,7 +35,9 @@ class Settings(BaseSettings): MOBILE_SESSION_LIMIT: int = 3 MOBILE_SESSION_TTL_SECONDS: int = 180 MOBILE_SESSION_DAYS: int = 7 - + # Admin list defaults + ADMIN_USERS_DEFAULT_LIMIT: int = 20 + ADMIN_USERS_MAX_LIMIT: int = 100 # Security jwt_secret: str jwt_algorithm: str = "HS256" From 22c5ca4f48b6474d5cfce0f693335e44ca85bbca Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:17:47 +0100 Subject: [PATCH 15/26] Refactor mobile auth endpoints for consistency --- app/router/mobile/auth.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 1784300..72c33db 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -4,7 +4,6 @@ from uuid import UUID from app.container import get_container, Container -from app.core.exceptions import AppException from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.schema.request.mobile.auth import MobileAuthRequest, RefreshTokenRequest @@ -18,7 +17,6 @@ async def mobile_register_login( req: MobileAuthRequest, container: Container = Depends(get_container), ) -> MobileAuthResponse: - return await container.auth_service.mobile_register_login(container.redis, req) @@ -27,20 +25,18 @@ async def refresh_token( req: RefreshTokenRequest, container: Container = Depends(get_container), ) -> MobileAuthResponse: - return await container.auth_service.refresh_token(container.redis, req.refresh_token) @router.post("/logout") async def logout( container: Container = Depends(get_container), - User: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(get_current_mobile_user), ) -> dict[str, str]: - return await container.auth_service.logout( container.redis, - str(User.user_id), - str(User.session_id), + str(current_user.user_id), + str(current_user.session_id), ) @@ -50,7 +46,6 @@ async def revoke_device( container: Container = Depends(get_container), current_user: MobileUserSchema = Depends(get_current_mobile_user), ) -> dict[str, str]: - await container.device_service.revoke_device( device_id=device_id, user_id=current_user.user_id, @@ -63,17 +58,14 @@ async def get_me( current_user: MobileUserSchema = Depends(get_current_mobile_user), container: Container = Depends(get_container), ) -> MeResponse: - - user = await container.auth_service.user_querier.get_user_by_id(id=current_user.user_id) - if user is None : - raise AppException.not_found("user not found") + user = await container.auth_service.get_user(user_id=current_user.user_id) devices, _ = await container.device_service.get_all_devices(current_user.user_id) device_list = [ DeviceSchema( id=d.id, - device_name=d.device_name or "uknown ", - device_type=d.device_type or "uknown ", + device_name=d.device_name or "unknown", + device_type=d.device_type or "unknown", totp_secret=d.totp_secret, ) for d in devices @@ -92,8 +84,6 @@ async def get_me( expires_at=sessions_objs.expires_at, ) - - return MeResponse( user=UserSchema(id=user.id, email=user.email), devices=device_list, From 3f46dda84d1d67a3ce69157c72c08cedd43d3faf Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:18:00 +0100 Subject: [PATCH 16/26] Refactor admin users router mappings and defaults --- app/router/web/users.py | 83 +++++++++++------------------------------ 1 file changed, 22 insertions(+), 61 deletions(-) diff --git a/app/router/web/users.py b/app/router/web/users.py index 118a8d4..59af708 100644 --- a/app/router/web/users.py +++ b/app/router/web/users.py @@ -3,15 +3,26 @@ from fastapi import APIRouter, Depends, Query, status from app.container import Container, get_container +from app.core.config import settings from app.core.logger import logger from app.deps.cookie_auth import get_current_staff_user from app.schema.request.web.user import AdminUserCreateRequest, AdminUserUpdateRequest from app.schema.response.web.user import AdminUserSchema -from db.generated.models import StaffUser +from db.generated.models import StaffUser, User router = APIRouter(prefix="/users") +def _to_admin_user_schema(user: User) -> AdminUserSchema: + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + @router.post("/", response_model=AdminUserSchema, status_code=status.HTTP_201_CREATED) async def create_user( @@ -26,35 +37,20 @@ async def create_user( blocked=req.blocked, ) logger.info("admin %s created user %s", current_staff_user.id, user.id) - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) + return _to_admin_user_schema(user) @router.get("/", response_model=list[AdminUserSchema]) async def list_users( - limit: int = Query(20, ge=1, le=100), + limit: int = Query( + settings.ADMIN_USERS_DEFAULT_LIMIT, ge=1, le=settings.ADMIN_USERS_MAX_LIMIT + ), offset: int = Query(0, ge=0), current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> list[AdminUserSchema]: users = await container.auth_service.list_users(limit=limit, offset=offset) - return [ - AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) - for user in users - ] + return [_to_admin_user_schema(user) for user in users] @router.get("/{user_id}", response_model=AdminUserSchema) @@ -64,14 +60,7 @@ async def get_user( container: Container = Depends(get_container), ) -> AdminUserSchema: user = await container.auth_service.get_user(user_id=user_id) - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) + return _to_admin_user_schema(user) @router.put("/{user_id}", response_model=AdminUserSchema) @@ -88,14 +77,7 @@ async def update_user( blocked=req.blocked, ) logger.info("admin %s updated user %s", current_staff_user.id, user_id) - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) + return _to_admin_user_schema(user) @router.delete("/{user_id}", response_model=AdminUserSchema) @@ -106,14 +88,7 @@ async def delete_user( ) -> AdminUserSchema: user = await container.auth_service.delete_user(user_id=user_id) logger.info("admin %s deleted user %s", current_staff_user.id, user_id) - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) + return _to_admin_user_schema(user) @router.post("/{user_id}/block", response_model=AdminUserSchema) @@ -127,14 +102,7 @@ async def block_user( user_id=user_id, ) logger.info("admin %s blocked user %s", current_staff_user.id, user_id) - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) + return _to_admin_user_schema(user) @router.post("/{user_id}/unblock", response_model=AdminUserSchema) @@ -145,11 +113,4 @@ async def unblock_user( ) -> AdminUserSchema: user = await container.auth_service.unblock_user(user_id=user_id) logger.info("admin %s unblocked user %s", current_staff_user.id, user_id) - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) + return _to_admin_user_schema(user) From bbccb5c713d16585dc9dc370a9ba78818b80bcc1 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:18:09 +0100 Subject: [PATCH 17/26] Use settings and consistent DB error handling in user service --- app/service/users.py | 164 ++++++++++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 63 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index bcb6489..01b7bb2 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -2,7 +2,7 @@ import uuid from app.core import constant -from app.core.exceptions import AppException +from app.core.exceptions import AppException, DBException from app.core.securite import ( # EmbeddingCrypto, hash_password, @@ -248,26 +248,32 @@ async def create_user( display_name: str | None = None, blocked: bool = False, ) -> User: - hashed = hash_password(password) - user = await self.user_querier.create_user( - email=email, - hashed_password=hashed, - ) - if not user: - raise AppException.internal_error("Failed to create user") - - if display_name is not None or blocked: - updated = await self.user_querier.update_user( - email=user.email, - display_name=display_name, - blocked=blocked, - id=user.id, + try: + hashed = hash_password(password) + user = await self.user_querier.create_user( + email=email, + hashed_password=hashed, ) - if not updated: - raise AppException.internal_error("Failed to update user") - return updated + if not user: + raise AppException.internal_error("Failed to create user") - return user + if display_name is not None or blocked: + updated = await self.user_querier.update_user( + email=user.email, + display_name=display_name, + blocked=blocked, + id=user.id, + ) + if not updated: + raise AppException.internal_error("Failed to update user") + return updated + + return user + except AppException: + raise + except Exception as exc: + logger.error("Failed to create user: %s", exc) + raise DBException.handle(exc) async def get_user(self, *, user_id: uuid.UUID) -> User: user = await self.user_querier.get_user_by_id(id=user_id) @@ -276,10 +282,14 @@ async def get_user(self, *, user_id: uuid.UUID) -> User: return user async def list_users(self, *, limit: int, offset: int) -> list[User]: - users: list[User] = [] - async for user in self.user_querier.list_users(limit=limit, offset=offset): - users.append(user) - return users + try: + users: list[User] = [] + async for user in self.user_querier.list_users(limit=limit, offset=offset): + users.append(user) + return users + except Exception as exc: + logger.error("Failed to list users: %s", exc) + raise DBException.handle(exc) async def update_user( self, @@ -289,52 +299,80 @@ async def update_user( display_name: str | None = None, blocked: bool | None = None, ) -> User: - existing = await self.user_querier.get_user_by_id(id=user_id) - if not existing: - raise AppException.not_found("User not found") - - new_email = email if email is not None else existing.email - new_display_name = ( - display_name if display_name is not None else existing.display_name - ) - new_blocked = blocked if blocked is not None else existing.blocked + try: + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + + new_email = email if email is not None else existing.email + new_display_name = ( + display_name if display_name is not None else existing.display_name + ) + new_blocked = blocked if blocked is not None else existing.blocked - user = await self.user_querier.update_user( - email=new_email, - display_name=new_display_name, - blocked=new_blocked, - id=user_id, - ) - if not user: - raise AppException.internal_error("Failed to update user") - return user + user = await self.user_querier.update_user( + email=new_email, + display_name=new_display_name, + blocked=new_blocked, + id=user_id, + ) + if not user: + raise AppException.internal_error("Failed to update user") + return user + except AppException: + raise + except Exception as exc: + logger.error("Failed to update user: %s", exc) + raise DBException.handle(exc) async def delete_user(self, *, user_id: uuid.UUID) -> User: - existing = await self.user_querier.get_user_by_id(id=user_id) - if not existing: - raise AppException.not_found("User not found") - await self.user_querier.delete_user(id=user_id) - return existing + try: + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + await self.user_querier.delete_user(id=user_id) + return existing + except AppException: + raise + except Exception as exc: + logger.error("Failed to delete user: %s", exc) + raise DBException.handle(exc) async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: - user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) - if not user: - raise AppException.not_found("User not found") - - async for session in self.session_querier.list_sessions_by_user(user_id=user_id): - await blacklist_session( - redis=redis, - session_id=str(session.id), - expires_at=session.expires_at, + try: + user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) + if not user: + raise AppException.not_found("User not found") + + async for session in self.session_querier.list_sessions_by_user( + user_id=user_id + ): + await blacklist_session( + redis=redis, + session_id=str(session.id), + expires_at=session.expires_at, + ) + + session_key = constant.RedisKey.UserSessionByUser.value.format( + user_id=user_id ) + await redis.delete(session_key) - session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - await redis.delete(session_key) - - return user + return user + except AppException: + raise + except Exception as exc: + logger.error("Failed to block user: %s", exc) + raise DBException.handle(exc) async def unblock_user(self, *, user_id: uuid.UUID) -> User: - user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) - if not user: - raise AppException.not_found("User not found") - return user + try: + user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) + if not user: + raise AppException.not_found("User not found") + return user + except AppException: + raise + except Exception as exc: + logger.error("Failed to unblock user: %s", exc) + raise DBException.handle(exc) From e534e32a63b2201aa5ff086a4b4cd6beda0b260c Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:23:33 +0100 Subject: [PATCH 18/26] Fix mypy exception handling in user service --- app/service/users.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 01b7bb2..98c1ae1 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -2,6 +2,7 @@ import uuid from app.core import constant +from fastapi import HTTPException from app.core.exceptions import AppException, DBException from app.core.securite import ( # EmbeddingCrypto, @@ -269,7 +270,7 @@ async def create_user( return updated return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to create user: %s", exc) @@ -319,7 +320,7 @@ async def update_user( if not user: raise AppException.internal_error("Failed to update user") return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to update user: %s", exc) @@ -332,7 +333,7 @@ async def delete_user(self, *, user_id: uuid.UUID) -> User: raise AppException.not_found("User not found") await self.user_querier.delete_user(id=user_id) return existing - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to delete user: %s", exc) @@ -359,7 +360,7 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: await redis.delete(session_key) return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to block user: %s", exc) @@ -371,7 +372,7 @@ async def unblock_user(self, *, user_id: uuid.UUID) -> User: if not user: raise AppException.not_found("User not found") return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to unblock user: %s", exc) From cd516a1c6d348b71669b29cc42be6e066fc82ee0 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:25 +0100 Subject: [PATCH 19/26] refactor: move admin user mapping out of router --- app/router/web/users.py | 34 +++++++++++++--------------------- app/service/users.py | 37 ++++++------------------------------- 2 files changed, 19 insertions(+), 52 deletions(-) diff --git a/app/router/web/users.py b/app/router/web/users.py index 59af708..4866d5f 100644 --- a/app/router/web/users.py +++ b/app/router/web/users.py @@ -7,23 +7,12 @@ from app.core.logger import logger from app.deps.cookie_auth import get_current_staff_user from app.schema.request.web.user import AdminUserCreateRequest, AdminUserUpdateRequest -from app.schema.response.web.user import AdminUserSchema -from db.generated.models import StaffUser, User +from app.schema.response.web.user import AdminUserSchema, to_admin_user_schema +from db.generated.models import StaffUser router = APIRouter(prefix="/users") -def _to_admin_user_schema(user: User) -> AdminUserSchema: - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) - - @router.post("/", response_model=AdminUserSchema, status_code=status.HTTP_201_CREATED) async def create_user( req: AdminUserCreateRequest, @@ -37,7 +26,7 @@ async def create_user( blocked=req.blocked, ) logger.info("admin %s created user %s", current_staff_user.id, user.id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.get("/", response_model=list[AdminUserSchema]) @@ -50,7 +39,7 @@ async def list_users( container: Container = Depends(get_container), ) -> list[AdminUserSchema]: users = await container.auth_service.list_users(limit=limit, offset=offset) - return [_to_admin_user_schema(user) for user in users] + return [to_admin_user_schema(user) for user in users] @router.get("/{user_id}", response_model=AdminUserSchema) @@ -60,7 +49,7 @@ async def get_user( container: Container = Depends(get_container), ) -> AdminUserSchema: user = await container.auth_service.get_user(user_id=user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.put("/{user_id}", response_model=AdminUserSchema) @@ -77,7 +66,7 @@ async def update_user( blocked=req.blocked, ) logger.info("admin %s updated user %s", current_staff_user.id, user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.delete("/{user_id}", response_model=AdminUserSchema) @@ -86,9 +75,12 @@ async def delete_user( current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> AdminUserSchema: - user = await container.auth_service.delete_user(user_id=user_id) + user = await container.auth_service.delete_user( + redis=container.redis, + user_id=user_id, + ) logger.info("admin %s deleted user %s", current_staff_user.id, user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.post("/{user_id}/block", response_model=AdminUserSchema) @@ -102,7 +94,7 @@ async def block_user( user_id=user_id, ) logger.info("admin %s blocked user %s", current_staff_user.id, user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.post("/{user_id}/unblock", response_model=AdminUserSchema) @@ -113,4 +105,4 @@ async def unblock_user( ) -> AdminUserSchema: user = await container.auth_service.unblock_user(user_id=user_id) logger.info("admin %s unblocked user %s", current_staff_user.id, user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) diff --git a/app/service/users.py b/app/service/users.py index 98c1ae1..2315cd0 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -2,7 +2,6 @@ import uuid from app.core import constant -from fastapi import HTTPException from app.core.exceptions import AppException, DBException from app.core.securite import ( # EmbeddingCrypto, @@ -76,8 +75,6 @@ async def mobile_register_login( user_id: uuid.UUID = user.id session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - if await redis.exists(session_key): - raise AppException.forbidden("User already has an active session") session_count = await self.session_querier.count_user_sessions(user_id=user_id) if session_count and session_count >= AuthService.SESSION_LIMIT: @@ -155,22 +152,12 @@ async def refresh_token( if session.expires_at < datetime.now(timezone.utc): raise AppException.unauthorized("Session expired") - session_key = constant.RedisKey.UserSessionByUser.value.format( - user_id=session.user_id - ) - redis_session = await redis.get(session_key) - - if not redis_session or redis_session != session_id: - raise AppException.unauthorized("Session invalidated") - user = await self.user_querier.get_user_by_id(id=session.user_id) if not user: raise AppException.unauthorized("User not found") if user.blocked: raise AppException.forbidden("User is blocked") - await redis.expire(session_key, AuthService.REDIS_SESSION_TTL) - new_access_token = create_acces_mobile_token(session_id) new_refresh_token = create_refresh_mobile_token(session_id) expiry = Get_expiry_time() @@ -230,13 +217,7 @@ async def validate_session( if session.expires_at < datetime.now(timezone.utc): return False - - session_key = constant.RedisKey.UserSessionByUser.value.format( - user_id=session.user_id - ) - redis_session = await redis.get(session_key) - - return redis_session == session_id + return True async def get_user_by_id(self, user_id: uuid.UUID) -> User | None: return await self.user_querier.get_user_by_id(id=user_id) @@ -270,8 +251,6 @@ async def create_user( return updated return user - except HTTPException: - raise except Exception as exc: logger.error("Failed to create user: %s", exc) raise DBException.handle(exc) @@ -320,21 +299,21 @@ async def update_user( if not user: raise AppException.internal_error("Failed to update user") return user - except HTTPException: - raise except Exception as exc: logger.error("Failed to update user: %s", exc) raise DBException.handle(exc) - async def delete_user(self, *, user_id: uuid.UUID) -> User: + async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: try: existing = await self.user_querier.get_user_by_id(id=user_id) if not existing: raise AppException.not_found("User not found") await self.user_querier.delete_user(id=user_id) + session_key = constant.RedisKey.UserSessionByUser.value.format( + user_id=user_id + ) + await redis.delete(session_key) return existing - except HTTPException: - raise except Exception as exc: logger.error("Failed to delete user: %s", exc) raise DBException.handle(exc) @@ -360,8 +339,6 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: await redis.delete(session_key) return user - except HTTPException: - raise except Exception as exc: logger.error("Failed to block user: %s", exc) raise DBException.handle(exc) @@ -372,8 +349,6 @@ async def unblock_user(self, *, user_id: uuid.UUID) -> User: if not user: raise AppException.not_found("User not found") return user - except HTTPException: - raise except Exception as exc: logger.error("Failed to unblock user: %s", exc) raise DBException.handle(exc) From 05c8102a8f6f2a81bec5b437629e66e6d500d419 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:25 +0100 Subject: [PATCH 20/26] feat: add admin user schema mapper --- app/schema/response/web/user.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/schema/response/web/user.py b/app/schema/response/web/user.py index 4df356e..bd79627 100644 --- a/app/schema/response/web/user.py +++ b/app/schema/response/web/user.py @@ -2,6 +2,7 @@ from uuid import UUID from pydantic import BaseModel +from db.generated.models import User class AdminUserSchema(BaseModel): @@ -11,3 +12,14 @@ class AdminUserSchema(BaseModel): blocked: bool created_at: datetime updated_at: datetime + + +def to_admin_user_schema(user: User) -> AdminUserSchema: + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) From f4a6853b3b666d158c17b45cb8798f6b2f2003d0 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:26 +0100 Subject: [PATCH 21/26] fix: rely on db for session validity in auth service --- app/service/users.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 2315cd0..e4801d4 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -13,7 +13,6 @@ Get_expiry_time, ) from app.core.config import settings -from app.core.token_blacklist import blacklist_session, is_session_blacklisted from app.infra.redis import RedisClient from app.schema.request.mobile.auth import MobileAuthRequest @@ -141,9 +140,6 @@ async def refresh_token( if not session_id: raise AppException.unauthorized("Invalid refresh token") - if await is_session_blacklisted(redis, session_id): - raise AppException.unauthorized("Token is blacklisted") - session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) if not session: @@ -207,9 +203,6 @@ async def validate_session( redis: RedisClient, session_id: str, ) -> bool: - if await is_session_blacklisted(redis, session_id): - return False - session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) if not session: @@ -324,15 +317,6 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: if not user: raise AppException.not_found("User not found") - async for session in self.session_querier.list_sessions_by_user( - user_id=user_id - ): - await blacklist_session( - redis=redis, - session_id=str(session.id), - expires_at=session.expires_at, - ) - session_key = constant.RedisKey.UserSessionByUser.value.format( user_id=user_id ) From 069ec7cfa565defc13a4acb80afe215f15aa95b7 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:26 +0100 Subject: [PATCH 22/26] chore: deprecate token blacklist helpers --- app/core/token_blacklist.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/app/core/token_blacklist.py b/app/core/token_blacklist.py index 3f22038..a722935 100644 --- a/app/core/token_blacklist.py +++ b/app/core/token_blacklist.py @@ -1,23 +1,19 @@ -from datetime import datetime, timezone +from datetime import datetime -from app.core.constant import RedisKey from app.infra.redis import RedisClient +# Deprecated: sessions are validated against the database as the source of truth. +# Keep these helpers as no-ops to avoid breaking callers while we remove usage. async def blacklist_session( redis: RedisClient, session_id: str, expires_at: datetime | None = None, ) -> None: - ttl: int | None = None - if expires_at is not None: - ttl = int((expires_at - datetime.now(timezone.utc)).total_seconds()) - if ttl < 0: - ttl = 0 - key = RedisKey.BlacklistedSession.value.format(session_id=session_id) - await redis.set(key, "1", expire=ttl) + _ = (redis, session_id, expires_at) + return None async def is_session_blacklisted(redis: RedisClient, session_id: str) -> bool: - key = RedisKey.BlacklistedSession.value.format(session_id=session_id) - return await redis.exists(key) + _ = (redis, session_id) + return False From 194e1dfe565f6a1fb77cd52e7f025016e16e216b Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:26 +0100 Subject: [PATCH 23/26] fix: validate sessions via db in token auth --- app/deps/token_auth.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index 527e28b..a7eff17 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -5,7 +5,6 @@ from pydantic import BaseModel from app.container import get_container, Container from app.core.securite import decode_access_mobile_token -from app.core.token_blacklist import is_session_blacklisted security = HTTPBearer() @@ -32,9 +31,6 @@ async def get_current_mobile_user( session_id = uuid.UUID(session_id_str) - if await is_session_blacklisted(container.redis, session_id_str): - raise HTTPException(status_code=401, detail="Token is blacklisted") - # Validate session via SessionService session = await container.session_service.session_querier.get_session_by_id(id=session_id) if not session: From 5aa0cb80f21d08b45781a527e37a77da5a2374eb Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:32:07 +0100 Subject: [PATCH 24/26] chore: remove token blacklist helpers --- app/core/constant.py | 1 - app/core/token_blacklist.py | 19 ------------------- 2 files changed, 20 deletions(-) delete mode 100644 app/core/token_blacklist.py diff --git a/app/core/constant.py b/app/core/constant.py index 5188f40..7d3f191 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -4,7 +4,6 @@ class RedisKey(str, Enum): UserSession = "user_session" UserSessionByUser = "user_session:{user_id}" - BlacklistedSession = "blacklist:session:{session_id}" IMAGE_ALLOWED_TYPES = { "image/jpeg", diff --git a/app/core/token_blacklist.py b/app/core/token_blacklist.py deleted file mode 100644 index a722935..0000000 --- a/app/core/token_blacklist.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime - -from app.infra.redis import RedisClient - - -# Deprecated: sessions are validated against the database as the source of truth. -# Keep these helpers as no-ops to avoid breaking callers while we remove usage. -async def blacklist_session( - redis: RedisClient, - session_id: str, - expires_at: datetime | None = None, -) -> None: - _ = (redis, session_id, expires_at) - return None - - -async def is_session_blacklisted(redis: RedisClient, session_id: str) -> bool: - _ = (redis, session_id) - return False From e5bafbbb8f2b099a06f97c4db56d3b67024cdaf7 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 23:15:36 +0100 Subject: [PATCH 25/26] Remove GH Actions cache from docker publish --- .github/workflows/docker-publish.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5f14801..180ecfa 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -55,5 +55,3 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max From 1bad9f4f3dea08cbdd66f89a7791d8c203f95722 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 23:39:26 +0100 Subject: [PATCH 26/26] Use explicit image tags in docker publish workflow --- .github/workflows/docker-publish.yml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 180ecfa..d946aae 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -38,20 +38,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=latest - type=sha,prefix= - - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }}