Skip to content
Merged

Dev #167

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 api/src/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
from src.core.settings import settings
import src.models

engine = create_engine(str(settings.PGSQL_DATABASE_URI))
engine = create_engine(
str(settings.PGSQL_DATABASE_URI),
pool_size=settings.POSTGRES_POOL_SIZE,
max_overflow=settings.POSTGRES_MAX_OVERFLOW,
)


async def init_db():
Expand Down
3 changes: 3 additions & 0 deletions api/src/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class Settings(BaseSettings):
POSTGRES_USER: str
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
POSTGRES_POOL_SIZE: int = 15
POSTGRES_MAX_OVERFLOW: int = 20

# Keycloak
KEYCLOAK_URL: str = ""
Expand Down Expand Up @@ -61,6 +63,7 @@ class Settings(BaseSettings):
GARAGE_CONTENT_PREFIX: str = "content"
GARAGE_LOGOS_PREFIX: str = "logos"
GARAGE_PRESIGNED_URL_TTL_SECONDS: int = 900
CONTENT_PUBLIC_URL_SECRET: str = ""

# RabbitMQ
RABBITMQ_HOST: str
Expand Down
12 changes: 11 additions & 1 deletion api/src/routers/content.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Annotated

from fastapi import APIRouter, File, Form, UploadFile, status
from fastapi import APIRouter, File, Form, Query, UploadFile, status

from src.core.dependencies import CurrentRealm
from src.services import content as content_service
Expand Down Expand Up @@ -91,6 +91,16 @@ async def get_content_file_url(content_piece_id: str, _: CurrentRealm) -> dict[s
return {"url": await content_service.get_content_file_url(content_piece_id)}


@router.get("/content-public/{content_piece_id}/file")
async def get_public_content_file(
content_piece_id: str,
expires: Annotated[int, Query(...)],
sig: Annotated[str, Query(min_length=1)],
):
content_service.verify_public_content_signature(content_piece_id, expires, sig)
return await content_service.stream_content_file(content_piece_id)


@router.delete("/content/{content_piece_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_content(content_piece_id: str, _: CurrentRealm) -> None:
await content_service.delete_content_piece(content_piece_id)
91 changes: 83 additions & 8 deletions api/src/services/content.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import hashlib
import hmac
from datetime import datetime, timezone
from typing import Any, Literal
from urllib.parse import urlencode
from uuid import uuid4

from fastapi import HTTPException, UploadFile, status
from fastapi.responses import RedirectResponse
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field

from src.core.mongo import (
Expand All @@ -18,7 +21,7 @@
delete_object,
ensure_bucket,
garage_enabled,
generate_presigned_get_url,
get_object,
put_bytes,
)
from src.core.settings import settings
Expand All @@ -32,6 +35,7 @@
CONTENT_NOT_FOUND = "Content not found"
FILE_NOT_FOUND = "File not found"
FOLDER_NOT_FOUND = "Folder not found"
PUBLIC_CONTENT_URL_TTL_SECONDS = 900


class ContentPieceCreate(BaseModel):
Expand Down Expand Up @@ -176,7 +180,43 @@ def _content_file_url(file_meta: dict[str, Any] | None) -> str | None:
if not isinstance(object_key, str) or not object_key:
return None

return generate_presigned_get_url(bucket=settings.GARAGE_BUCKET_CONTENT, key=object_key)
return generate_public_content_url(file_meta.get("content_piece_id"))


def _content_public_secret() -> str:
secret = settings.CONTENT_PUBLIC_URL_SECRET.strip() or settings.CLIENT_SECRET.strip()
if not secret:
raise ObjectStorageError("Content public URL secret is not configured")
return secret


def _sign_public_content_url(content_piece_id: str, expires: int) -> str:
payload = f"{content_piece_id}:{expires}".encode()
return hmac.new(
_content_public_secret().encode(),
payload,
hashlib.sha256,
).hexdigest()


def generate_public_content_url(content_piece_id: str | None, expires_in: int = PUBLIC_CONTENT_URL_TTL_SECONDS) -> str | None:
if not isinstance(content_piece_id, str) or not content_piece_id:
return None

expires = int(datetime.now(timezone.utc).timestamp()) + expires_in
sig = _sign_public_content_url(content_piece_id, expires)
query = urlencode({"expires": expires, "sig": sig})
return f"/garage/{content_piece_id}?{query}"


def verify_public_content_signature(content_piece_id: str, expires: int, sig: str) -> None:
now_ts = int(datetime.now(timezone.utc).timestamp())
if expires < now_ts:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Content URL expired")

expected = _sign_public_content_url(content_piece_id, expires)
if not hmac.compare_digest(sig, expected):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid content URL signature")


async def _load_folders_by_id() -> dict[str, dict[str, Any]]:
Expand Down Expand Up @@ -217,6 +257,7 @@ def _to_content_out(
payload["path"] = _build_folder_path(payload.get("folder_id"), folders_by_id or {})
file_meta = payload.get("file")
if isinstance(file_meta, dict):
file_meta["content_piece_id"] = payload.get("content_piece_id")
file_meta["file_url"] = _content_file_url(file_meta) if include_file_url else None
return ContentPieceOut.model_validate(payload)

Expand Down Expand Up @@ -559,11 +600,8 @@ async def upload_content_piece(
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc


async def download_content_file(content_piece_id: str) -> RedirectResponse:
url = await get_content_file_url(content_piece_id)
if not url:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=FILE_NOT_FOUND)
return RedirectResponse(url=url, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
async def download_content_file(content_piece_id: str) -> StreamingResponse:
return await stream_content_file(content_piece_id)


async def get_content_file_url(content_piece_id: str) -> str | None:
Expand All @@ -580,11 +618,48 @@ async def get_content_file_url(content_piece_id: str) -> str | None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=FILE_NOT_FOUND)

try:
file_meta["content_piece_id"] = content_piece_id
return _content_file_url(file_meta)
except ObjectStorageError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc


async def stream_content_file(content_piece_id: str) -> StreamingResponse:
collection = get_content_collection()
doc = await collection.find_one(
{"kind": "content_piece", "content_piece_id": content_piece_id},
{"file": 1},
)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=CONTENT_NOT_FOUND)

file_meta = doc.get("file")
if not isinstance(file_meta, dict):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=FILE_NOT_FOUND)

object_key = file_meta.get("object_key")
if not isinstance(object_key, str) or not object_key:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=FILE_NOT_FOUND)

