"""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)