Add S3 presigned URL direct upload (presign → PUT → confirm)
- Add presign endpoint: POST /api/v1/transactions/presign - Add confirm endpoint: POST /api/v1/transactions/confirm - Add generate_presigned_put_url() to MinIO service with public endpoint support - Add public_endpoint field to MinioConfig - Keep legacy /upload endpoint as fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class MinioConfig(BaseModel):
|
class MinioConfig(BaseModel):
|
||||||
endpoint: str
|
endpoint: str
|
||||||
|
public_endpoint: str = ""
|
||||||
bucket: str = "snapledger"
|
bucket: str = "snapledger"
|
||||||
access_key: str
|
access_key: str
|
||||||
secret_key: str
|
secret_key: str
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class UploadResponse(BaseModel):
|
class PresignRequest(BaseModel):
|
||||||
success: bool
|
original_filename: str
|
||||||
data: dict | None = None
|
|
||||||
error: str | None = None
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, File, Form, UploadFile
|
from fastapi import APIRouter, File, Form, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.models.transaction import ConfirmRequest, PresignRequest
|
||||||
from app.services import database, minio
|
from app.services import database, minio
|
||||||
|
|
||||||
router = APIRouter(tags=["upload"])
|
router = APIRouter(tags=["upload"])
|
||||||
@@ -10,6 +12,66 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
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")
|
@router.post("/transactions/upload")
|
||||||
async def upload_transaction(
|
async def upload_transaction(
|
||||||
@@ -17,7 +79,7 @@ async def upload_transaction(
|
|||||||
original_filename: str | None = Form(None),
|
original_filename: str | None = Form(None),
|
||||||
user_note: 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()
|
content = await file.read()
|
||||||
|
|
||||||
if len(content) == 0:
|
if len(content) == 0:
|
||||||
@@ -34,7 +96,6 @@ async def upload_transaction(
|
|||||||
|
|
||||||
filename = original_filename or file.filename or "upload.jpg"
|
filename = original_filename or file.filename or "upload.jpg"
|
||||||
|
|
||||||
# Upload to MinIO
|
|
||||||
try:
|
try:
|
||||||
object_key = minio.upload_image(content, filename)
|
object_key = minio.upload_image(content, filename)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -44,7 +105,6 @@ async def upload_transaction(
|
|||||||
content={"success": False, "error": "Storage service unavailable"},
|
content={"success": False, "error": "Storage service unavailable"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Insert metadata into PostgreSQL
|
|
||||||
try:
|
try:
|
||||||
result = database.insert_transaction(
|
result = database.insert_transaction(
|
||||||
image_object_key=object_key,
|
image_object_key=object_key,
|
||||||
|
|||||||
@@ -39,3 +39,23 @@ def upload_image(file_bytes: bytes, original_filename: str) -> str:
|
|||||||
ContentType=content_type,
|
ContentType=content_type,
|
||||||
)
|
)
|
||||||
return object_key
|
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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
endpoint: "https://minio.example.com:9000"
|
endpoint: "https://minio.example.com:9000"
|
||||||
|
public_endpoint: "https://minio-public.example.com"
|
||||||
bucket: "snapledger"
|
bucket: "snapledger"
|
||||||
access_key: "YOUR_MINIO_ACCESS_KEY"
|
access_key: "YOUR_MINIO_ACCESS_KEY"
|
||||||
secret_key: "YOUR_MINIO_SECRET_KEY"
|
secret_key: "YOUR_MINIO_SECRET_KEY"
|
||||||
|
|||||||
Reference in New Issue
Block a user