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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ yarn-error.log*
__pycache__/

# Secrets
secrets/
secrets/

.idea/

k8s/secrets.yaml
2 changes: 1 addition & 1 deletion apps/agent-backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ RUN poetry install

EXPOSE 8002

CMD ["uvicorn", "server.serve:app", "--host", "0.0.0.0", "--port", "8002"]
CMD ["uvicorn", "server.serve:app", "--host", "0.0.0.0", "--port", "8002", "--proxy-headers", "--forwarded-allow-ips", "*"]
12 changes: 9 additions & 3 deletions apps/agent-backend/server/serve.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os

import uvicorn
from fastapi import Depends, FastAPI, Request
from fastapi import APIRouter, Depends, FastAPI, Request
from loguru import logger
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
Expand Down Expand Up @@ -33,7 +33,10 @@ def get_real_ip(request: Request) -> str:
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,
Expand All @@ -44,11 +47,14 @@ def chat_endpoint(
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.
Expand Down
2 changes: 1 addition & 1 deletion apps/blogpost-backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ RUN poetry install

EXPOSE 8001

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001", "--proxy-headers", "--forwarded-allow-ips", "*"]
10 changes: 4 additions & 6 deletions apps/blogpost-backend/app/core/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
s3_client = boto3.client("s3", **s3_args)
8 changes: 7 additions & 1 deletion apps/blogpost-backend/app/services/blog_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
63 changes: 49 additions & 14 deletions apps/blogpost-backend/app/services/storage_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import uuid
from typing import BinaryIO
from urllib.parse import urlparse

from botocore.exceptions import ClientError

Expand Down Expand Up @@ -41,10 +42,51 @@ def _object_exists(self, key: str) -> bool:

def _upload_extra_args(self, content_type: str) -> dict[str, str]:
extra_args: dict[str, str] = {"ContentType": content_type}
if not self.s3_endpoint:
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.
Expand All @@ -67,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(
Expand All @@ -96,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(
Expand All @@ -113,9 +148,9 @@ def promote_image(self, temp_image_url: str) -> str:
"Bucket": self.bucket_name,
"Key": new_key,
}
if not self.s3_endpoint:
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}"
return self._canonical_url(new_key)
2 changes: 1 addition & 1 deletion apps/core-backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ RUN poetry install

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
21 changes: 21 additions & 0 deletions apps/frontend/.env.production
Original file line number Diff line number Diff line change
@@ -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://<host>/auth/... → auth-service (k8s Service)
# browser → http://<host>/blogs/... → blog-service
# browser → http://<host>/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
1 change: 1 addition & 0 deletions apps/frontend/nginx.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
Expand Down
6 changes: 5 additions & 1 deletion apps/frontend/src/api/auth.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/api/blogposts.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export const blogPostsApi = axios.create({
});

export async function getAllBlogPosts(): Promise<BlogPostTypeIn[]> {
const res = await blogPostsApi.get<BlogPostTypeIn[]>('/blogs');
const res = await blogPostsApi.get<BlogPostTypeIn[]>('/blogs/');
return res.data;
}

export async function createBlogPosts(newBlogPost: BlogPostTypeOut): Promise<BlogPostTypeIn> {
const res = await blogPostsApi.post<BlogPostTypeIn>('/blogs', newBlogPost);
const res = await blogPostsApi.post<BlogPostTypeIn>('/blogs/', newBlogPost);
return res.data;
}

Expand Down
10 changes: 9 additions & 1 deletion apps/frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,17 @@ 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.'));
}
};
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function LoginPage() {

const onSubmitHandler = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');

try {
await login(email, password);
Expand Down
2 changes: 2 additions & 0 deletions k8s/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
doc.md
secrets.yaml
52 changes: 52 additions & 0 deletions k8s/agent-service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: agent-service
spec:
replicas: 1
selector:
matchLabels:
app: agent-service
template:
metadata:
labels:
app: agent-service
spec:
imagePullSecrets:
- name: registry-credentials
containers:
- name: agent-service
image: leventedaroczi/galactic-private:agent-latest
imagePullPolicy: Always
ports:
- containerPort: 8002
env:
- name: ENVIRONMENT
value: "prod"
- name: LOGGING_LEVEL
value: "INFO"
- name: CORE_BACKEND_URL
value: "http://auth-service"
- name: GROQ_API_KEY
valueFrom:
secretKeyRef:
name: galactic-secrets
key: GROQ_API_KEY
- name: TAVILY_API_KEY
valueFrom:
secretKeyRef:
name: galactic-secrets
key: TAVILY_API_KEY
optional: true
---
apiVersion: v1
kind: Service
metadata:
name: agent-service
spec:
selector:
app: agent-service
ports:
- protocol: TCP
port: 80
targetPort: 8002
Loading
Loading