Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@ GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
# Frontend
FRONTEND_ORIGIN=http://localhost:3000

# S3 (Mock implementation - update when S3 is ready)
AWS_S3_BUCKET=waffice-uploads
AWS_S3_REGION=ap-northeast-2
# OCI Object Storage
OCI_NAMESPACE=
OCI_BUCKET=
OCI_REGION=ap-chuncheon-1
# Use instance_principal in OKE. Use config_file for local development.
OCI_OBJECT_STORAGE_AUTH=config_file
OCI_CONFIG_FILE=
OCI_CONFIG_PROFILE=DEFAULT
OCI_PUBLIC_BASE_URL=

# AWS Secrets Manager (for dev/prod environments)
# AWS_SECRET_NAME=waffice-db-credentials
Expand Down
21 changes: 21 additions & 0 deletions app/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,24 @@ class RequestAlreadyProcessedError(AppError):

def __init__(self, message: str = "Request has already been processed"):
super().__init__("REQUEST_ALREADY_PROCESSED", message, 400)


class InvalidProfileImageError(AppError):
"""Invalid profile image content type"""

def __init__(self, message: str = "Only jpeg, png, or webp images are allowed"):
super().__init__("INVALID_PROFILE_IMAGE", message, 400)


class ProfileImageTooLargeError(AppError):
"""Profile image is too large"""

def __init__(self, message: str = "Profile image must be 5MB or smaller"):
super().__init__("PROFILE_IMAGE_TOO_LARGE", message, 413)


class ObjectStorageError(AppError):
"""Object storage operation failed"""

def __init__(self, message: str = "Object storage operation failed"):
super().__init__("OBJECT_STORAGE_ERROR", message, 502)
6 changes: 4 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ async def app_error_handler(request: Request, exc: AppError):
# ==============================
# ROUTERS
# ==============================
from app.routes import auth, projects, requests, upload, users
from app.routes import auth, profile_image, projects, requests, users

app.include_router(auth.router, prefix="/auth", tags=["Auth"])
app.include_router(users.router, prefix="/users", tags=["Users"])
app.include_router(projects.router, prefix="/projects", tags=["Projects"])
app.include_router(requests.router, prefix="/requests", tags=["Requests"])
app.include_router(upload.router, prefix="/upload", tags=["Upload"])
app.include_router(
profile_image.router, prefix="/profile-image", tags=["Profile Image"]
)

# Dev-only auth endpoints (only included in local/dev environments)
if ENV in ("local", "dev"):
Expand Down
4 changes: 2 additions & 2 deletions app/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from app.routes import auth, projects, upload, users
from app.routes import auth, profile_image, projects, users

__all__ = ["auth", "users", "projects", "upload"]
__all__ = ["auth", "users", "projects", "profile_image"]
36 changes: 36 additions & 0 deletions app/routes/profile_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, File, UploadFile
from sqlalchemy.orm import Session

from app.config.database import get_db
from app.deps.auth import require_associate
from app.exceptions import InvalidProfileImageError, ProfileImageTooLargeError
from app.models import User
from app.schemas import Response, UserDetail
from app.services import OCIObjectStorageService, UserService

router = APIRouter()

MAX_PROFILE_IMAGE_SIZE = 5 * 1024 * 1024
PROFILE_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}


@router.post("/upload", response_model=Response[UserDetail])
async def upload_profile_image(
file: UploadFile = File(...),
current_user: User = Depends(require_associate),
db: Session = Depends(get_db),
):
if file.content_type not in PROFILE_IMAGE_TYPES:
raise InvalidProfileImageError()

body = await file.read()
if len(body) > MAX_PROFILE_IMAGE_SIZE:
raise ProfileImageTooLargeError()

storage = OCIObjectStorageService()
old_url = current_user.avatar_url
avatar_url = storage.upload_profile_image(current_user.id, file, body)
updated_user = UserService.update(db, current_user, avatar_url=avatar_url)
storage.delete_profile_image(current_user.id, old_url)

return Response(ok=True, data=updated_user)
28 changes: 0 additions & 28 deletions app/routes/upload.py

This file was deleted.

