diff --git a/api/src/core/settings.py b/api/src/core/settings.py index 07c1820..29270f1 100644 --- a/api/src/core/settings.py +++ b/api/src/core/settings.py @@ -63,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 diff --git a/api/src/routers/content.py b/api/src/routers/content.py index 8922b1d..3bb177d 100644 --- a/api/src/routers/content.py +++ b/api/src/routers/content.py @@ -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 @@ -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) diff --git a/api/src/services/content.py b/api/src/services/content.py index 3a7d69a..861737a 100644 --- a/api/src/services/content.py +++ b/api/src/services/content.py @@ -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 ( @@ -18,7 +21,7 @@ delete_object, ensure_bucket, garage_enabled, - generate_presigned_get_url, + get_object, put_bytes, ) from src.core.settings import settings @@ -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): @@ -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]]: @@ -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) @@ -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: @@ -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}) diff --git a/api/src/services/platform_admin/user_handler.py b/api/src/services/platform_admin/user_handler.py index 77e0a64..d907453 100644 --- a/api/src/services/platform_admin/user_handler.py +++ b/api/src/services/platform_admin/user_handler.py @@ -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 diff --git a/deployment/.env.prod.example b/deployment/.env.prod.example index b432f2d..52e2c5d 100644 --- a/deployment/.env.prod.example +++ b/deployment/.env.prod.example @@ -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=https://mednat.ieeta.pt:9071/garage +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 @@ -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 diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 28d6221..fef184e 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -7,6 +7,7 @@ services: restart: unless-stopped ports: - "${NGINX_PORT}:8081" + - "${GARAGE_PUBLIC_PORT}:8082" expose: - "8080" volumes: diff --git a/deployment/nginx.mednat.conf b/deployment/nginx.mednat.conf index f7c27dc..eb40cd9 100644 --- a/deployment/nginx.mednat.conf +++ b/deployment/nginx.mednat.conf @@ -107,8 +107,8 @@ http { return 301 /garage/; } location /garage/ { - rewrite ^/garage/(.*)$ /$1 break; - proxy_pass http://garages; + 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;