content_type = file_meta.get("content_type") or "application/octet-stream"
filename = file_meta.get("filename") or "content"
size = file_meta.get("size")
etag = file_meta.get("etag")

try:
stored = await get_object(bucket=settings.GARAGE_BUCKET_CONTENT, key=object_key)
except ObjectStorageError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc

headers = {"Content-Disposition": f'inline; filename="{filename}"'}
if isinstance(size, int) and size >= 0:
headers["Content-Length"] = str(size)
if isinstance(etag, str) and etag:
headers["ETag"] = etag

return StreamingResponse(stored.stream, media_type=content_type, headers=headers)


async def delete_content_piece(content_piece_id: str) -> None:
collection = get_content_collection()
doc = await collection.find_one({"kind": "content_piece", "content_piece_id": content_piece_id})
Expand Down
2 changes: 1 addition & 1 deletion api/src/services/keycloak_admin/user_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def add_user(
user_id = location.rstrip("/").split("/")[-1]

# Trigger "Update Password" email
kc.execute_actions_email(realm_name, token, user_id, ["UPDATE_PASSWORD"])
# kc.execute_actions_email(realm_name, token, user_id, ["UPDATE_PASSWORD"])

# Determine org manager flag from requested role
is_org_manager = (role or "").strip().upper() == "ORG_MANAGER"
Expand Down
12 changes: 9 additions & 3 deletions api/src/services/platform_admin/user_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,19 @@ def get_current_user_profile(self, token: str) -> CurrentUserProfileDTO:
"""Resolve current user from bearer token and return base profile info."""
claims = decode_token_verified(token)
realm = get_realm_from_iss(claims.get("iss"))
profile = self.admin.keycloak_client.get_userinfo(realm, token)
user_id = profile.get("sub") or claims.get("sub")
user_id = claims.get("sub")

if not user_id:
raise HTTPException(status_code=401, detail="Unable to resolve current user")

return self._build_current_user_profile(realm, user_id, claims, profile, {})
admin_user: dict = {}
try:
admin_token = self.admin._get_admin_token()
admin_user = self.admin.keycloak_client.get_user(realm, admin_token, user_id) or {}
except HTTPException:
admin_user = {}

return self._build_current_user_profile(realm, user_id, claims, {}, admin_user)

def delete_user_in_realm(
self, realm: str, user_id: str, session: Session, token: str
Expand Down
38 changes: 19 additions & 19 deletions api/test/services/test_keycloak_admin_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,25 +77,25 @@ def test_create_realm_with_smtp_and_required_actions(mock_kc):
assert payload["clients"] == template["clients"]
assert payload["users"] == template["users"]

def test_add_user_triggers_email(mock_kc):
# Arrange
uh = user_handler()
session = MagicMock()

mock_response = MagicMock(
status_code=201,
headers={"Location": "http://keycloak/u/123"}
)
mock_kc.create_user.return_value = mock_response
mock_kc.get_realm_role.return_value = {"id": "r1"}

# Act
uh.add_user(session, "realm", "user", "pass", "Full Name", "user@test.com", "ORG_MANAGER")

# Assert
mock_kc.execute_actions_email.assert_called_once_with(
"realm", ANY, "123", ["UPDATE_PASSWORD"]
)
# def test_add_user_triggers_email(mock_kc):
# # Arrange
# uh = user_handler()
# session = MagicMock()
#
# mock_response = MagicMock(
# status_code=201,
# headers={"Location": "http://keycloak/u/123"}
# )
# mock_kc.create_user.return_value = mock_response
# mock_kc.get_realm_role.return_value = {"id": "r1"}
#
# # Act
# uh.add_user(session, "realm", "user", "pass", "Full Name", "user@test.com", "ORG_MANAGER")
#
# # Assert
# mock_kc.execute_actions_email.assert_called_once_with(
# "realm", ANY, "123", ["UPDATE_PASSWORD"]
# )

def test_add_user_location_missing(mock_kc):
uh = user_handler()
Expand Down
6 changes: 3 additions & 3 deletions api/test/services/test_user_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ def test_create_user_success(service, mock_kc, session: Session):
# Assert
assert res["status"] == "created"
assert res["username"] == "john.doe"
mock_kc.execute_actions_email.assert_called_once_with(
realm_name, "token", "123", ["UPDATE_PASSWORD"]
)
# mock_kc.execute_actions_email.assert_called_once_with(
# realm_name, "token", "123", ["UPDATE_PASSWORD"]
# )

# Verify DB
db_user = session.get(User, "123")
Expand Down
3 changes: 2 additions & 1 deletion deployment/.env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ MONGO_PASSWORD=template_pass
# Garage object storage (S3-compatible API)
FILE_STORAGE_BACKEND=garage
GARAGE_S3_ENDPOINT=http://garage:3900
GARAGE_S3_PUBLIC_ENDPOINT=http://localhost:3900
GARAGE_S3_PUBLIC_ENDPOINT=https://mednat.ieeta.pt:9072
GARAGE_S3_REGION=garage
GARAGE_ACCESS_KEY_ID=garage-access-key
GARAGE_SECRET_ACCESS_KEY=garage-secret-key
Expand Down Expand Up @@ -55,6 +55,7 @@ KEYCLOAK_ISSUER_URL=https://mednat.ieeta.pt:9071/kc/realms/platform

# Port Configuration
NGINX_PORT=8081
GARAGE_PUBLIC_PORT=9072
SERVER_PORT=9071

# TLS certificate file names inside deployment/certs
Expand Down
1 change: 1 addition & 0 deletions deployment/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ services:
restart: unless-stopped
ports:
- "${NGINX_PORT}:8081"
- "${GARAGE_PUBLIC_PORT}:8082"
expose:
- "8080"
volumes:
Expand Down
22 changes: 22 additions & 0 deletions deployment/nginx.mednat.conf
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ http {
server keycloak:8080 resolve max_fails=3 fail_timeout=30s;
}

upstream garages {
zone garages 64k;
server garage:3900 resolve max_fails=3 fail_timeout=30s;
}

upstream grafanas {
zone grafanas 64k;
server grafana:3000 resolve max_fails=3 fail_timeout=30s;
Expand Down Expand Up @@ -97,6 +102,23 @@ http {
proxy_set_header X-Forwarded-Port $forwarded_port;
}

# Garage S3 API on same host via /garage for presigned object URLs.
location = /garage {
return 301 /garage/;
}
location /garage/ {
rewrite ^/garage/(.*)$ /api/content-public/$1/file break;
proxy_pass http://apis;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $forwarded_port;
}


# Frontend entrypoint is /app.
location = / {
Expand Down
Loading
Loading