Skip to content

Commit 00982fc

Browse files
Merge pull request #31 from codewithme-py/feat/bug-fixes
feat: implement flexible user authentication, harden product moderation logic, and improve S3 presigned URL generation with host substitution.
2 parents 3d3024a + b1168a9 commit 00982fc

21 files changed

Lines changed: 356 additions & 36 deletions

.dockerignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ __pycache__
33
.git
44
.pytest_cache
55
.mypy_cache
6-
.ruff_cache
6+
.ruff_cache
7+
.qwen/

.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ S3_HOST=s3-fairdrop
1414
S3_PORT=9000
1515
MINIO_ROOT_USER=s3_fairdrop_user
1616
MINIO_ROOT_PASSWORD=s3_fairdrop_password
17-
MINIO_BUCKET_NAME=s3_fairdrop-media
17+
MINIO_BUCKET_NAME=fairdrop-media
1818
MINIO_URL=http://${S3_HOST}:${S3_PORT}
19+
S3_PUBLIC_URL=http://localhost:9000
1920
PRESIGNED_URL_EXPIRE_SECONDS=3600
2021
MIN_FILE_SIZE_BYTES=1
2122
MAX_FILE_SIZE_BYTES=5242880

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ wheels/
1717
.vscode/
1818

1919
# Local env
20-
.coverage
20+
.coverage
21+
.qwen/

app/core/auth_schemes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
22

33
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/v1/auth/token')
4+
oauth2_scheme_optional = OAuth2PasswordBearer(
5+
tokenUrl='/api/v1/auth/token', auto_error=False
6+
)
47
header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False)

app/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
2020
minio_root_password: str = Field(alias='MINIO_ROOT_PASSWORD')
2121
minio_bucket_name: str = Field(alias='MINIO_BUCKET_NAME')
2222
minio_url: str = Field(alias='MINIO_URL')
23+
s3_public_url: str = Field(alias='S3_PUBLIC_URL')
2324
pool_size: int = Field(alias='POOL_SIZE')
2425
max_overflow: int = Field(alias='MAX_OVERFLOW')
2526
jwt_algorithm: str = Field(default='HS256', alias='JWT_ALGORITHM')

app/core/s3.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,33 @@
44

55
import aioboto3 # type: ignore
66
import structlog
7+
from botocore.config import Config
78
from botocore.exceptions import ClientError
89

910
from app.core.config import settings
1011

1112
logger = structlog.get_logger(__name__)
1213

1314
session = aioboto3.Session()
15+
s3_config = Config(s3={'addressing_style': 'path'})
1416

1517

16-
@asynccontextmanager
17-
async def get_s3_client() -> AsyncIterator[Any]:
18+
async def get_s3_client_gen() -> AsyncIterator[Any]:
1819
async with session.client(
1920
's3',
2021
endpoint_url=settings.minio_url,
2122
region_name='us-east-1',
2223
aws_access_key_id=settings.minio_root_user,
2324
aws_secret_access_key=settings.minio_root_password,
2425
verify=False,
26+
config=s3_config,
2527
) as client:
2628
yield client
2729

2830

31+
get_s3_client = asynccontextmanager(get_s3_client_gen)
32+
33+
2934
async def init_s3_bucket() -> None:
3035
async with session.client(
3136
's3',
@@ -34,13 +39,14 @@ async def init_s3_bucket() -> None:
3439
aws_access_key_id=settings.minio_root_user,
3540
aws_secret_access_key=settings.minio_root_password,
3641
verify=False,
42+
config=s3_config,
3743
) as client:
3844
try:
3945
await client.head_bucket(Bucket=settings.minio_bucket_name)
4046
logger.info(f'Bucket {settings.minio_bucket_name} already exists')
4147
except ClientError as e:
4248
if e.response['Error']['Code'] == '404':
43-
await client.make_bucket(Bucket=settings.minio_bucket_name)
49+
await client.create_bucket(Bucket=settings.minio_bucket_name)
4450
logger.info(f'Bucket {settings.minio_bucket_name} created')
4551
else:
4652
logger.error(e)

app/services/inventory/routes.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from typing import Annotated
1+
from typing import Annotated, Any
22
from uuid import UUID
33

44
from fastapi import APIRouter, Depends, Header, Query, Request, status
55

66
from app.core.config import settings
77
from app.core.database import SessionDep
8+
from app.core.s3 import get_s3_client_gen
89
from app.core.security import RoleChecker, UserRole
910
from app.services.inventory.models import ProductStatus
1011
from app.services.inventory.schemas import (
@@ -15,6 +16,7 @@
1516
ReservationResponse,
1617
)
1718
from app.services.inventory.service import InventoryAdminService, InventoryService
19+
from app.services.media.service import generate_presigned_get_url
1820
from app.services.user.models import User
1921
from app.shared.decorators import idempotent
2022
from app.shared.deps import get_current_user
@@ -50,10 +52,24 @@
5052
)
5153

5254

