111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
"""S3 storage service — private objects; auth download / short-lived open URLs."""
|
|
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException,UploadFile
|
|
from fastapi.responses import Response
|
|
|
|
from s3.plugins import S3,S3ServiceError,assert_pdf
|
|
from s3.serializers import serialize_delete,serialize_health,serialize_open,serialize_upload
|
|
|
|
|
|
class S3Storage:
|
|
"""No DB session — pure object storage against the configured private bucket."""
|
|
|
|
def __init__(self):
|
|
self.s3=S3()
|
|
|
|
def _map(self,exc:S3ServiceError):
|
|
raise HTTPException(status_code=exc.status_code,detail=exc.message)
|
|
|
|
async def health_check(self):
|
|
try:
|
|
return serialize_health(self.s3.head_bucket())
|
|
except S3ServiceError as e:
|
|
self._map(e)
|
|
|
|
async def upload_for_record(self,file:UploadFile,source,record_id,owner_id):
|
|
"""PDF gate → PutObject under {source}/{record_id}/{owner_id}/{name}.pdf."""
|
|
if file is None:
|
|
raise HTTPException(status_code=422,detail="file is required")
|
|
filename=(file.filename or "").strip() or "resume.pdf"
|
|
try:
|
|
assert_pdf(filename,file.content_type)
|
|
except S3ServiceError as e:
|
|
self._map(e)
|
|
body=await file.read()
|
|
if not body:
|
|
raise HTTPException(status_code=422,detail="file is empty")
|
|
try:
|
|
result=self.s3.upload_for_record(
|
|
body,
|
|
filename,
|
|
source=source,
|
|
record_id=record_id,
|
|
owner_id=owner_id,
|
|
content_type=file.content_type,
|
|
)
|
|
return serialize_upload(result)
|
|
except S3ServiceError as e:
|
|
self._map(e)
|
|
|
|
async def upload_bytes_for_record(self,body,filename,source,record_id,owner_id,content_type=None):
|
|
try:
|
|
assert_pdf(filename or "resume.pdf",content_type)
|
|
result=self.s3.upload_for_record(
|
|
body,
|
|
filename or "resume.pdf",
|
|
source=source,
|
|
record_id=record_id,
|
|
owner_id=owner_id,
|
|
content_type=content_type,
|
|
)
|
|
return serialize_upload(result)
|
|
except S3ServiceError as e:
|
|
self._map(e)
|
|
|
|
async def delete_file(self,key):
|
|
if not key or not str(key).strip():
|
|
raise HTTPException(status_code=422,detail="key is required")
|
|
try:
|
|
return serialize_delete(self.s3.delete_object(str(key).strip()))
|
|
except S3ServiceError as e:
|
|
self._map(e)
|
|
|
|
async def object_url(self,key):
|
|
"""Stable DB identity address (private — not for anonymous open)."""
|
|
if not key or not str(key).strip():
|
|
raise HTTPException(status_code=422,detail="key is required")
|
|
try:
|
|
raw=str(key).strip()
|
|
object_key=self.s3.key_from_url(raw)
|
|
url=self.s3.object_url(object_key)
|
|
return {"key":object_key,"url":url,"access":"private"}
|
|
except S3ServiceError as e:
|
|
self._map(e)
|
|
|
|
async def open_url(self,key,expires_in=None):
|
|
"""Short-lived presigned GET — use this when a recruiter needs to open the CV."""
|
|
if not key or not str(key).strip():
|
|
raise HTTPException(status_code=422,detail="key is required")
|
|
try:
|
|
return serialize_open(self.s3.presigned_get_url(str(key).strip(),expires_in=expires_in))
|
|
except S3ServiceError as e:
|
|
self._map(e)
|
|
|
|
async def download_file(self,key):
|
|
"""Authenticated stream of a private PDF (no public bucket needed)."""
|
|
if not key or not str(key).strip():
|
|
raise HTTPException(status_code=422,detail="key is required")
|
|
try:
|
|
object_key=self.s3.key_from_url(str(key).strip())
|
|
body=self.s3.download_bytes(object_key)
|
|
name=Path(object_key).name or "resume.pdf"
|
|
return Response(
|
|
content=body,
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition":f'inline; filename="{name}"'},
|
|
)
|
|
except S3ServiceError as e:
|
|
self._map(e)
|