3 changes: 0 additions & 3 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
RequestScope,
RequestStatusFilter,
)
from app.schemas.upload import PresignedUrlRequest, PresignedUrlResponse
from app.schemas.user import (
ApproveRequest,
ProfileUpdateRequest,
Expand Down Expand Up @@ -78,8 +77,6 @@
"ProjectBrief",
"ProjectDetail",
"AuditLogDetail",
"PresignedUrlRequest",
"PresignedUrlResponse",
"ActivityCreateRequest",
"ActivityUpdateRequest",
"ActivityDetail",
Expand Down
11 changes: 0 additions & 11 deletions app/schemas/upload.py

This file was deleted.

4 changes: 2 additions & 2 deletions app/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
from app.services.activity import ActivityService
from app.services.audit_log import AuditLogService
from app.services.member import CannotRemoveSelfError, LastLeaderError, MemberService
from app.services.object_storage import OCIObjectStorageService
from app.services.project import ProjectService
from app.services.request import RequestService
from app.services.s3 import S3Service
from app.services.user import UserService

__all__ = [
"UserService",
"AuditLogService",
"ProjectService",
"MemberService",
"S3Service",
"OCIObjectStorageService",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

S3Service, OCIObjectStorageService 이거 추상화 한번만 해주시겠어요 ?

혹시나 인프라 수정할 경우에 갈아끼기 쉽게 하는게 좋을것같아요

BucketService
ㄴ S3Service
ㄴ OCIObjectStorageService

"ActivityService",
"RequestService",
"LastLeaderError",
Expand Down
98 changes: 98 additions & 0 deletions app/services/object_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import os
from urllib.parse import quote, unquote, urlparse
from uuid import uuid4

from fastapi import UploadFile

from app.exceptions import ObjectStorageError

PROFILE_IMAGE_EXTENSIONS = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}


class OCIObjectStorageService:
def __init__(self):
try:
import oci
except ImportError:
raise ObjectStorageError("oci package is not installed")

self.oci = oci
self.namespace = os.getenv("OCI_NAMESPACE", "")
self.bucket = os.getenv("OCI_BUCKET", "")
self.region = os.getenv("OCI_REGION", "")
if not all((self.namespace, self.bucket, self.region)):
raise ObjectStorageError("OCI object storage is not configured")

auth_mode = os.getenv("OCI_OBJECT_STORAGE_AUTH", "instance_principal")
try:
if auth_mode == "config_file":
config_file = os.getenv("OCI_CONFIG_FILE", "~/.oci/config")
profile = os.getenv("OCI_CONFIG_PROFILE", "DEFAULT")
config = (
oci.config.from_file(config_file, profile)
if config_file
else oci.config.from_file(profile_name=profile)
)
self.client = oci.object_storage.ObjectStorageClient(config)
elif auth_mode == "resource_principal":
signer = oci.auth.signers.get_resource_principals_signer()
self.client = oci.object_storage.ObjectStorageClient(
{"region": self.region}, signer=signer
)
elif auth_mode == "instance_principal":
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
self.client = oci.object_storage.ObjectStorageClient(
{"region": self.region}, signer=signer
)
else:
raise ValueError(f"Unsupported OCI authentication mode: {auth_mode}")
except Exception as exc:
raise ObjectStorageError("OCI object storage is not configured") from exc

def upload_profile_image(self, user_id: int, file: UploadFile, body: bytes) -> str:
object_name = (
f"profiles/{user_id}/{uuid4()}{PROFILE_IMAGE_EXTENSIONS[file.content_type]}"
)
try:
self.client.put_object(
self.namespace,
self.bucket,
object_name,
body,
content_type=file.content_type,
)
except Exception as exc:
raise ObjectStorageError("Failed to upload profile image") from exc
return self.public_url(object_name)

def delete_profile_image(self, user_id: int, url: str | None) -> None:
object_name = self.object_name_from_url(url)
if not object_name or not object_name.startswith(f"profiles/{user_id}/"):
return
try:
self.client.delete_object(self.namespace, self.bucket, object_name)
except Exception:
pass

def public_url(self, object_name: str) -> str:
base_url = (
os.getenv("OCI_PUBLIC_BASE_URL")
or f"https://objectstorage.{self.region}.oraclecloud.com/n/{self.namespace}/b/{self.bucket}/o"
).rstrip("/")
return f"{base_url}/{quote(object_name, safe='')}"

def object_name_from_url(self, url: str | None) -> str | None:
if not url:
return None
path = urlparse(url).path
marker = f"/n/{self.namespace}/b/{self.bucket}/o/"
if marker in path:
return unquote(path.split(marker, 1)[1])
base_url = os.getenv("OCI_PUBLIC_BASE_URL", "").rstrip("/")
if base_url and url.startswith(f"{base_url}/"):
return unquote(url[len(base_url) + 1 :])
return None
30 changes: 0 additions & 30 deletions app/services/s3.py

This file was deleted.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies = [
"fastapi[standard]>=0.117.1",
"google-auth>=2.48.0",
"itsdangerous>=2.2.0",
"oci>=2.164.0",
"openpyxl>=3.1.0",
"pydantic>=2.11.9",
"pymysql>=1.1.2",
Expand Down
Loading
Loading