55+
async def _enrich_product_images(product: Any, s3_client: Any) -> ProductRead:
56+
"""Helper to generate presigned URLs for product images."""
57+
read_obj = ProductRead.model_validate(product)
58+
image_urls = []
59+
if hasattr(product, 'images'):
60+
for img in product.images:
61+
if img.status == 'active':
62+
url = await generate_presigned_get_url(s3_client, img.file_path)
63+
image_urls.append(url)
64+
read_obj.image_urls = image_urls
65+
return read_obj
66+
67+
5368
@router_v1.get('/', response_model=list[ProductRead])
5469
async def get_active_products(
5570
session: SessionDep,
5671
service: Annotated[InventoryService, Depends(get_inventory_service)],
72+
s3_client: Any = Depends(get_s3_client_gen),
5773
skip: int = 0,
5874
limit: int = 50,
5975
) -> list[ProductRead]:
@@ -63,7 +79,7 @@ async def get_active_products(
6379
limit=limit,
6480
session=session,
6581
)
66-
return [ProductRead.model_validate(p) for p in products]
82+
return [await _enrich_product_images(p, s3_client) for p in products]
6783

6884

6985
@router_v1.post('/', response_model=ProductRead, status_code=status.HTTP_201_CREATED)
@@ -134,12 +150,13 @@ async def get_product(
134150
product_id: UUID,
135151
session: SessionDep,
136152
service: Annotated[InventoryService, Depends(get_inventory_service)],
153+
s3_client: Any = Depends(get_s3_client_gen),
137154
) -> ProductRead:
138155
product = await service.get_product(
139156
session=session,
140157
product_id=product_id,
141158
)
142-
return ProductRead.model_validate(product)
159+
return await _enrich_product_images(product, s3_client)
143160

144161

145162
@router_v1.post('/reserve', response_model=ReservationResponse)

app/services/inventory/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ProductRead(BaseModel):
3737
status: ProductStatus
3838
created_at: datetime
3939
updated_at: datetime
40+
image_urls: list[str] = []
4041

4142

4243
class ReservationCreate(BaseModel):

app/services/inventory/service.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy import select
66
from sqlalchemy.exc import IntegrityError
77
from sqlalchemy.ext.asyncio import AsyncSession
8+
from sqlalchemy.orm import joinedload
89

910
from app.core.audit_log.service import audit_log_service
1011
from app.core.config import settings
@@ -53,8 +54,10 @@ async def _get_product(
5354
query = select(Product).where(Product.id == product_id)
5455
if for_update:
5556
query = query.with_for_update()
57+
else:
58+
query = query.options(joinedload(Product.images))
5659
result = await session.execute(query)
57-
product = result.scalar_one_or_none()
60+
product = result.unique().scalar_one_or_none()
5861
if not product:
5962
raise NotFoundError
6063
if current_user:
@@ -142,9 +145,17 @@ async def update_product(
142145
product = await InventoryService._get_product(
143146
session, product_id, for_update=True, current_user=current_user
144147
)
148+
if product.status in (
149+
ProductStatus.PENDING_MODERATION,
150+
ProductStatus.MODERATION_IN_PROGRESS,
151+
):
152+
raise ConflictError('Cannot edit product while it is under moderation')
145153
old_snapshot = ProductRead.model_validate(product)
146154
for field, value in product_data.model_dump(exclude_unset=True).items():
147155
setattr(product, field, value)
156+
if product.status == ProductStatus.ACTIVE:
157+
product.status = ProductStatus.PENDING_MODERATION
158+
product.moderator_id = None
148159
await InventoryService._log_product_change(
149160
session=session,
150161
user=current_user,
@@ -187,11 +198,11 @@ async def get_products(
187198
skip: int = 0,
188199
limit: int = 50,
189200
) -> list[Product]:
190-
query = select(Product)
201+
query = select(Product).options(joinedload(Product.images))
191202
if status:
192203
query = query.where(Product.status == status)
193204
result = await session.execute(query.offset(skip).limit(limit))
194-
return list(result.scalars().all())
205+
return list(result.scalars().unique().all())
195206

196207
@staticmethod
197208
async def reserve_items(

app/services/media/routes.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from app.core.audit_log.service import audit_log_service
1010
from app.core.database import get_session
11-
from app.core.s3 import get_s3_client
11+
from app.core.s3 import get_s3_client_gen
1212
from app.services.media.schemas import (
1313
ImageUploadRequest,
1414
ImageUploadResponse,
@@ -21,7 +21,7 @@
2121
handle_minio_webhook,
2222
)
2323
from app.services.user.models import User, UserRole
24-
from app.shared.deps import get_current_user
24+
from app.shared.deps import get_current_user, get_current_user_flexible
2525

2626
router_v1 = APIRouter(prefix='/media', tags=['Media'])
2727

@@ -31,7 +31,7 @@ async def create_upload_url(
3131
product_id: UUID,
3232
req: ImageUploadRequest,
3333
session: AsyncSession = Depends(get_session),
34-
s3_client: Any = Depends(get_s3_client),
34+
s3_client: Any = Depends(get_s3_client_gen),
3535
current_user: User = Depends(get_current_user),
3636
) -> ImageUploadResponse:
3737
return await generate_upload_url(session, s3_client, product_id, req)
@@ -53,8 +53,8 @@ async def view_private_file(
5353
target_id: UUID,
5454
doc_key: str | None = None,
5555
session: AsyncSession = Depends(get_session),
56-
s3_client: Any = Depends(get_s3_client),
57-
current_user: User = Depends(get_current_user),
56+
s3_client: Any = Depends(get_s3_client_gen),
57+
current_user: User = Depends(get_current_user_flexible),
5858
) -> RedirectResponse:
5959
if current_user.role not in (UserRole.ADMIN, UserRole.MODERATOR):
6060
raise HTTPException(

0 commit comments

Comments
 (0)