diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 91e1dfc5..e66d2a89 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -27,6 +27,9 @@ jobs: - name: Install dependencies run: uv sync --group dev + - name: Copy to .env + run: cp .env.dev.example .env + - name: Run tests run: uv run pytest diff --git a/api/Dockerfile b/api/Dockerfile index dba8fb1f..ba4a56da 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -6,12 +6,15 @@ RUN apk add --no-cache wget # Install uv. COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -# Copy the application into the container. -COPY . /app +# Copy the dependency files. +COPY pyproject.toml uv.lock /app/ # Install the application dependencies. WORKDIR /app RUN uv sync --frozen --no-cache +# Copy the rest of the application into the container. +COPY . /app + # Run the application. CMD ["/app/.venv/bin/fastapi", "run", "src/main.py", "--port", "80", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/api/src/core/security.py b/api/src/core/security.py index 68f55d06..9da0e9a4 100644 --- a/api/src/core/security.py +++ b/api/src/core/security.py @@ -9,10 +9,13 @@ from fastapi.security import OAuth2PasswordBearer +from src.core.settings import settings + + oauth_2_scheme = OAuth2PasswordBearer(tokenUrl="token") -AUTH_SERVER_URL = os.getenv("KEYCLOAK_URL") -KEYCLOAK_ISSUER_URL = os.getenv("KEYCLOAK_ISSUER_URL", AUTH_SERVER_URL) +AUTH_SERVER_URL = settings.KEYCLOAK_URL +KEYCLOAK_ISSUER_URL = settings.KEYCLOAK_ISSUER_URL or AUTH_SERVER_URL RESOURCE_SERVER_ID = "api" _JWKS_CLIENTS: dict[str, PyJWKClient] = {} diff --git a/api/src/core/settings.py b/api/src/core/settings.py index 982ea12e..847a467b 100644 --- a/api/src/core/settings.py +++ b/api/src/core/settings.py @@ -17,6 +17,15 @@ class Settings(BaseSettings): POSTGRES_USER: str POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" + + # Keycloak + KEYCLOAK_URL: str = "" + KEYCLOAK_INTERNAL_URL: str = "" + KEYCLOAK_ISSUER_URL: str = "" + CLIENT_SECRET: str = "" + + WEB_URL: str = "http://localhost:5173" + API_URL: str = "http://localhost:8000" # MongoDB MONGODB_URI: str = "mongodb://template_user:template_pass@mongo:27017/securelearning?authSource=securelearning" @@ -48,6 +57,7 @@ class Settings(BaseSettings): RABBITMQ_USER: str RABBITMQ_PASS: str RABBITMQ_QUEUE: str + RABBITMQ_TRACKING_QUEUE: str = "tracking_queue" # Statistics # Users who fell for phishing in more than this fraction of campaigns are diff --git a/api/src/main.py b/api/src/main.py index 403f3e2b..1af521b9 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -24,7 +24,7 @@ from src.core.object_storage import ensure_bucket, garage_enabled from src.core.settings import settings from src.tasks import start_scheduler, shutdown_scheduler - +from src.tasks.tracking_consumer import start_tracking_consumer, shutdown_tracking_consumer @asynccontextmanager async def lifespan(app: FastAPI): @@ -34,11 +34,12 @@ async def lifespan(app: FastAPI): await ensure_bucket(settings.GARAGE_BUCKET_LOGOS) try: start_scheduler() + start_tracking_consumer() yield finally: + shutdown_scheduler() + shutdown_tracking_consumer() await close_mongo_client() - shutdown_scheduler() - app = FastAPI( title="Project Template API", @@ -49,8 +50,8 @@ async def lifespan(app: FastAPI): ) origins = [ - os.getenv("WEB_URL"), - os.getenv("API_URL"), + settings.WEB_URL, + settings.API_URL, ] app.add_middleware( diff --git a/api/src/models/email_sending/table.py b/api/src/models/email_sending/table.py index e90b9ee6..8c2bb25c 100644 --- a/api/src/models/email_sending/table.py +++ b/api/src/models/email_sending/table.py @@ -14,6 +14,7 @@ class EmailSendingStatus(StrEnum): SCHEDULED = "scheduled" + QUEUED = "queued" SENT = "sent" OPENED = "opened" CLICKED = "clicked" diff --git a/api/src/routers/tracking.py b/api/src/routers/tracking.py index 8b69bf60..22380c44 100644 --- a/api/src/routers/tracking.py +++ b/api/src/routers/tracking.py @@ -3,6 +3,7 @@ from src.core.dependencies import SessionDep, OAuth2Scheme from src.services.tracking import TrackingService +from src.core.settings import settings router = APIRouter() @@ -68,7 +69,7 @@ def track_sent(si: str, session: SessionDep): ) -@router.post( +@router.get( "/track/open", status_code=200, description="Tracking pixel endpoint - records email opens", @@ -100,13 +101,16 @@ async def track_click(si: str, session: SessionDep): @router.post( "/track/phish", - status_code=200, + status_code=303, description="Phishing event endpoint - records when user submits credentials on landing page", ) def track_phish(si: str, session: SessionDep): """ Called when user submits credentials on the landing page. - Records the phishing event. + Records the phishing event and redirects to the simulation oops page. """ service.record_phish(si, session) - return {"message": "Event recorded"} + return RedirectResponse( + url=f"{settings.WEB_URL}/simulation-oops.html", + status_code=303 + ) diff --git a/api/src/services/compliance/token_helpers.py b/api/src/services/compliance/token_helpers.py index 3ca9435d..ba0159ab 100644 --- a/api/src/services/compliance/token_helpers.py +++ b/api/src/services/compliance/token_helpers.py @@ -12,10 +12,11 @@ import jwt from jwt import PyJWKClient from fastapi import HTTPException, status +from src.core.settings import settings REALM_PATH = "/realms/" -AUTH_SERVER_URL = os.getenv("KEYCLOAK_INTERNAL_URL") or os.getenv("KEYCLOAK_URL") +AUTH_SERVER_URL = settings.KEYCLOAK_INTERNAL_URL or settings.KEYCLOAK_URL SYSTEM_REALMS = {"platform", "master"} PRIVILEGED_COMPLIANCE_ROLES = {"admin", "org_manager", "content_manager"} diff --git a/api/src/services/keycloak_admin/base_handler.py b/api/src/services/keycloak_admin/base_handler.py index 3f98f504..9b4db5c9 100644 --- a/api/src/services/keycloak_admin/base_handler.py +++ b/api/src/services/keycloak_admin/base_handler.py @@ -2,20 +2,17 @@ import json from pathlib import Path from fastapi import HTTPException -from dotenv import load_dotenv +from src.core.settings import settings from src.services.keycloak_client import get_keycloak_client -load_dotenv() - - class base_handler: def __init__(self): - self.keycloak_url = os.getenv("KEYCLOAK_URL") - self.admin_secret = os.getenv("CLIENT_SECRET") - self.web_url = os.getenv("WEB_URL", "http://localhost:3000") - self.api_url = os.getenv("API_URL", "http://localhost:8080") + self.keycloak_url = settings.KEYCLOAK_URL + self.admin_secret = settings.CLIENT_SECRET + self.web_url = settings.WEB_URL + self.api_url = settings.API_URL self.keycloak_client = get_keycloak_client() if not self.keycloak_url: diff --git a/api/src/services/keycloak_client/base_handler.py b/api/src/services/keycloak_client/base_handler.py index c9598b1f..83bc9b9a 100644 --- a/api/src/services/keycloak_client/base_handler.py +++ b/api/src/services/keycloak_client/base_handler.py @@ -2,17 +2,15 @@ import json import requests from fastapi import HTTPException -from dotenv import load_dotenv - -load_dotenv() +from src.core.settings import settings class base_handler: """Base handler with shared configuration and HTTP helpers.""" def __init__(self): - self.keycloak_url = os.getenv("KEYCLOAK_URL") - self.admin_secret = os.getenv("CLIENT_SECRET") + self.keycloak_url = settings.KEYCLOAK_URL + self.admin_secret = settings.CLIENT_SECRET if not self.keycloak_url: raise HTTPException( diff --git a/api/src/services/tracking.py b/api/src/services/tracking.py index 5c2d831d..f4810c0d 100644 --- a/api/src/services/tracking.py +++ b/api/src/services/tracking.py @@ -1,6 +1,6 @@ from datetime import datetime from fastapi import HTTPException -from sqlmodel import Session, select, text +from sqlmodel import Session, select, text, update from src.models import Campaign, EmailSending, EmailSendingStatus @@ -18,14 +18,16 @@ def record_sent(self, tracking_token: str, session: Session) -> EmailSending: if sending.sent_at is None: sending.sent_at = datetime.now() sending.status = EmailSendingStatus.SENT - - # Increment campaign counter - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_sent += 1 - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_sent=Campaign.total_sent + 1) + ) + session.commit() + session.refresh(sending) return sending @@ -37,14 +39,16 @@ def record_open(self, tracking_token: str, session: Session) -> EmailSending: if sending.opened_at is None: sending.opened_at = datetime.now() sending.status = EmailSendingStatus.OPENED - - # Increment campaign counter using ORM - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_opened += 1 - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_opened=Campaign.total_opened + 1) + ) + session.commit() + session.refresh(sending) return sending @@ -55,24 +59,28 @@ def record_click(self, tracking_token: str, session: Session) -> EmailSending: # Record open if not already recorded (click implies open) if sending.opened_at is None: sending.opened_at = datetime.now() - # Increment campaign counter using ORM - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_opened += 1 - session.add(campaign) - session.commit() + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_opened=Campaign.total_opened + 1) + ) # Only count first click if sending.clicked_at is None: sending.clicked_at = datetime.now() sending.status = EmailSendingStatus.CLICKED - # Increment campaign counter using ORM - campaign = session.get(Campaign, sending.campaign_id) - if campaign: - campaign.total_clicked += 1 - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + # Increment campaign counter atomically + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_clicked=Campaign.total_clicked + 1) + ) + + session.commit() + session.refresh(sending) return sending @@ -99,26 +107,36 @@ def record_phish(self, tracking_token: str, session: Session) -> EmailSending: sending = self._get_sending_by_token(tracking_token, session) # Record open and click if not already recorded (phish implies both) - campaign = session.get(Campaign, sending.campaign_id) if sending.opened_at is None: sending.opened_at = datetime.now() - if campaign: - campaign.total_opened += 1 + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_opened=Campaign.total_opened + 1) + ) + if sending.clicked_at is None: sending.clicked_at = datetime.now() - if campaign: - campaign.total_clicked += 1 + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_clicked=Campaign.total_clicked + 1) + ) # Only count first phish if sending.phished_at is None: sending.phished_at = datetime.now() sending.status = EmailSendingStatus.PHISHED - if campaign: - campaign.total_phished += 1 - if campaign: - session.add(campaign) - session.commit() - session.refresh(sending) + session.add(sending) + + session.exec( + update(Campaign) + .where(Campaign.id == sending.campaign_id) + .values(total_phished=Campaign.total_phished + 1) + ) + + session.commit() + session.refresh(sending) return sending diff --git a/api/src/tasks/scheduler.py b/api/src/tasks/scheduler.py index 7bb0a2e0..d2ff0a41 100644 --- a/api/src/tasks/scheduler.py +++ b/api/src/tasks/scheduler.py @@ -174,12 +174,11 @@ def process_pending_emails() -> None: try: # Send email to RabbitMQ campaign_service._send_email_to_rabbitmq(email, campaign) - - # Update status and timestamp for sent emails - email.status = EmailSendingStatus.SENT - email.sent_at = datetime.now() - + + # Update status to queued + email.status = EmailSendingStatus.QUEUED session.commit() + except (ValueError, ValidationError) as e: # Irrecoverable payload/configuration issue for this email. email.status = EmailSendingStatus.FAILED @@ -188,6 +187,7 @@ def process_pending_emails() -> None: logger.error( f"Failed email {email.id} for campaign {email.campaign_id} marked FAILED: {e}" ) + except Exception as e: logger.error( f"Failed to process email {email.id} for campaign {email.campaign_id}: {e}" diff --git a/api/src/tasks/tracking_consumer.py b/api/src/tasks/tracking_consumer.py new file mode 100644 index 00000000..d35577b4 --- /dev/null +++ b/api/src/tasks/tracking_consumer.py @@ -0,0 +1,94 @@ +import json +import logging +import threading +import time +import pika + +from sqlmodel import Session +from src.core.db import engine +from src.core.settings import settings +from src.services.tracking import TrackingService + +logger = logging.getLogger(__name__) + +_consumer_thread: threading.Thread | None = None +_stop_event = threading.Event() + +def process_tracking_message(channel, method, properties, body): + """Callback process for a single tracking message.""" + try: + data = json.loads(body) + if data.get("action") == "sent" and "tracking_id" in data: + tracking_id = data["tracking_id"] + + # Process in DB + with Session(engine) as session: + service = TrackingService() + service.record_sent(tracking_id, session) + + channel.basic_ack(method.delivery_tag) + except Exception as e: + logger.error(f"Error processing tracking message: {e}") + # Acknowledge to drop bad messages to prevent queue blocking + try: + channel.basic_ack(method.delivery_tag) + except Exception as ack_err: + logger.error(f"Failed to ack message after error: {ack_err}") + + +def _tracking_consumer_worker(): + """Worker function that continuously consumes tracking messages.""" + + while not _stop_event.is_set(): + try: + logger.info(f"Connecting to RabbitMQ tracking queue at {settings.RABBITMQ_HOST}...") + connection = pika.BlockingConnection(settings.RABBITMQ_CONNECTION_PARAMS) + channel = connection.channel() + channel.queue_declare(queue=settings.RABBITMQ_TRACKING_QUEUE, durable=True) + + # Don't consume too many unacked messages at once + channel.basic_qos(prefetch_count=10) + + for method, properties, body in channel.consume(settings.RABBITMQ_TRACKING_QUEUE, inactivity_timeout=1.0): + if _stop_event.is_set(): + break + + if method is None: + # Timeout reached + continue + + process_tracking_message(channel, method, properties, body) + + if connection.is_open: + connection.close() + + except Exception as e: + if not _stop_event.is_set(): + logger.error(f"RabbitMQ consumer connection error: {e}. Retrying in 5 seconds...") + time.sleep(5) + + +def start_tracking_consumer() -> None: + """Start the background tracking consumer thread.""" + global _consumer_thread + + if _consumer_thread is not None and _consumer_thread.is_alive(): + logger.warning("Tracking consumer is already running") + return + + _stop_event.clear() + _consumer_thread = threading.Thread(target=_tracking_consumer_worker, daemon=True, name="TrackingConsumer") + _consumer_thread.start() + logger.info("Started background RabbitMQ tracking consumer") + + +def shutdown_tracking_consumer() -> None: + """Signal the background tracking consumer to stop.""" + global _consumer_thread + + if _consumer_thread is not None: + _stop_event.set() + # We don't strictly need to join if it's daemon, but it's cleaner + _consumer_thread.join(timeout=2.0) + _consumer_thread = None + logger.info("Tracking consumer shutdown complete") diff --git a/api/test/tasks/test_scheduler.py b/api/test/tasks/test_scheduler.py index 4717a85e..6bf89404 100644 --- a/api/test/tasks/test_scheduler.py +++ b/api/test/tasks/test_scheduler.py @@ -108,7 +108,7 @@ def test_does_not_duplicate_sendings_on_second_run( class TestProcessPendingEmails: - def test_processes_pending_emails_and_marks_as_sent( + def test_processes_pending_emails_and_marks_as_queued( self, session: Session, users, @@ -133,6 +133,5 @@ def test_processes_pending_emails_and_marks_as_sent( ).all() assert len(sendings) == 3 - assert all(s.status == EmailSendingStatus.SENT for s in sendings) - assert all(s.sent_at is not None for s in sendings) + assert all(s.status == EmailSendingStatus.QUEUED for s in sendings) assert mock_rabbitmq_service.send_email.call_count == 3 diff --git a/api/test/test_tracking_consumer.py b/api/test/test_tracking_consumer.py new file mode 100644 index 00000000..1e249c6b --- /dev/null +++ b/api/test/test_tracking_consumer.py @@ -0,0 +1,95 @@ +import json +import pytest +from unittest.mock import patch, MagicMock +from sqlmodel import Session, create_engine +from sqlmodel.pool import StaticPool +from datetime import datetime +from uuid import uuid4 + +from src.models import User, Campaign, EmailSending, EmailSendingStatus +from src.core.db import engine +from src.tasks.tracking_consumer import process_tracking_message + +# Create an in-memory database for testing +engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) + +@pytest.fixture(name="session") +def session_fixture(): + from sqlmodel import SQLModel + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + SQLModel.metadata.drop_all(engine) + +def test_tracking_consumer_updates_database(session: Session): + # 0. Setup User + user = User(keycloak_id="u1", email="alice@test.com") + session.add(user) + session.commit() + + # 1. Setup Database Mock State + campaign = Campaign( + name="Test Consumer Campaign", + company_name="Corp", + total_sent=0, + begin_date=datetime.now(), + end_date=datetime.now() + ) + session.add(campaign) + session.commit() + session.refresh(campaign) + + email = EmailSending( + campaign_id=campaign.id, + user_id="u1", + first_name="Alice", + last_name="Test", + email_to="alice@test.com", + scheduled_date=datetime.now(), + tracking_token=str(uuid4()), + status=EmailSendingStatus.QUEUED # Simulating the scheduler's queuing state + ) + session.add(email) + session.commit() + session.refresh(email) + + campaign_id = campaign.id + email_id = email.id + + # 2. Setup RabbitMQ Mock Payload + payload = { + "action": "sent", + "tracking_id": email.tracking_token + } + + # 3. Mock the get_db context manager inside the consumer + # Because run_tracking_consumer creates its own session from the global engine, + # we patch it to yield our testing session instead. + with patch("src.tasks.tracking_consumer.Session", return_value=session): + # Simulate pika channel/method/properties, not used by the script extensively + mock_channel = MagicMock() + mock_method = MagicMock() + mock_method.delivery_tag = 1 + + # ACT: simulate a message consumption + process_tracking_message(mock_channel, mock_method, None, json.dumps(payload).encode('utf-8')) + + # Verify message was acknowledged + mock_channel.basic_ack.assert_called_once_with(1) + + # 4. Assert Database State was Mutated Correctly + # We refetch because the with session block in the consumer closed our mock session + email_db = session.get(EmailSending, email_id) + campaign_db = session.get(Campaign, campaign_id) + + # The consumer should have flipped it from QUEUED to SENT + assert email_db.status == EmailSendingStatus.SENT + assert email_db.sent_at is not None + assert type(email_db.sent_at) is datetime + + # And campaign counter must have incremented exactly once based on the event + assert campaign_db.total_sent == 1 diff --git a/api/test/test_tracking_endpoints.py b/api/test/test_tracking_endpoints.py new file mode 100644 index 00000000..cf20828c --- /dev/null +++ b/api/test/test_tracking_endpoints.py @@ -0,0 +1,126 @@ +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool +from datetime import datetime +import os + +from src.main import app +from src.core.dependencies import get_db +from src.models import Campaign, EmailSending, EmailSendingStatus +from src.core.settings import settings + +# Setup SQLite in-memory DB +@pytest.fixture(name="engine") +def engine_fixture(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +@pytest.fixture(name="session") +def session_fixture(engine): + with Session(engine) as session: + yield session + + +@pytest.fixture(name="client") +def client_fixture(session): + def get_session_override(): + return session + + app.dependency_overrides[get_db] = get_session_override + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +@pytest.fixture(name="mock_data") +def mock_data_fixture(session): + now = datetime.now() + c = Campaign( + name="Test Camp", + realm_name="test-realm", + total_recipients=1, + sending_interval_seconds=60, + begin_date=now, + end_date=now, + ) + session.add(c) + session.commit() + session.refresh(c) + + es = EmailSending( + campaign_id=c.id, + user_id="u1", + email_to="u@test.com", + scheduled_date=now, + tracking_token="valid-token-123", + ) + session.add(es) + session.commit() + return {"campaign": c, "email_sending": es} + + +def test_track_open_success(client: TestClient, mock_data: dict, session: Session): + response = client.get("/api/track/open?si=valid-token-123") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/gif" + + # Verify DB update + session.refresh(mock_data["email_sending"]) + assert mock_data["email_sending"].status == EmailSendingStatus.OPENED + assert mock_data["email_sending"].opened_at is not None + + session.refresh(mock_data["campaign"]) + assert mock_data["campaign"].total_opened == 1 + + +def test_track_phish_success_redirect(client: TestClient, mock_data: dict, session: Session): + response = client.post("/api/track/phish?si=valid-token-123", follow_redirects=False) + + # Should be 303 Redirect to frontend + assert response.status_code == 303 + assert response.headers["location"] == f"{settings.WEB_URL}/simulation-oops.html" + + # Verify DB update + session.refresh(mock_data["email_sending"]) + assert mock_data["email_sending"].status == EmailSendingStatus.PHISHED + assert mock_data["email_sending"].phished_at is not None + assert mock_data["email_sending"].opened_at is not None + + session.refresh(mock_data["campaign"]) + assert mock_data["campaign"].total_phished == 1 + assert mock_data["campaign"].total_opened == 1 + + +def test_track_invalid_token(client: TestClient): + response_open = client.get("/api/track/open?si=invalid-token") + assert response_open.status_code == 404 + + response_phish = client.post("/api/track/phish?si=invalid-token") + assert response_phish.status_code == 404 + +def test_simulate_rabbitmq_consumer(session: Session, mock_data: dict): + from src.services.tracking import TrackingService + import json + + # Simulate the payload the SMTP microservice publishes to AMQP + mock_rabbitmq_payload = json.dumps({"action": "sent", "tracking_id": "valid-token-123"}) + data = json.loads(mock_rabbitmq_payload) + + # Simulate consumer worker processing + if data.get("action") == "sent" and "tracking_id" in data: + service = TrackingService() + service.record_sent(data["tracking_id"], session) + + session.refresh(mock_data["email_sending"]) + assert mock_data["email_sending"].status == EmailSendingStatus.SENT + assert mock_data["email_sending"].sent_at is not None + + session.refresh(mock_data["campaign"]) + assert mock_data["campaign"].total_sent == 1 diff --git a/deployment/.env.dev.example b/deployment/.env.dev.example index 8200f6f0..249cc97c 100644 --- a/deployment/.env.dev.example +++ b/deployment/.env.dev.example @@ -54,3 +54,4 @@ KC_HOSTNAME_URL=http://localhost:8080 KEYCLOAK_URL=http://localhost:8080 API_URL=http://localhost:8000 WEB_URL=http://localhost:5173 +KEYCLOAK_ISSUER_URL=http://localhost:8080 diff --git a/deployment/.env.prod.example b/deployment/.env.prod.example index 45837ba4..84d89ca5 100644 --- a/deployment/.env.prod.example +++ b/deployment/.env.prod.example @@ -54,6 +54,7 @@ KC_HOSTNAME_URL=https://mednat.ieeta.pt:9071/kc KEYCLOAK_URL=https://mednat.ieeta.pt:9071/kc API_URL=https://mednat.ieeta.pt:9071/api WEB_URL=https://mednat.ieeta.pt:9071/app +KEYCLOAK_ISSUER_URL=https://mednat.ieeta.pt:9071/kc/realms/platform # Port Configuration NGINX_PORT=8081 diff --git a/deployment/docker-compose.dev.yml b/deployment/docker-compose.dev.yml index 74876b1b..64cb4674 100644 --- a/deployment/docker-compose.dev.yml +++ b/deployment/docker-compose.dev.yml @@ -16,7 +16,7 @@ services: healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s + interval: 5s timeout: 5s retries: 5 @@ -48,7 +48,7 @@ services: "--eval", "db.adminCommand('ping')" ] - interval: 10s + interval: 5s timeout: 5s retries: 5 @@ -93,7 +93,7 @@ services: - rabbitmq_data:/var/lib/rabbitmq healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s + interval: 5s timeout: 5s retries: 5 @@ -139,6 +139,8 @@ services: WEB_URL: ${WEB_URL} API_URL: ${API_URL} JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING" + KC_DB_POOL_INITIAL_SIZE: 5 + KC_DB_POOL_MAX_SIZE: 10 healthcheck: test: @@ -233,8 +235,8 @@ services: ports: - "5173:5173" volumes: - - ../web/src:/app/src:ro - - ../web/public:/app/public:ro + - ../web:/app:rw + - /app/node_modules depends_on: api: condition: service_healthy diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c9e54e95..38c19cda 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -40,9 +40,9 @@ services: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s + interval: 5s timeout: 5s - retries: 10 + retries: 5 mongo: build: @@ -74,9 +74,9 @@ services: "--eval", "db.adminCommand('ping')" ] - interval: 10s + interval: 5s timeout: 5s - retries: 10 + retries: 5 garage: image: dxflrs/garage:76592723deb9285a071320c40842f6be61e924fd @@ -121,9 +121,9 @@ services: - rabbitmq_data:/var/lib/rabbitmq healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s + interval: 5s timeout: 5s - retries: 10 + retries: 5 smtp: build: @@ -174,6 +174,8 @@ services: WEB_URL: ${WEB_URL} API_URL: ${API_URL} JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING" + KC_DB_POOL_INITIAL_SIZE: 5 + KC_DB_POOL_MAX_SIZE: 10 healthcheck: test: ["CMD", "true"] interval: 30s @@ -202,7 +204,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} KEYCLOAK_URL: ${KEYCLOAK_INTERNAL_URL} - KEYCLOAK_ISSUER_URL: ${KEYCLOAK_URL} + KEYCLOAK_ISSUER_URL: ${KC_HOSTNAME_URL} CLIENT_SECRET: ${CLIENT_SECRET} MONGODB_URI: mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo:27017/${MONGO_DB}?authSource=${MONGO_DB} MONGODB_DB: securelearning @@ -249,8 +251,8 @@ services: context: ../web dockerfile: Dockerfile args: - VITE_API_URL: ${API_URL} - VITE_KEYCLOAK_URL: ${KEYCLOAK_URL} + VITE_API_URL: ${API_URL}/api + VITE_KEYCLOAK_URL: ${KC_HOSTNAME_URL} VITE_BASE_PATH: ${VITE_BASE_PATH} VITE_WEB_URL: ${WEB_URL} container_name: frontend diff --git a/smtp/src/consumer.py b/smtp/src/consumer.py index 0c37521a..9d148713 100644 --- a/smtp/src/consumer.py +++ b/smtp/src/consumer.py @@ -48,6 +48,15 @@ def _handle_message( data = json.loads(body) email_message = EmailMessage(**data) self.email_sender.send(email_message) + + tracking_msg = json.dumps({"action": "sent", "tracking_id": email_message.tracking_id}) + if self._channel and self._channel.is_open: + self._channel.basic_publish( + exchange="", + routing_key=self.rabbitmq_config.RABBITMQ_TRACKING_QUEUE, + body=tracking_msg + ) + except json.JSONDecodeError: print("Error: Failed to decode JSON body") except ValidationError as e: diff --git a/smtp/src/core/config.py b/smtp/src/core/config.py index bf8759c7..a57cf0fd 100644 --- a/smtp/src/core/config.py +++ b/smtp/src/core/config.py @@ -16,6 +16,7 @@ class RabbitMQConfig(BaseSettings): RABBITMQ_USER: str RABBITMQ_PASS: str RABBITMQ_QUEUE: str + RABBITMQ_TRACKING_QUEUE: str = "tracking_queue" @property def credentials(self) -> pika.PlainCredentials: diff --git a/smtp/src/emails/email_sender.py b/smtp/src/emails/email_sender.py index e25c8980..23ea1bcb 100644 --- a/smtp/src/emails/email_sender.py +++ b/smtp/src/emails/email_sender.py @@ -49,22 +49,6 @@ def _send_via_smtp( print(f"Email sent successfully to {receiver}") - def _notify_tracking(self, tracking_id: str) -> None: - """Notify the API that an email was successfully sent.""" - config = APIConfig() - api_internal_url = config.API_INTERNAL_URL - try: - response = requests.post( - f"{api_internal_url}/api/track/sent?si={tracking_id}", - timeout=5 - ) - if response.ok: - print(f"Tracking recorded for {tracking_id}") - else: - print(f"Failed to record tracking: {response.status_code}") - except Exception as e: - print(f"Failed to notify tracking API: {e}") - def send(self, email_message: EmailMessage) -> None: """Process and send an email from an EmailMessage payload.""" @@ -104,7 +88,4 @@ def send(self, email_message: EmailMessage) -> None: sender=email_message.sender_email, receiver=email_message.receiver_email, message=message - ) - - # Notify tracking API that email was sent - self._notify_tracking(tracking_id) + ) \ No newline at end of file diff --git a/web/DESIGN_SYSTEM.md b/web/DESIGN_SYSTEM.md index c2fba759..78a49431 100644 --- a/web/DESIGN_SYSTEM.md +++ b/web/DESIGN_SYSTEM.md @@ -1,305 +1,120 @@ # SecureLearning — Design System -> Read this before adding any new page, component, or style. Goal: fully consistent UI across all modules and personas in light **and** dark mode. +> Read this before adding any new page, component, or style. Goal: fully consistent UI across all modules and personas in light, dark, and accessible modes. --- -## 1. Themes +## 1. Themes & Accessibility -Supports **Light**, **Dark**, **Deuteranopia**, and **Protanopia**. Toggled by adding the corresponding class (`.dark`, `.deuteranopia`, `.protanopia`) to ``. Uses the **View Transition API** clip-path wipe via `useThemeTransition`. +The application supports five core themes. The theme is changed by adding the corresponding class to the `` element. + +| Theme | Class | Purpose | Primary / Accent | +|---|---|---|---| +| **Light** | (default) | Standard bright mode | Purple (`#7C3AED`) | +| **Dark** | `.dark` | Low-light environment | Purple (`#7C3AED`) | +| **Deuteranopia** | `.deuteranopia` | Green-blindness | Cobalt Blue (`#005AB5`) | +| **Protanopia** | `.protanopia` | Red-blindness | Gold/Orange (`#E69F00`) | +| **Tritanopia** | `.tritanopia` | Blue-blindness | Crimson Red (`#D62828`) | + +### Theme Transitions +We use the **View Transition API** for fluid switching. The `useThemeTransition` hook performs a **center-out circular expansion** (500ms duration). ```ts const setTheme = useThemeTransition() -setTheme('dark') // 'light' | 'dark' | 'deuteranopia' | 'protanopia' | 'system' +setTheme('tritanopia') // 'light' | 'dark' | 'deuteranopia' | 'protanopia' | 'tritanopia' | 'system' ``` --- ## 2. Colour Tokens -All colours live in `src/globals.css` and are exposed to Tailwind via `@theme inline`. **Never hardcode hex values in JSX.** - -| Token | Light | Dark | Usage | -|---|---|---|---| -| `bg-background` | `#FFF` | `#0C0A0F` | Page root, overlays | -| `bg-surface` | `#FFF` | `#18151C` | Cards, tables, panels, modals | -| `bg-surface-subtle` | `#F9FAFB` | `#131019` | Table header rows, modal footers, sidebar | -| `bg-muted` | `#F3F4F6` | `#18151C` | View-toggle pill, tag backgrounds | -| `text-foreground` | `#111827` | `#EDEDED` | All primary labels | -| `text-muted-foreground` | `#6B7280` | `#A1A1AA` | Secondary labels, `