diff --git a/app/config.py b/app/config.py index b30b55b..36048a6 100644 --- a/app/config.py +++ b/app/config.py @@ -6,6 +6,7 @@ from pydantic import BaseModel class MinioConfig(BaseModel): endpoint: str + public_endpoint: str = "" bucket: str = "snapledger" access_key: str secret_key: str diff --git a/app/models/transaction.py b/app/models/transaction.py index 36095b5..ebfad62 100644 --- a/app/models/transaction.py +++ b/app/models/transaction.py @@ -1,7 +1,18 @@ from pydantic import BaseModel -class UploadResponse(BaseModel): - success: bool - data: dict | None = None - error: str | None = None +class PresignRequest(BaseModel): + original_filename: str + + +class PresignData(BaseModel): + upload_url: str + object_key: str + ref_id: str + + +class ConfirmRequest(BaseModel): + ref_id: str + object_key: str + original_filename: str + user_note: str | None = None diff --git a/app/routers/upload.py b/app/routers/upload.py index aedc5b2..f721334 100644 --- a/app/routers/upload.py +++ b/app/routers/upload.py @@ -1,8 +1,10 @@ import logging +import uuid from fastapi import APIRouter, File, Form, UploadFile from fastapi.responses import JSONResponse +from app.models.transaction import ConfirmRequest, PresignRequest from app.services import database, minio router = APIRouter(tags=["upload"]) @@ -10,6 +12,66 @@ logger = logging.getLogger(__name__) MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +# In-memory pending references (ref_id → object_key) +_pending_refs: dict[str, str] = {} + + +@router.post("/transactions/presign") +async def presign_upload(req: PresignRequest): + object_key = minio._generate_object_key(req.original_filename) + ref_id = uuid.uuid4().hex + + try: + upload_url = minio.generate_presigned_put_url(object_key) + except Exception: + logger.exception("Failed to generate presigned URL") + return JSONResponse( + status_code=503, + content={"success": False, "error": "Storage service unavailable"}, + ) + + _pending_refs[ref_id] = object_key + + return { + "success": True, + "data": { + "upload_url": upload_url, + "object_key": object_key, + "ref_id": ref_id, + }, + } + + +@router.post("/transactions/confirm") +async def confirm_upload(req: ConfirmRequest): + expected_key = _pending_refs.pop(req.ref_id, None) + if expected_key is None: + return JSONResponse( + status_code=404, + content={"success": False, "error": "Invalid or expired reference ID"}, + ) + + if req.object_key != expected_key: + return JSONResponse( + status_code=400, + content={"success": False, "error": "Object key mismatch"}, + ) + + try: + result = database.insert_transaction( + image_object_key=req.object_key, + original_filename=req.original_filename, + user_note=req.user_note if req.user_note else None, + ) + except Exception: + logger.exception("Failed to save transaction metadata") + return JSONResponse( + status_code=500, + content={"success": False, "error": "Failed to save transaction metadata"}, + ) + + return {"success": True, "data": result} + @router.post("/transactions/upload") async def upload_transaction( @@ -17,7 +79,7 @@ async def upload_transaction( original_filename: str | None = Form(None), user_note: str | None = Form(None), ): - # Read file content + # Legacy fallback endpoint — receives image via backend relay content = await file.read() if len(content) == 0: @@ -34,7 +96,6 @@ async def upload_transaction( filename = original_filename or file.filename or "upload.jpg" - # Upload to MinIO try: object_key = minio.upload_image(content, filename) except Exception: @@ -44,7 +105,6 @@ async def upload_transaction( content={"success": False, "error": "Storage service unavailable"}, ) - # Insert metadata into PostgreSQL try: result = database.insert_transaction( image_object_key=object_key, diff --git a/app/services/minio.py b/app/services/minio.py index 000b0ad..dc8cf5a 100644 --- a/app/services/minio.py +++ b/app/services/minio.py @@ -39,3 +39,23 @@ def upload_image(file_bytes: bytes, original_filename: str) -> str: ContentType=content_type, ) return object_key + + +def generate_presigned_put_url(object_key: str) -> str: + client = _get_client() + url: str = client.generate_presigned_url( + "put_object", + Params={ + "Bucket": settings.minio.bucket, + "Key": object_key, + }, + ExpiresIn=300, + ) + # Replace internal endpoint with public endpoint + if settings.minio.public_endpoint: + from urllib.parse import urlparse, urlunparse + + internal = urlparse(settings.minio.endpoint) + public = urlparse(settings.minio.public_endpoint) + url = url.replace(f"{internal.scheme}://{internal.netloc}", f"{public.scheme}://{public.netloc}", 1) + return url diff --git a/config.example.yaml b/config.example.yaml index 0a1733b..9a30bd8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,6 +3,7 @@ minio: endpoint: "https://minio.example.com:9000" + public_endpoint: "https://minio-public.example.com" bucket: "snapledger" access_key: "YOUR_MINIO_ACCESS_KEY" secret_key: "YOUR_MINIO_SECRET_KEY"