diff --git a/.gitignore b/.gitignore index 9df3a22..9ebd609 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,11 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pytest_cache/ -__pycache__/ \ No newline at end of file +__pycache__/ + +# Secrets +secrets/ + +.idea/ + +k8s/secrets.yaml \ No newline at end of file diff --git a/apps/agent-backend/.dockerignore b/apps/agent-backend/.dockerignore new file mode 100644 index 0000000..cc25703 --- /dev/null +++ b/apps/agent-backend/.dockerignore @@ -0,0 +1,17 @@ +__pycache__ +*.py[cod] +*.egg-info +.venv +venv +.env +.env.* +.git +.gitignore +.dockerignore +Dockerfile +docker-compose.yml +requirements.txt +.ruff_cache +.mypy_cache +tests +chroma_db diff --git a/apps/agent-backend/.env.example b/apps/agent-backend/.env.example new file mode 100644 index 0000000..a0c63ce --- /dev/null +++ b/apps/agent-backend/.env.example @@ -0,0 +1,21 @@ +# Copy to `.env` and fill in your values: + +# Local dev: poetry run uvicorn server.serve:app --host 0.0.0.0 --port 8002 +# Docker: docker compose up agent-service + +# Required — Groq LLM +GROQ_API_KEY=gsk_your_groq_key_here +MODEL_NAME=llama3.1 +LLM_LOCAL=False + +# Auth — session cookie validation +CORE_BACKEND_URL=http://localhost:8000 + +# Optional — Tavily web search +TAVILY_API_KEY= + +ENVIRONMENT=dev +PORT=8002 +LOGGING_LEVEL=INFO + +ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/apps/agent-backend/Dockerfile b/apps/agent-backend/Dockerfile new file mode 100644 index 0000000..688a207 --- /dev/null +++ b/apps/agent-backend/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV POETRY_VERSION=2.2.1 \ + POETRY_VIRTUALENVS_CREATE=false \ + POETRY_NO_INTERACTION=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app + +RUN pip install --no-cache-dir poetry==${POETRY_VERSION} + +COPY pyproject.toml poetry.lock README.md ./ +RUN poetry install --no-root + +COPY galacticview_bot ./galacticview_bot +COPY server ./server +RUN poetry install + +EXPOSE 8002 + +CMD ["uvicorn", "server.serve:app", "--host", "0.0.0.0", "--port", "8002", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/apps/agent-backend/galacticview_bot/nodes/reasoner.py b/apps/agent-backend/galacticview_bot/nodes/reasoner.py index 9f72ff6..f263fdd 100644 --- a/apps/agent-backend/galacticview_bot/nodes/reasoner.py +++ b/apps/agent-backend/galacticview_bot/nodes/reasoner.py @@ -1,4 +1,7 @@ +from groq import BadRequestError +from langchain_core.messages import BaseMessage from loguru import logger + from galacticview_bot.core.state import AgentState from galacticview_bot.nodes.model import llm from galacticview_bot.tools.search import tavily_search_tool @@ -6,6 +9,38 @@ tools = [tool for tool in [tavily_search_tool] if tool is not None] llm_with_tools = llm.bind_tools(tools, tool_choice="auto") if tools else llm +MAX_TOOL_RETRIES = 3 +TOOL_TEMPERATURE = 0.3 + + +def _is_tool_use_failed(error: BadRequestError) -> bool: + body = str(error) + return "tool_use_failed" in body + + +def _invoke_with_tool_retry(messages: list[BaseMessage]) -> BaseMessage: + """Invoke the tool-enabled LLM, retrying with lower temperature on Groq tool_use_failed.""" + temperature = TOOL_TEMPERATURE + + for attempt in range(MAX_TOOL_RETRIES): + try: + bound_llm = llm_with_tools.bind(temperature=temperature) + return bound_llm.invoke(messages) + except BadRequestError as error: + if not _is_tool_use_failed(error) or attempt == MAX_TOOL_RETRIES - 1: + raise + + temperature = max(temperature - 0.1, 0.0) + logger.warning( + "Groq tool call failed (attempt {}/{}), retrying with temperature {}", + attempt + 1, + MAX_TOOL_RETRIES, + temperature, + ) + + raise RuntimeError("Tool invocation failed after retries") + + def reasoner(state: AgentState) -> dict: """ The brain. Decides whether to search or answer. @@ -13,5 +48,19 @@ def reasoner(state: AgentState) -> dict: logger.info("Entering Reasoner Node") messages = state["messages"] - response = llm_with_tools.invoke(messages) + if tools: + try: + response = _invoke_with_tool_retry(messages) + except BadRequestError as error: + if _is_tool_use_failed(error): + logger.warning( + "Tool calling failed after retries, answering without search: {}", + error, + ) + response = llm.invoke(messages) + else: + raise + else: + response = llm_with_tools.invoke(messages) + return {"messages": [response]} diff --git a/apps/agent-backend/galacticview_bot/nodes/tool_node.py b/apps/agent-backend/galacticview_bot/nodes/tool_node.py index 4de7767..32122ec 100644 --- a/apps/agent-backend/galacticview_bot/nodes/tool_node.py +++ b/apps/agent-backend/galacticview_bot/nodes/tool_node.py @@ -4,7 +4,7 @@ from galacticview_bot.core.state import AgentState from galacticview_bot.tools.search import tavily_search_tool -tools = [tavily_search_tool] +tools = [tool for tool in [tavily_search_tool] if tool is not None] def custom_tool_node(state: AgentState) -> dict: """ diff --git a/apps/agent-backend/galacticview_bot/tools/search.py b/apps/agent-backend/galacticview_bot/tools/search.py index 142ef27..735b50b 100644 --- a/apps/agent-backend/galacticview_bot/tools/search.py +++ b/apps/agent-backend/galacticview_bot/tools/search.py @@ -10,6 +10,8 @@ class TavilyInput(BaseModel): query: str = Field(description="The search query to find information on the internet.") +tavily_search_tool = None + if os.getenv("TAVILY_API_KEY"): logger.info("TAVILY_API_KEY found. Initializing Tavily search tool.") tavily_search_tool = TavilySearch( diff --git a/apps/agent-backend/poetry.lock b/apps/agent-backend/poetry.lock index 671fe13..12fab2a 100644 --- a/apps/agent-backend/poetry.lock +++ b/apps/agent-backend/poetry.lock @@ -435,6 +435,11 @@ python-versions = ">=3.8" groups = ["main"] markers = "platform_python_implementation != \"CPython\"" files = [ + {file = "brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0"}, {file = "brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4"}, {file = "brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7"}, {file = "brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990"}, @@ -6704,4 +6709,4 @@ dev = ["mypy", "pytest", "ruff", "types-requests", "types-urllib3"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.14" -content-hash = "9314843362ffd92157a7a805e90c6969885e6e7a83e1d69d1678f3ea9530e0e0" +content-hash = "cb2a46322fb42fd1c0d86d28d465601a27ac979f52bd3c957825c8744aed0a64" diff --git a/apps/agent-backend/pyproject.toml b/apps/agent-backend/pyproject.toml index cfdd9c9..e99a9e8 100644 --- a/apps/agent-backend/pyproject.toml +++ b/apps/agent-backend/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "loguru (>=0.7.3,<0.8.0)", "fastapi[standard] (>=0.123.0,<0.124.0)", "slowapi (>=0.1.9,<0.2.0)", + "httpx (>=0.28.1,<0.29.0)", "langchain-community (>=0.4.1,<0.5.0)", "langchain-openai (>=1.2.1,<2.0.0)", ] diff --git a/apps/agent-backend/server/cors.py b/apps/agent-backend/server/cors.py new file mode 100644 index 0000000..149f939 --- /dev/null +++ b/apps/agent-backend/server/cors.py @@ -0,0 +1,33 @@ +import os + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +_DEV_ORIGIN_REGEX = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$" +_DEFAULT_ORIGINS = "http://localhost:5173,http://127.0.0.1:5173" + + +def add_cors_middleware(app: FastAPI) -> None: + """Apply CORS. In dev, allow any localhost port; in prod, use ALLOWED_ORIGINS.""" + if os.getenv("ENVIRONMENT", "prod") == "dev": + app.add_middleware( + CORSMiddleware, + allow_origin_regex=_DEV_ORIGIN_REGEX, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + return + + allowed_origins = [ + origin.strip() + for origin in os.getenv("ALLOWED_ORIGINS", _DEFAULT_ORIGINS).split(",") + if origin.strip() + ] + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/apps/agent-backend/server/dependencies.py b/apps/agent-backend/server/dependencies.py new file mode 100644 index 0000000..bed6175 --- /dev/null +++ b/apps/agent-backend/server/dependencies.py @@ -0,0 +1,44 @@ +import os + +import httpx +from dotenv import load_dotenv +from fastapi import HTTPException, Request + +load_dotenv() + +CORE_BACKEND_URL = os.getenv("CORE_BACKEND_URL", "http://localhost:8000") +AUTH_REQUEST_TIMEOUT = 5.0 + + +async def _verify_session(session_cookie: str) -> dict: + """ + Forwards the session cookie to the core-backend and returns the user dict. + Raises HTTPException on any auth failure. + """ + async with httpx.AsyncClient(timeout=AUTH_REQUEST_TIMEOUT) as client: + try: + response = await client.get( + f"{CORE_BACKEND_URL}/auth/me", + cookies={"session": session_cookie}, + ) + except httpx.RequestError as exc: + raise HTTPException(status_code=502, detail="Auth service unavailable") from exc + + if response.status_code == 401: + raise HTTPException(status_code=401, detail="Invalid or expired session") + + if response.status_code != 200: + raise HTTPException(status_code=502, detail="Auth service returned an error") + + return response.json().get("user", {}) + + +async def require_auth(request: Request) -> None: + """ + Lightweight auth guard — verifies the session cookie is present and valid. + """ + session_cookie = request.cookies.get("session") + if not session_cookie: + raise HTTPException(status_code=401, detail="Not authenticated") + + await _verify_session(session_cookie) diff --git a/apps/agent-backend/server/dto/chat_type_out.py b/apps/agent-backend/server/dto/chat_type_out.py index 4adf070..dc535c8 100644 --- a/apps/agent-backend/server/dto/chat_type_out.py +++ b/apps/agent-backend/server/dto/chat_type_out.py @@ -1,7 +1,11 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field + class ChatTypeOut(BaseModel): """Data transfer object for chat type output.""" + + model_config = ConfigDict(populate_by_name=True) + title: str content: str - key_metrics: list[str] + key_metrics: list[str] = Field(serialization_alias="keyMetrics") diff --git a/apps/agent-backend/server/serve.py b/apps/agent-backend/server/serve.py index 3711508..2d9bdc9 100644 --- a/apps/agent-backend/server/serve.py +++ b/apps/agent-backend/server/serve.py @@ -1,16 +1,15 @@ -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.errors import RateLimitExceeded -import uvicorn - import os -from .service import chat_ask_question +import uvicorn +from fastapi import APIRouter, Depends, FastAPI, Request +from loguru import logger +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from .dependencies import require_auth # loads .env via load_dotenv() +from .cors import add_cors_middleware from .dto import ChatTypeIn, ChatTypeOut - -from loguru import logger +from .service import chat_ask_question def get_real_ip(request: Request) -> str: """ @@ -31,27 +30,31 @@ def get_real_ip(request: Request) -> str: app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +add_cors_middleware(app) -@app.post("/chat") +agent_router = APIRouter(prefix="/agent", tags=["agent"]) + + +@agent_router.post("/chat") @limiter.limit("7/minute") -def chat_endpoint(request: Request, body: ChatTypeIn) -> ChatTypeOut: +def chat_endpoint( + request: Request, + body: ChatTypeIn, + _: None = Depends(require_auth), +) -> ChatTypeOut: """ Process chat questions using the agent and return structured responses. Rate limited to 7 requests per minute per IP. """ - logger.info("Received request to /chat endpoint") + logger.info("Received request to /agent/chat endpoint") response_data: ChatTypeOut = chat_ask_question(body) logger.info("Sending response back to client") return response_data + +app.include_router(agent_router) + def main() -> None: """ Main function to run the FastAPI app using Uvicorn. @@ -59,12 +62,11 @@ def main() -> None: env = os.getenv("ENVIRONMENT", "prod") reload = env == "dev" - host = "127.0.0.1" - if env == "prod": - host = "0.0.0.0" + host = "0.0.0.0" - logger.info(f"Starting server on {host}:8000 with reload={reload}") - uvicorn.run("server.serve:app", host=host, port=8000, reload=reload) + port = int(os.getenv("PORT", "8002")) + logger.info(f"Starting agent server on {host}:{port} (reload={reload}, ENVIRONMENT={env})") + uvicorn.run("server.serve:app", host=host, port=port, reload=reload) if __name__ == "__main__": main() \ No newline at end of file diff --git a/apps/agent-backend/server/service.py b/apps/agent-backend/server/service.py index 63e170a..918e045 100644 --- a/apps/agent-backend/server/service.py +++ b/apps/agent-backend/server/service.py @@ -6,6 +6,8 @@ from langchain_core.messages import HumanMessage import json +import uuid + def chat_ask_question(chat_input: ChatTypeIn) -> ChatTypeOut: """ @@ -20,8 +22,7 @@ def chat_ask_question(chat_input: ChatTypeIn) -> ChatTypeOut: } try: - # Hardcoded to a single thread ID to maintain context across all requests for initial testing. - thread_id = "initial-single-context-thread" + thread_id = str(uuid.uuid4()) config = { "configurable": {"thread_id": thread_id}, diff --git a/apps/blogpost-backend/.dockerignore b/apps/blogpost-backend/.dockerignore new file mode 100644 index 0000000..37dce10 --- /dev/null +++ b/apps/blogpost-backend/.dockerignore @@ -0,0 +1,16 @@ +__pycache__ +*.py[cod] +*.egg-info +.venv +venv +.env +.env.* +.git +.gitignore +.dockerignore +Dockerfile +docker-compose.yml +requirements.txt +.ruff_cache +.mypy_cache +tests diff --git a/apps/blogpost-backend/.env.example b/apps/blogpost-backend/.env.example new file mode 100644 index 0000000..0005d53 --- /dev/null +++ b/apps/blogpost-backend/.env.example @@ -0,0 +1,21 @@ +# Local dev: poetry run uvicorn app.main:app --host 0.0.0.0 --port 8001 +# Requires LocalStack on localhost:4566 +# Docker: docker compose up blog-service +# Run once: docker compose exec blog-service python -m app.scripts.init_db + +ENVIRONMENT=dev +PORT=8001 + +CORE_BACKEND_URL=http://localhost:8000 + +# LocalStack (local dev — use localhost; Docker overrides S3_ENDPOINT) +DYNAMODB_ENDPOINT=http://localhost:4566 +S3_ENDPOINT=http://localhost:4566 +S3_PUBLIC_ENDPOINT=http://localhost:4566 + +AWS_REGION=eu-central-1 +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +S3_BUCKET_NAME=galactic-blog-images + +ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/apps/blogpost-backend/Dockerfile b/apps/blogpost-backend/Dockerfile new file mode 100644 index 0000000..ffd2958 --- /dev/null +++ b/apps/blogpost-backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV POETRY_VERSION=2.2.1 \ + POETRY_VIRTUALENVS_CREATE=false \ + POETRY_NO_INTERACTION=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN pip install --no-cache-dir poetry==${POETRY_VERSION} + +COPY pyproject.toml poetry.lock README.md ./ +RUN poetry install --no-root + +COPY app ./app +RUN poetry install + +EXPOSE 8001 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/apps/blogpost-backend/app/api/dependencies.py b/apps/blogpost-backend/app/api/dependencies.py index eb66c1f..5fcaaac 100644 --- a/apps/blogpost-backend/app/api/dependencies.py +++ b/apps/blogpost-backend/app/api/dependencies.py @@ -3,7 +3,7 @@ import httpx from fastapi import Request, HTTPException -CORE_BACKEND_URL = os.getenv("CORE_BACKEND_URL", "http://localhost:8001") +CORE_BACKEND_URL = os.getenv("CORE_BACKEND_URL", "http://localhost:8000") async def _verify_session(session_cookie: str) -> dict: diff --git a/apps/blogpost-backend/app/core/aws.py b/apps/blogpost-backend/app/core/aws.py index 2d3ebbe..01da0e1 100644 --- a/apps/blogpost-backend/app/core/aws.py +++ b/apps/blogpost-backend/app/core/aws.py @@ -17,11 +17,9 @@ dynamodb = boto3.resource('dynamodb', **dynamodb_args) # Configure the S3 client (for future image uploads) -s3_args = { - "region_name": AWS_REGION, - "config": Config(s3={'addressing_style': 'path'}) # Forces LocalStack compatibility -} +s3_args: dict[str, object] = {"region_name": AWS_REGION} if S3_ENDPOINT: - s3_args["endpoint_url"] = S3_ENDPOINT # Route traffic to LocalStack! + s3_args["endpoint_url"] = S3_ENDPOINT + s3_args["config"] = Config(s3={"addressing_style": "path"}) -s3_client = boto3.client('s3', **s3_args) \ No newline at end of file +s3_client = boto3.client("s3", **s3_args) \ No newline at end of file diff --git a/apps/blogpost-backend/app/main.py b/apps/blogpost-backend/app/main.py index 3213b1b..22b92c7 100644 --- a/apps/blogpost-backend/app/main.py +++ b/apps/blogpost-backend/app/main.py @@ -11,16 +11,30 @@ app = FastAPI(title="GalacticView Blog Content Service") -ALLOWED_ORIGINS = [o.strip() - for o in os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",") if o.strip()] - -app.add_middleware( - CORSMiddleware, - allow_origins=ALLOWED_ORIGINS, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +_DEV_ORIGIN_REGEX = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$" +_DEFAULT_ORIGINS = "http://localhost:5173,http://127.0.0.1:5173" + +if os.getenv("ENVIRONMENT", "prod") == "dev": + app.add_middleware( + CORSMiddleware, + allow_origin_regex=_DEV_ORIGIN_REGEX, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) +else: + allowed_origins = [ + origin.strip() + for origin in os.getenv("ALLOWED_ORIGINS", _DEFAULT_ORIGINS).split(",") + if origin.strip() + ] + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) @app.get("/health", tags=["System"]) def health_check() -> dict[str, str]: diff --git a/apps/blogpost-backend/app/scripts/init_db.py b/apps/blogpost-backend/app/scripts/init_db.py index e5ddebd..23511ae 100644 --- a/apps/blogpost-backend/app/scripts/init_db.py +++ b/apps/blogpost-backend/app/scripts/init_db.py @@ -30,15 +30,14 @@ def setup_s3(): """ bucket_name = os.getenv("S3_BUCKET_NAME", "galactic-blog-images") region = os.getenv("AWS_REGION", "eu-central-1") - + try: - # AWS requires a specific LocationConstraint for regions outside us-east-1 if region == "us-east-1": s3_client.create_bucket(Bucket=bucket_name) else: s3_client.create_bucket( Bucket=bucket_name, - CreateBucketConfiguration={'LocationConstraint': region} + CreateBucketConfiguration={"LocationConstraint": region}, ) print(f"✅ S3 Bucket '{bucket_name}' created successfully!") except Exception as e: diff --git a/apps/blogpost-backend/app/services/blog_service.py b/apps/blogpost-backend/app/services/blog_service.py index fd6d491..b0d92f8 100644 --- a/apps/blogpost-backend/app/services/blog_service.py +++ b/apps/blogpost-backend/app/services/blog_service.py @@ -36,7 +36,13 @@ def fetch_all_blogs(self) -> list: """ Fetches all blog posts from the repository. """ - return self.repo.get_all_posts() + posts = self.repo.get_all_posts() + for post in posts: + post["image_urls"] = [ + self.storage_service.presign_from_url(url) + for url in post.get("image_urls", []) + ] + return posts def delete_blog(self, blog_id: str, requesting_author: str) -> dict: """ diff --git a/apps/blogpost-backend/app/services/storage_service.py b/apps/blogpost-backend/app/services/storage_service.py index a9a0d40..6c7e6d9 100644 --- a/apps/blogpost-backend/app/services/storage_service.py +++ b/apps/blogpost-backend/app/services/storage_service.py @@ -1,6 +1,7 @@ import os import uuid from typing import BinaryIO +from urllib.parse import urlparse from botocore.exceptions import ClientError @@ -22,11 +23,12 @@ class StorageService: def __init__(self) -> None: self.bucket_name = os.getenv("S3_BUCKET_NAME", "galactic-blog-images") self.s3_endpoint = os.getenv("S3_ENDPOINT") + self.s3_public_endpoint = os.getenv("S3_PUBLIC_ENDPOINT", self.s3_endpoint) self.aws_region = os.getenv("AWS_REGION", "eu-central-1") def _get_base_url(self) -> str: - if self.s3_endpoint: - return f"{self.s3_endpoint}/{self.bucket_name}" + if self.s3_public_endpoint: + return f"{self.s3_public_endpoint.rstrip('/')}/{self.bucket_name}" return f"https://{self.bucket_name}.s3.{self.aws_region}.amazonaws.com" def _object_exists(self, key: str) -> bool: @@ -38,6 +40,53 @@ def _object_exists(self, key: str) -> bool: return False raise + def _upload_extra_args(self, content_type: str) -> dict[str, str]: + extra_args: dict[str, str] = {"ContentType": content_type} + if self.s3_endpoint: + extra_args["ACL"] = "public-read" + return extra_args + + def _canonical_url(self, s3_key: str) -> str: + return f"{self._get_base_url()}/{s3_key}" + + def _presign_url(self, s3_key: str, expires_in: int = 86400) -> str: + if self.s3_endpoint: + return self._canonical_url(s3_key) + return s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": self.bucket_name, "Key": s3_key}, + ExpiresIn=expires_in, + ) + + def presign_from_url(self, image_url: str, expires_in: int = 86400) -> str: + key = self._url_to_key(image_url) + return self._presign_url(key, expires_in=expires_in) + + def _url_to_key(self, image_url: str) -> str: + url_without_query = image_url.split("?", 1)[0].rstrip("/") + parsed = urlparse(url_without_query) + host = parsed.netloc + path = parsed.path.lstrip("/") + + if host.startswith("s3.") or host == "s3.amazonaws.com": + bucket, _, key = path.partition("/") + if bucket == self.bucket_name and key: + return key + + if host.startswith(f"{self.bucket_name}."): + if path: + return path + + if self.s3_public_endpoint: + localstack_host = urlparse(self.s3_public_endpoint.rstrip("/")).netloc + if host == localstack_host and path.startswith(f"{self.bucket_name}/"): + return path[len(self.bucket_name) + 1 :] + + raise ImagePromotionError( + temp_image_url=image_url, + message="Unrecognized image URL — only uploaded images are accepted.", + ) + def upload_image(self, file_obj: BinaryIO, original_filename: str, content_type: str) -> str: """ Uploads an image to S3 under the "temp/" folder and returns its URL. @@ -51,7 +100,7 @@ def upload_image(self, file_obj: BinaryIO, original_filename: str, content_type: file_obj, self.bucket_name, s3_key, - ExtraArgs={"ContentType": content_type, "ACL": "public-read"} + ExtraArgs=self._upload_extra_args(content_type), ) if not self._object_exists(s3_key): @@ -60,24 +109,17 @@ def upload_image(self, file_obj: BinaryIO, original_filename: str, content_type: message="Image upload did not persist in storage. Please try again.", ) - return f"{self._get_base_url()}/{s3_key}" + return self._presign_url(s3_key) def promote_image(self, temp_image_url: str) -> str: """ Moves an image from temp/ to published/ and returns the new URL. All URLs are guaranteed to come from our own upload endpoint. """ - base = f"{self._get_base_url()}/" - if not temp_image_url.startswith(base): - raise ImagePromotionError( - temp_image_url=temp_image_url, - message="Unrecognized image URL — only uploaded images are accepted.", - ) - - source_key = temp_image_url[len(base):] + source_key = self._url_to_key(temp_image_url) if source_key.startswith("published/"): - return temp_image_url + return self._canonical_url(source_key) if not source_key.startswith("temp/"): raise ImagePromotionError( @@ -89,7 +131,7 @@ def promote_image(self, temp_image_url: str) -> str: new_key = f"published/{filename}" if self._object_exists(new_key): - return f"{self._get_base_url()}/{new_key}" + return self._canonical_url(new_key) if not self._object_exists(source_key): raise ImagePromotionError( @@ -101,12 +143,14 @@ def promote_image(self, temp_image_url: str) -> str: ) copy_source = {"Bucket": self.bucket_name, "Key": source_key} - s3_client.copy_object( - CopySource=copy_source, - Bucket=self.bucket_name, - Key=new_key, - ACL="public-read", - ) + copy_args: dict[str, object] = { + "CopySource": copy_source, + "Bucket": self.bucket_name, + "Key": new_key, + } + if self.s3_endpoint: + copy_args["ACL"] = "public-read" + s3_client.copy_object(**copy_args) s3_client.delete_object(Bucket=self.bucket_name, Key=source_key) - return f"{self._get_base_url()}/{new_key}" \ No newline at end of file + return self._canonical_url(new_key) diff --git a/apps/blogpost-backend/docker-compose.yml b/apps/blogpost-backend/docker-compose.yml deleted file mode 100644 index c186c4f..0000000 --- a/apps/blogpost-backend/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: "3.8" -services: - dynamodb-local: - image: amazon/dynamodb-local:latest - command: "-jar DynamoDBLocal.jar -sharedDb -inMemory" - ports: - - "8000:8000" - - localstack: - image: localstack/localstack:3.8.0 - container_name: galactic-localstack - ports: - - "4566:4566" - environment: - - SERVICES=s3 diff --git a/apps/blogpost-backend/poetry.lock b/apps/blogpost-backend/poetry.lock index cb8af62..0c4f926 100644 --- a/apps/blogpost-backend/poetry.lock +++ b/apps/blogpost-backend/poetry.lock @@ -393,10 +393,7 @@ files = [ librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=1.0.0" -typing_extensions = [ - {version = ">=4.6.0", markers = "python_version < \"3.15\""}, - {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, -] +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""} [package.extras] dmypy = ["psutil (>=4.0)"] @@ -418,9 +415,6 @@ files = [ {file = "mypy_boto3_dynamodb-1.43.0.tar.gz", hash = "sha256:f0cea38e058f1d07361ecb55d8f40665d824b42cf4864724c7fccc8bf3946fcd"}, ] -[package.dependencies] -typing-extensions = {version = "*", markers = "python_version < \"3.12\""} - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -813,5 +807,5 @@ dev = ["mypy", "ruff", "types-requests"] [metadata] lock-version = "2.1" -python-versions = "^3.11" -content-hash = "e8453873e3ad39bbab9712463102812a33f0677d791176afab4b519058ac438a" +python-versions = ">=3.12,<3.14" +content-hash = "356778f3872764ebc5d9e2f69143c25fe7479be5d072bcc667c469b48fd42d3d" diff --git a/apps/blogpost-backend/pyproject.toml b/apps/blogpost-backend/pyproject.toml index 4d4080b..3db429c 100644 --- a/apps/blogpost-backend/pyproject.toml +++ b/apps/blogpost-backend/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "Daroczi Levente",email = "daroczilevente2@gmail.com"} ] readme = "README.md" -requires-python = "^3.11" +requires-python = ">=3.12,<3.14" dependencies = [ "fastapi (>=0.136.1,<0.137.0)", "uvicorn (>=0.47.0,<0.48.0)", diff --git a/apps/core-backend/.dockerignore b/apps/core-backend/.dockerignore new file mode 100644 index 0000000..37dce10 --- /dev/null +++ b/apps/core-backend/.dockerignore @@ -0,0 +1,16 @@ +__pycache__ +*.py[cod] +*.egg-info +.venv +venv +.env +.env.* +.git +.gitignore +.dockerignore +Dockerfile +docker-compose.yml +requirements.txt +.ruff_cache +.mypy_cache +tests diff --git a/apps/core-backend/.env.example b/apps/core-backend/.env.example new file mode 100644 index 0000000..cf87b85 --- /dev/null +++ b/apps/core-backend/.env.example @@ -0,0 +1,16 @@ +# Local dev: poetry run python -m app.main +# Docker: docker compose up auth-service +# Mount Firebase credentials at ./secrets/firebase-service-account.json + +POSTGRESQL_USER=galactic +POSTGRESQL_PASSWORD=galactic +POSTGRESQL_HOST=localhost + +ENVIRONMENT=dev +PORT=8000 + +# Comma-separated frontend origins +ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Local dev only — Docker Compose sets this via GOOGLE_APPLICATION_CREDENTIALS mount +# GOOGLE_APPLICATION_CREDENTIALS=./config/firebase-service-account.json diff --git a/apps/core-backend/Dockerfile b/apps/core-backend/Dockerfile new file mode 100644 index 0000000..25f39eb --- /dev/null +++ b/apps/core-backend/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV POETRY_VERSION=2.2.1 \ + POETRY_VIRTUALENVS_CREATE=false \ + POETRY_NO_INTERACTION=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpq5 \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir poetry==${POETRY_VERSION} + +COPY pyproject.toml poetry.lock README.md ./ +RUN poetry install --no-root + +COPY app ./app +RUN poetry install + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/apps/core-backend/app/api/routes/auth.py b/apps/core-backend/app/api/routes/auth.py index afe794d..513b2ef 100644 --- a/apps/core-backend/app/api/routes/auth.py +++ b/apps/core-backend/app/api/routes/auth.py @@ -42,7 +42,8 @@ async def register( max_age=cookie_data["expires"], httponly=True, samesite="lax", - secure=False # True in production + secure=False, # True in production + path="/", ) return {"status": "success", "message": "Registered and logged in"} @@ -58,8 +59,13 @@ async def login( cookie_data = service.login_user(request.id_token) response.set_cookie( - key="session", value=cookie_data["cookie"], max_age=cookie_data["expires"], - httponly=True, samesite="lax", secure=False + key="session", + value=cookie_data["cookie"], + max_age=cookie_data["expires"], + httponly=True, + samesite="lax", + secure=False, + path="/", ) return {"status": "success", "message": "Login successful"} @@ -69,7 +75,7 @@ async def logout(response: Response) -> dict[str, str]: """ Clear the session cookie to log the user out. """ - response.delete_cookie(key="session") + response.delete_cookie(key="session", path="/") return {"status": "success", "message": "Logout successful"} diff --git a/apps/core-backend/app/main.py b/apps/core-backend/app/main.py index df002dc..ca8d8ca 100644 --- a/apps/core-backend/app/main.py +++ b/apps/core-backend/app/main.py @@ -48,13 +48,30 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +_DEV_ORIGIN_REGEX = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$" +_DEFAULT_ORIGINS = "http://localhost:5173,http://127.0.0.1:5173" + +if os.getenv("ENVIRONMENT", "prod") == "dev": + app.add_middleware( + CORSMiddleware, + allow_origin_regex=_DEV_ORIGIN_REGEX, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) +else: + allowed_origins = [ + origin.strip() + for origin in os.getenv("ALLOWED_ORIGINS", _DEFAULT_ORIGINS).split(",") + if origin.strip() + ] + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) app.include_router(auth.router) @@ -76,8 +93,9 @@ def main() -> None: if env == "prod": host = "0.0.0.0" - print(f"Starting server on {host}:8001 with reload={reload}") - uvicorn.run("app.main:app", host=host, port=8001, reload=reload) + port = int(os.getenv("PORT", "8000")) + print(f"Starting server on {host}:{port} with reload={reload}") + uvicorn.run("app.main:app", host=host, port=port, reload=reload) if __name__ == "__main__": main() \ No newline at end of file diff --git a/apps/core-backend/app/services/auth_service.py b/apps/core-backend/app/services/auth_service.py index 02f0e81..1dfb7fb 100644 --- a/apps/core-backend/app/services/auth_service.py +++ b/apps/core-backend/app/services/auth_service.py @@ -43,8 +43,20 @@ def register_user( def login_user(self, id_token: str) -> dict[str, Any]: """ - Creates cookie on login + Verify the Firebase token, ensure the user exists in PostgreSQL, then mint a session cookie. """ + try: + decoded_token = auth.verify_id_token(id_token) + uid = decoded_token["uid"] + except Exception: + raise HTTPException(status_code=401, detail="Invalid Firebase Token") + + if not self.user_repo.get_user_by_id(uid): + raise HTTPException( + status_code=404, + detail="User not found. Complete registration to create your profile.", + ) + return self._create_cookie(id_token) def get_user_by_id(self, user_id: str) -> User: diff --git a/apps/core-backend/poetry.lock b/apps/core-backend/poetry.lock index f256dd5..5c2c9b0 100644 --- a/apps/core-backend/poetry.lock +++ b/apps/core-backend/poetry.lock @@ -553,14 +553,8 @@ files = [ [package.dependencies] google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.63.2,<2.0.0" -grpcio = [ - {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, - {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\" and python_version < \"3.14\""}, -] -grpcio-status = [ - {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, - {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, -] +grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = [ {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, {version = ">=1.22.3,<2.0.0"}, @@ -636,10 +630,7 @@ files = [ google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]} google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" google-cloud-core = ">=2.0.0,<3.0.0" -grpcio = [ - {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, - {version = ">=1.33.2,<2.0.0"}, -] +grpcio = ">=1.33.2,<2.0.0" proto-plus = [ {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, {version = ">=1.22.3,<2.0.0"}, @@ -1297,10 +1288,7 @@ files = [ librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=1.0.0" -typing_extensions = [ - {version = ">=4.6.0", markers = "python_version < \"3.15\""}, - {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, -] +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""} [package.extras] dmypy = ["psutil (>=4.0)"] @@ -2075,5 +2063,5 @@ dev = ["mypy", "ruff", "types-requests"] [metadata] lock-version = "2.1" -python-versions = "^3.11" -content-hash = "2bf125a3396926ff3fc40a082e0aa1c614aae644c72d03f79e890bf146091212" +python-versions = ">=3.12,<3.14" +content-hash = "867ac62a1edbe3b403729d8a5c6b0fbb8838e29f4c6f3302d6518512b8f7033f" diff --git a/apps/core-backend/pyproject.toml b/apps/core-backend/pyproject.toml index 2b77160..03f62b9 100644 --- a/apps/core-backend/pyproject.toml +++ b/apps/core-backend/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "Daroczi Levente",email = "daroczilevente2@gmail.com"} ] readme = "README.md" -requires-python = "^3.11" +requires-python = ">=3.12,<3.14" dependencies = [ "fastapi (>=0.136.1,<0.137.0)", "sqlalchemy[asyncio] (>=2.0.49,<3.0.0)", diff --git a/apps/frontend/.dockerignore b/apps/frontend/.dockerignore index 82119a7..b63e47f 100644 --- a/apps/frontend/.dockerignore +++ b/apps/frontend/.dockerignore @@ -2,8 +2,6 @@ node_modules dist dist-ssr *.local -.env -.env.* .git .gitignore .dockerignore diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 0000000..6142ca0 --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1,18 @@ +# Local Vite dev: yarn dev (UI at http://localhost:5173) +# Docker Compose: UI at http://localhost:8080 + +# NASA APOD / image library +VITE_NASA_API_KEY=your-nasa-api-key + +# Backend API base URLs +VITE_CORE_API_BASE_URL=http://localhost:8000 +VITE_BLOGPOSTS_API_BASE_URL=http://localhost:8001 +VITE_AGENT_API_BASE_URL=http://localhost:8002 + +# Firebase web app config (Firebase console → Project settings) +VITE_FIREBASE_API_KEY=your-firebase-api-key +VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=your-project-id +VITE_FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app +VITE_FIREBASE_MESSAGING_SENDER_ID=123456789012 +VITE_FIREBASE_APP_ID=1:123456789012:web:abcdef0123456789 diff --git a/apps/frontend/.env.production b/apps/frontend/.env.production new file mode 100644 index 0000000..4f17776 --- /dev/null +++ b/apps/frontend/.env.production @@ -0,0 +1,21 @@ +# Production build (yarn build) — loaded ON TOP of .env when Vite is in +# `production` mode. Keep secrets in .env (gitignored); this file is safe +# to commit because it only contains routing paths. +# +# Relative paths make every API call same-origin via the Traefik Ingress: +# browser → http:///auth/... → auth-service (k8s Service) +# browser → http:///blogs/... → blog-service +# browser → http:///agent/... → agent-service +# +# Same-origin removes CORS entirely and means the same image works behind +# any DNS name without rebuilding. + +# auth.api.ts and blogposts.api.ts already include "/auth/..." and "/blogs/..." +# in every call, so their base URL must be "/" (NOT "/auth" or "/blogs", which +# would produce "/auth/auth/register" → 404). +# +# agent.api.ts only calls "/chat", so its base needs the "/agent" prefix. + +VITE_CORE_API_BASE_URL=/ +VITE_BLOGPOSTS_API_BASE_URL=/ +VITE_AGENT_API_BASE_URL=/agent diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 17e0497..c6cf3ae 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -7,17 +7,13 @@ RUN yarn install --frozen-lockfile COPY . . -ARG VITE_AGENT_API_BASE_URL -ENV VITE_AGENT_API_BASE_URL=$VITE_AGENT_API_BASE_URL - RUN yarn build - FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 3000 +EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/frontend/docker-compose.yml b/apps/frontend/docker-compose.yml deleted file mode 100644 index 770fbe1..0000000 --- a/apps/frontend/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: '3.8' - -services: - galactic-view: - build: - context: . - dockerfile: Dockerfile - args: - - VITE_AGENT_API_BASE_URL=https://localhost:8080 - ports: - - "3000:3000" - restart: always \ No newline at end of file diff --git a/apps/frontend/nginx.conf b/apps/frontend/nginx.conf index 04b890f..aa75871 100644 --- a/apps/frontend/nginx.conf +++ b/apps/frontend/nginx.conf @@ -1,20 +1,17 @@ server { - listen 3000; + listen 80; + listen [::]:80; server_name localhost; + root /usr/share/nginx/html; + index index.html; location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; } - location ~* \.(?:ico|css|js|gif|jpe?g|png)$ { - root /usr/share/nginx/html; + location ~* \.(?:ico|css|js|gif|jpe?g|png|svg|woff2?|ttf|eot)$ { expires 1y; access_log off; add_header Cache-Control "public"; } - - -} \ No newline at end of file +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 304dbd8..64670e1 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -27,8 +27,8 @@ "react-i18next": "^16.5.0", "react-icons": "^5.5.0", "react-medium-image-zoom": "^5.4.0", - "react-router": "^7.9.5", - "react-router-dom": "^7.9.5", + "react-router": "^7.15.0", + "react-router-dom": "^7.15.0", "react-textarea-autosize": "^8.5.9", "uuid": "^14.0.0" }, @@ -52,5 +52,8 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.1.7" + }, + "resolutions": { + "esbuild": "^0.28.1" } } diff --git a/apps/frontend/public/locales/en/translation.json b/apps/frontend/public/locales/en/translation.json index 8fc4dad..cf8d7ad 100644 --- a/apps/frontend/public/locales/en/translation.json +++ b/apps/frontend/public/locales/en/translation.json @@ -112,7 +112,9 @@ }, "agent": { "welcome": "\uD83D\uDC4B How can I help you explore the cosmos today?", - "placeholder": "Ask me anything about space..." + "placeholder": "Ask me anything about space...", + "loginRequired": "Please log in to ask the Galactic Agent.", + "oneQuestionOnly": "Refresh the page to ask a new question." }, "loading": "Loading..." } \ No newline at end of file diff --git a/apps/frontend/src/api/auth.api.ts b/apps/frontend/src/api/auth.api.ts index 4c67bc0..5e278c8 100644 --- a/apps/frontend/src/api/auth.api.ts +++ b/apps/frontend/src/api/auth.api.ts @@ -25,14 +25,18 @@ export const coreAPI = axios.create({ withCredentials: true, }); +const AUTH_FLOW_ENDPOINTS = new Set(['/auth/login', '/auth/register', '/auth/logout', '/auth/me']); + coreAPI.interceptors.response.use( (response) => { return response; }, async (error: AxiosError) => { const originalRequest = error.config; + const requestUrl = originalRequest?.url ?? ''; + const isAuthFlowRequest = AUTH_FLOW_ENDPOINTS.has(requestUrl); - if (error.response?.status === 401 && originalRequest?.url !== '/auth/me') { + if (error.response?.status === 401 && !isAuthFlowRequest) { console.warn('Session expired. Logging out...'); try { await coreAPI.post('/auth/logout'); diff --git a/apps/frontend/src/api/blogposts.api.ts b/apps/frontend/src/api/blogposts.api.ts index 4609e70..b70834c 100644 --- a/apps/frontend/src/api/blogposts.api.ts +++ b/apps/frontend/src/api/blogposts.api.ts @@ -17,12 +17,12 @@ export const blogPostsApi = axios.create({ }); export async function getAllBlogPosts(): Promise { - const res = await blogPostsApi.get('/blogs'); + const res = await blogPostsApi.get('/blogs/'); return res.data; } export async function createBlogPosts(newBlogPost: BlogPostTypeOut): Promise { - const res = await blogPostsApi.post('/blogs', newBlogPost); + const res = await blogPostsApi.post('/blogs/', newBlogPost); return res.data; } diff --git a/apps/frontend/src/components/AgentChatWidget.module.css b/apps/frontend/src/components/AgentChatWidget.module.css index 93d3215..01e095a 100644 --- a/apps/frontend/src/components/AgentChatWidget.module.css +++ b/apps/frontend/src/components/AgentChatWidget.module.css @@ -93,6 +93,12 @@ padding: 0.75rem; } +.authHint { + font-size: 0.875rem; + color: #6c757d; + margin-bottom: 0.5rem; +} + .inputGroup { position: relative; } diff --git a/apps/frontend/src/components/AgentChatWidget.tsx b/apps/frontend/src/components/AgentChatWidget.tsx index f701fdc..4fedb47 100644 --- a/apps/frontend/src/components/AgentChatWidget.tsx +++ b/apps/frontend/src/components/AgentChatWidget.tsx @@ -3,8 +3,10 @@ import * as React from 'react'; import { Button, Card, CloseButton, Form, InputGroup } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { IoSend } from 'react-icons/io5'; +import { Link } from 'react-router-dom'; import TextareaAutosize from 'react-textarea-autosize'; +import { useAuth } from '../context/AuthContext.tsx'; import useAgentChat from '../hooks/useAgentChat.ts'; import style from './AgentChatWidget.module.css'; @@ -12,12 +14,15 @@ const MAX_MESSAGE_LENGTH = 512; function AgentChatWidget() { const { t } = useTranslation(); + const { isAuthenticated } = useAuth(); const [isOpen, setIsOpen] = useState(false); const [inputMessage, setInputMessage] = useState(''); - const { messages, isLoading, sendMessage } = useAgentChat(); + const { messages, isLoading, questionSent, sendMessage } = useAgentChat(); const messagesEndRef = useRef(null); + const inputDisabled = !isAuthenticated || questionSent || isLoading; + useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); @@ -31,7 +36,7 @@ function AgentChatWidget() { const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); - if (inputMessage.trim() === '' || inputMessage.length > MAX_MESSAGE_LENGTH) return; + if (inputDisabled || inputMessage.trim() === '' || inputMessage.length > MAX_MESSAGE_LENGTH) return; setInputMessage(''); try { @@ -42,12 +47,19 @@ function AgentChatWidget() { }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (inputDisabled) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleSendMessage(e); } }; + const placeholder = !isAuthenticated + ? t('agent.loginRequired') + : questionSent + ? t('agent.oneQuestionOnly') + : t('agent.placeholder'); + return (
{isOpen ? ( @@ -77,18 +89,25 @@ function AgentChatWidget() {
-
void handleSendMessage(e)} onKeyDown={handleKeyDown}> + {!isAuthenticated ? ( +

+ {t('agent.loginRequired')} {t('navigation.login')} +

+ ) : null} + void handleSendMessage(e)}> setInputMessage(e.target.value)} + onKeyDown={handleKeyDown} + disabled={inputDisabled} /> - diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index 91a9e96..50adaff 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -53,21 +53,41 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const handleLogin: AuthContextType['login'] = async (email, password) => { try { const userCredential = await signInWithEmailAndPassword(auth, email, password); - await sendLoginRequest(userCredential); + const response = await sendLoginRequest(userCredential); + if (response.status !== 'success') { + throw new Error(response.message || 'Login failed.'); + } await refreshUser(); } catch (error) { + try { + await signOut(auth); + } catch (signOutError) { + console.warn('Failed signing out firebase after login failure:', signOutError); + } throw new Error(getApiErrorMessage(error, 'Login failed.')); } }; const handleRegister: AuthContextType['register'] = async (email, password, username, firstName, lastName) => { + let createdFirebaseUser = false; try { - const userCredential = await createUserWithEmailAndPassword(auth, email, password); + let userCredential; + try { + userCredential = await createUserWithEmailAndPassword(auth, email, password); + createdFirebaseUser = true; + } catch (error) { + const code = (error as { code?: string }).code; + if (code === 'auth/email-already-in-use') { + userCredential = await signInWithEmailAndPassword(auth, email, password); + } else { + throw error; + } + } await sendRegisterRequest(userCredential, username, firstName, lastName); await refreshUser(); } catch (error) { console.error('Error during registration:', error); - if (auth.currentUser) { + if (createdFirebaseUser && auth.currentUser) { try { await auth.currentUser.delete(); } catch (e) { diff --git a/apps/frontend/src/hooks/useAgentChat.ts b/apps/frontend/src/hooks/useAgentChat.ts index 390988a..2f9f184 100644 --- a/apps/frontend/src/hooks/useAgentChat.ts +++ b/apps/frontend/src/hooks/useAgentChat.ts @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { sendPromptToAgent } from '../api/agent.api.ts'; +import { getApiErrorMessage } from '../utils/authUtils'; export interface ChatMessage { id: string; @@ -14,28 +15,10 @@ export interface ChatMessage { error?: string; } -const SESSION_STORAGE_KEY = 'agent_chat_messages'; - const useAgentChat = () => { - const [messages, setMessages] = useState(() => { - try { - const storedMessages = sessionStorage.getItem(SESSION_STORAGE_KEY); - return storedMessages ? (JSON.parse(storedMessages) as ChatMessage[]) : []; - } catch (error) { - console.error('Failed to parse stored messages:', error); - return []; - } - }); - - useEffect(() => { - try { - sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(messages)); - } catch (error) { - console.error('Failed to store messages:', error); - } - }, [messages]); - + const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [questionSent, setQuestionSent] = useState(false); const sendMessage = async (msg: string) => { const userMessage: ChatMessage = { @@ -44,7 +27,7 @@ const useAgentChat = () => { sender: 'user', }; - setMessages((prev) => [...prev, userMessage]); + setMessages([userMessage]); setIsLoading(true); try { @@ -60,13 +43,14 @@ const useAgentChat = () => { }; setMessages((prev) => [...prev, agentMessage]); + setQuestionSent(true); } catch (error) { const errorMessage: ChatMessage = { id: uuidv4(), - message: 'Sorry, there was an error processing your request.', + message: getApiErrorMessage(error, 'Sorry, there was an error processing your request.'), sender: 'agent', isError: true, - error: (error as Error).message, + error: getApiErrorMessage(error, 'Sorry, there was an error processing your request.'), }; setMessages((prev) => [...prev, errorMessage]); } finally { @@ -74,17 +58,11 @@ const useAgentChat = () => { } }; - const clearChat = () => { - setMessages([]); - sessionStorage.removeItem(SESSION_STORAGE_KEY); - setIsLoading(false); - }; - return { messages, isLoading, + questionSent, sendMessage, - clearChat, }; }; diff --git a/apps/frontend/src/pages/BlogPostPageCreate.tsx b/apps/frontend/src/pages/BlogPostPageCreate.tsx index 31ffcba..0220ef2 100644 --- a/apps/frontend/src/pages/BlogPostPageCreate.tsx +++ b/apps/frontend/src/pages/BlogPostPageCreate.tsx @@ -169,12 +169,6 @@ function BlogPostPageCreate() { className={`d-flex flex-column mb-3 ${style.imageUrlItem}`} >
- - {url} -