Skip to content

Commit 1b3c258

Browse files
committed
moved file uploads to aws s3
1 parent 08a65a3 commit 1b3c258

8 files changed

Lines changed: 201 additions & 22 deletions

File tree

check_s3.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Quick script to verify AWS S3 credentials and permissions.
3+
4+
Run with: uv run check_s3.py
5+
6+
This checks that your .env credentials can upload to and delete from your S3 bucket
7+
without needing to go through the full application flow.
8+
"""
9+
10+
from io import BytesIO
11+
12+
from botocore.exceptions import BotoCoreError, ClientError
13+
14+
from config import settings
15+
from image_utils import _get_s3_client
16+
17+
18+
def check_s3_connection():
19+
s3 = _get_s3_client()
20+
21+
print(f"Bucket: {settings.s3_bucket_name}")
22+
print(f"Region: {settings.s3_region}")
23+
print()
24+
25+
test_key = "profile_pics/test.txt"
26+
27+
# Test upload
28+
try:
29+
s3.upload_fileobj(
30+
BytesIO(b"test"),
31+
settings.s3_bucket_name,
32+
test_key,
33+
ExtraArgs={"ContentType": "text/plain"},
34+
)
35+
print("Upload: SUCCESS")
36+
except (BotoCoreError, ClientError) as e:
37+
print(f"Upload: FAILED - {e}")
38+
return
39+
40+
# Test delete
41+
try:
42+
s3.delete_object(Bucket=settings.s3_bucket_name, Key=test_key)
43+
print("Delete: SUCCESS")
44+
except (BotoCoreError, ClientError) as e:
45+
print(f"Delete: FAILED - {e}")
46+
return
47+
48+
print()
49+
print("All tests passed! Your S3 configuration is working.")
50+
51+
52+
if __name__ == "__main__":
53+
check_s3_connection()

config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ class Settings(BaseSettings):
1414
algorithm: str = "HS256"
1515
access_token_expire_minutes: int = 30
1616

17+
# S3 Configuration
18+
s3_bucket_name: str
19+
s3_region: str = "eu-north-1"
20+
s3_access_key_id: SecretStr | None = None
21+
s3_secret_access_key: SecretStr | None = None
22+
s3_endpoint_url: str | None = None
23+
1724
max_upload_size_bytes: int = 5 * 1024 * 1024
1825

1926
posts_per_page: int = 10

image_utils.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
1-
from fileinput import filename
2-
from pickletools import optimize
3-
from termios import IMAXBEL
41
import uuid
52
from io import BytesIO
6-
from pathlib import Path
73
from PIL import Image, ImageOps
4+
import boto3
5+
from starlette.concurrency import run_in_threadpool
6+
from config import settings
87

9-
PROFILE_PICS_DIR = Path("media/profile_pics")
8+
def _get_s3_client():
9+
return boto3.client(
10+
"s3",
11+
region_name=settings.s3_region,
12+
aws_access_key_id=(
13+
settings.s3_access_key_id.get_secret_value()
14+
if settings.s3_access_key_id
15+
else None
16+
),
17+
aws_secret_access_key=(
18+
settings.s3_secret_access_key.get_secret_value()
19+
if settings.s3_secret_access_key
20+
else None
21+
),
22+
endpoint_url=settings.s3_endpoint_url,
23+
)
1024

11-
def process_profile_image(content: bytes) -> str:
25+
def process_profile_image(content: bytes) -> tuple[bytes, str]:
1226
with Image.open(BytesIO(content)) as original:
1327
img = ImageOps.exif_transpose(original)
1428
img = ImageOps.fit(img, (300, 300), method=Image.Resampling.LANCZOS)
@@ -17,17 +31,36 @@ def process_profile_image(content: bytes) -> str:
1731
img = img.convert("RGB")
1832

1933
filename = f"{uuid.uuid4().hex}.jpg"
20-
filepath = PROFILE_PICS_DIR / filename
21-
PROFILE_PICS_DIR.mkdir(parents=True, exist_ok=True)
22-
img.save(filepath, "JPEG", quality=85, optimize=True)
34+
35+
output = BytesIO()
36+
img.save(output, "JPEG", quality=85, optimize=True)
37+
output.seek(0)
2338

24-
return filename
39+
return output.read(), filename
2540

2641

27-
def delete_profile_image(filename: str | None) -> None:
42+
def _upload_to_s3(file_bytes: bytes, key: str) -> None:
43+
s3 = _get_s3_client()
44+
s3.upload_fileobj(
45+
BytesIO(file_bytes),
46+
settings.s3_bucket_name,
47+
key,
48+
ExtraArgs={"ContentType": "image/jpeg"},
49+
)
50+
51+
52+
def _delete_from_s3(key: str) -> None:
53+
s3 = _get_s3_client()
54+
s3.delete_object(Bucket=settings.s3_bucket_name, Key=key)
55+
56+
57+
async def upload_profile_image(file_bytes: bytes, filename: str) -> None:
58+
key = f"profile_pics/{filename}"
59+
await run_in_threadpool(_upload_to_s3, file_bytes, key)
60+
61+
62+
async def delete_profile_image(filename: str | None) -> None:
2863
if filename is None:
2964
return
30-
31-
filepath = PROFILE_PICS_DIR / filename
32-
if filepath.exists():
33-
filepath.unlink()
65+
key = f"profile_pics/{filename}"
66+
await run_in_threadpool(_delete_from_s3, key)

main.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ async def lifespan(_app: FastAPI):
2929
app = FastAPI(lifespan=lifespan)
3030

3131
app.mount("/static", StaticFiles(directory="static"), name="static")
32-
app.mount("/media", StaticFiles(directory="media"), name="media")
3332

3433
templates = Jinja2Templates(directory="templates")
3534

models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from sqlalchemy.orm import Mapped, mapped_column, relationship
77

88
from database import Base
9+
from config import settings
10+
911

1012

1113
class User(Base):
@@ -28,7 +30,7 @@ class User(Base):
2830
@property
2931
def image_path(self) -> str:
3032
if self.image_file:
31-
return f"/media/profile_pics/{self.image_file}"
33+
return f"https://{settings.s3_bucket_name}.s3.{settings.s3_region}.amazonaws.com/profile_pics/{self.image_file}"
3234
return "/static/profile_pics/default.jpg"
3335

3436

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"aiosmtplib>=5.1.0",
99
"aiosqlite>=0.22.1",
1010
"alembic>=1.18.4",
11+
"boto3>=1.42.90",
1112
"fastapi[standard]>=0.128.1",
1213
"greenlet>=3.3.1",
1314
"pillow>=12.1.1",

routers/users.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
from database import get_db
1616
from schemas import PostResponse, UserCreate, UserPrivate, UserPublic, UserUpdate, Token, PaginatedPostsResponse, ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest
1717
from auth import create_access_token, hash_password, verify_password, CurrentUser, generate_reset_token, hash_reset_token
18-
from image_utils import delete_profile_image, process_profile_image
18+
from image_utils import delete_profile_image, process_profile_image, upload_profile_image
1919
from email_utils import send_password_reset_email
20+
from botocore.exceptions import ClientError
21+
2022

2123
router = APIRouter()
2224

@@ -342,7 +344,7 @@ async def delete_user(
342344
await db.commit()
343345

344346
if old_filename:
345-
delete_profile_image(old_filename)
347+
await delete_profile_image(old_filename)
346348

347349

348350
@router.patch("/{user_id}/picture", response_model=UserPrivate)
@@ -367,21 +369,31 @@ async def upload_profile_picture(
367369
)
368370

369371
try:
370-
new_filename = await run_in_threadpool(process_profile_image, content)
372+
processed_bytes,new_filename = await run_in_threadpool(process_profile_image, content)
371373
except UnidentifiedImageError as err:
372374
raise HTTPException(
373375
status_code=status.HTTP_400_BAD_REQUEST,
374376
detail="Invalid image file. Please upload a valid image (JPEG, PNG, GIF, WebP).",
375377
) from err
376378

379+
# Upload to S3 (also runs in threadpool via async wrapper)
380+
try:
381+
await upload_profile_image(processed_bytes, new_filename)
382+
except ClientError as err:
383+
raise HTTPException(
384+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
385+
detail="Failed to upload image. Please try again.",
386+
) from err
387+
388+
377389
old_filename = current_user.image_file
378390

379391
current_user.image_file = new_filename
380392
await db.commit()
381393
await db.refresh(current_user)
382394

383395
if old_filename:
384-
delete_profile_image(old_filename)
396+
await delete_profile_image(old_filename)
385397

386398
return current_user
387399

@@ -409,6 +421,6 @@ async def delete_user_picture(
409421
await db.commit()
410422
await db.refresh(current_user)
411423

412-
delete_profile_image(old_filename)
424+
await delete_profile_image(old_filename)
413425

414426
return current_user

uv.lock

Lines changed: 72 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)