From ffd63018d6c3050f0ead9458ed7b10ec012cd3b4 Mon Sep 17 00:00:00 2001 From: benya Date: Sat, 7 Mar 2026 22:52:05 +0300 Subject: [PATCH] fix: make media uploads work behind docker - add S3_PUBLIC_ENDPOINT_URL for browser-reachable presigned urls - support both public/internal file url validation - configure MinIO bucket CORS in minio-init - update env examples and docs --- .env.docker.example | 2 ++ .env.example | 1 + README.md | 4 +++- app/config/settings.py | 1 + app/media/service.py | 18 +++++++++++------- docker-compose.yml | 15 ++++++++++++++- 6 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 7b46c2f..d2487c1 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -27,6 +27,8 @@ MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 S3_REGION=us-east-1 S3_BUCKET_NAME=messenger-media +S3_PUBLIC_ENDPOINT_URL=http://localhost:9000 +S3_CORS_ALLOW_ORIGIN=* S3_PRESIGN_EXPIRE_SECONDS=900 MAX_UPLOAD_SIZE_BYTES=104857600 diff --git a/.env.example b/.env.example index 19c5aac..0f49aa1 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ POSTGRES_DSN=postgresql+asyncpg://postgres:postgres@localhost:5432/messenger REDIS_URL=redis://localhost:6379/0 S3_ENDPOINT_URL=http://localhost:9000 +S3_PUBLIC_ENDPOINT_URL=http://localhost:9000 S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin S3_REGION=us-east-1 diff --git a/README.md b/README.md index c3786d7..2000fdd 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ celery -A app.celery_app:celery_app worker --loglevel=info Run full stack (web + api + worker + postgres + redis + minio + mailpit): 1. cp .env.docker.example .env -2. edit `.env` (`SECRET_KEY`, passwords, domain) +2. edit `.env` (`SECRET_KEY`, passwords, domain, `S3_PUBLIC_ENDPOINT_URL`) 3. docker compose up -d --build 2. Open: - Web: http://localhost @@ -49,3 +49,5 @@ Run full stack (web + api + worker + postgres + redis + minio + mailpit): Use production override to close internal ports (postgres/redis/minio/mailpit/backend): docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build + +For media uploads from browser, `S3_PUBLIC_ENDPOINT_URL` must be reachable by users (for example `https://storage.example.com` or `http://SERVER_IP:9000`). diff --git a/app/config/settings.py b/app/config/settings.py index f72dac9..b04768f 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): redis_url: str = "redis://localhost:6379/0" s3_endpoint_url: str = "http://localhost:9000" + s3_public_endpoint_url: str | None = None s3_access_key: str = "minioadmin" s3_secret_key: str = "minioadmin" s3_region: str = "us-east-1" diff --git a/app/media/service.py b/app/media/service.py index 6266223..97014ac 100644 --- a/app/media/service.py +++ b/app/media/service.py @@ -38,13 +38,16 @@ def _sanitize_filename(file_name: str) -> str: def _build_file_url(bucket: str, object_key: str) -> str: - base = settings.s3_endpoint_url.rstrip("/") + base = (settings.s3_public_endpoint_url or settings.s3_endpoint_url).rstrip("/") encoded_key = quote(object_key) return f"{base}/{bucket}/{encoded_key}" -def _allowed_file_url_prefix() -> str: - return f"{settings.s3_endpoint_url.rstrip('/')}/{settings.s3_bucket_name}/" +def _allowed_file_url_prefixes() -> tuple[str, ...]: + endpoints = [settings.s3_endpoint_url] + if settings.s3_public_endpoint_url: + endpoints.append(settings.s3_public_endpoint_url) + return tuple(f"{endpoint.rstrip('/')}/{settings.s3_bucket_name}/" for endpoint in endpoints) def _validate_media(file_type: str, file_size: int) -> None: @@ -54,10 +57,10 @@ def _validate_media(file_type: str, file_size: int) -> None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="File size exceeds limit") -def _get_s3_client(): +def _get_s3_client(endpoint_url: str): return boto3.client( "s3", - endpoint_url=settings.s3_endpoint_url, + endpoint_url=endpoint_url, aws_access_key_id=settings.s3_access_key, aws_secret_access_key=settings.s3_secret_key, region_name=settings.s3_region, @@ -73,7 +76,8 @@ async def generate_upload_url(payload: UploadUrlRequest) -> UploadUrlResponse: bucket = settings.s3_bucket_name try: - s3_client = _get_s3_client() + presign_endpoint = settings.s3_public_endpoint_url or settings.s3_endpoint_url + s3_client = _get_s3_client(presign_endpoint) upload_url = s3_client.generate_presigned_url( "put_object", Params={ @@ -103,7 +107,7 @@ async def store_attachment_metadata( payload: AttachmentCreateRequest, ) -> AttachmentRead: _validate_media(payload.file_type, payload.file_size) - if not payload.file_url.startswith(_allowed_file_url_prefix()): + if not payload.file_url.startswith(_allowed_file_url_prefixes()): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid file URL") message = await get_message_by_id(db, payload.message_id) diff --git a/docker-compose.yml b/docker-compose.yml index 49d0dcc..1d6b7ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ x-app-env: &app-env POSTGRES_DSN: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-messenger} REDIS_URL: redis://redis:6379/0 S3_ENDPOINT_URL: http://minio:9000 + S3_PUBLIC_ENDPOINT_URL: ${S3_PUBLIC_ENDPOINT_URL:-http://localhost:${MINIO_API_PORT:-9000}} S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} S3_REGION: ${S3_REGION:-us-east-1} @@ -88,7 +89,19 @@ services: entrypoint: > /bin/sh -c " mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} && - mc mb --ignore-existing local/${S3_BUCKET_NAME:-messenger-media} + mc mb --ignore-existing local/${S3_BUCKET_NAME:-messenger-media} && + cat > /tmp/cors-rules.json <