first commit

This commit is contained in:
2026-03-07 21:31:38 +03:00
commit a879ba7b50
68 changed files with 2487 additions and 0 deletions

0
app/media/__init__.py Normal file
View File

16
app/media/models.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True)
file_url: Mapped[str] = mapped_column(String(1024), nullable=False)
file_type: Mapped[str] = mapped_column(String(64), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
message = relationship("Message", back_populates="attachments")

26
app/media/repository.py Normal file
View File

@@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.media.models import Attachment
async def create_attachment(
db: AsyncSession,
*,
message_id: int,
file_url: str,
file_type: str,
file_size: int,
) -> Attachment:
attachment = Attachment(
message_id=message_id,
file_url=file_url,
file_type=file_type,
file_size=file_size,
)
db.add(attachment)
await db.flush()
return attachment
async def get_attachment_by_id(db: AsyncSession, attachment_id: int) -> Attachment | None:
return await db.get(Attachment, attachment_id)

27
app/media/router.py Normal file
View File

@@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user
from app.database.session import get_db
from app.media.schemas import AttachmentCreateRequest, AttachmentRead, UploadUrlRequest, UploadUrlResponse
from app.media.service import generate_upload_url, store_attachment_metadata
from app.users.models import User
router = APIRouter(prefix="/media", tags=["media"])
@router.post("/upload-url", response_model=UploadUrlResponse)
async def create_upload_url(
payload: UploadUrlRequest,
_current_user: User = Depends(get_current_user),
) -> UploadUrlResponse:
return await generate_upload_url(payload)
@router.post("/attachments", response_model=AttachmentRead)
async def create_attachment_metadata(
payload: AttachmentCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> AttachmentRead:
return await store_attachment_metadata(db, user_id=current_user.id, payload=payload)

32
app/media/schemas.py Normal file
View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, ConfigDict, Field
class UploadUrlRequest(BaseModel):
file_name: str = Field(min_length=1, max_length=255)
file_type: str = Field(min_length=1, max_length=64)
file_size: int = Field(gt=0)
class UploadUrlResponse(BaseModel):
upload_url: str
file_url: str
object_key: str
expires_in: int
required_headers: dict[str, str]
class AttachmentCreateRequest(BaseModel):
message_id: int
file_url: str = Field(min_length=1, max_length=1024)
file_type: str = Field(min_length=1, max_length=64)
file_size: int = Field(gt=0)
class AttachmentRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
message_id: int
file_url: str
file_type: str
file_size: int

127
app/media/service.py Normal file
View File

@@ -0,0 +1,127 @@
import re
from urllib.parse import quote
from uuid import uuid4
import boto3
from botocore.client import Config
from botocore.exceptions import BotoCoreError, ClientError
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config.settings import settings
from app.media import repository
from app.media.schemas import AttachmentCreateRequest, AttachmentRead, UploadUrlRequest, UploadUrlResponse
from app.messages.repository import get_message_by_id
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/webp",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
"application/zip",
"text/plain",
}
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+")
def _sanitize_filename(file_name: str) -> str:
sanitized = _SAFE_NAME_RE.sub("_", file_name).strip("._")
if not sanitized:
sanitized = "file.bin"
return sanitized[:120]
def _build_file_url(bucket: str, object_key: str) -> str:
base = settings.s3_endpoint_url.rstrip("/")
encoded_key = quote(object_key)
return f"{base}/{bucket}/{encoded_key}"
def _allowed_file_url_prefix() -> str:
return f"{settings.s3_endpoint_url.rstrip('/')}/{settings.s3_bucket_name}/"
def _validate_media(file_type: str, file_size: int) -> None:
if file_type not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unsupported file type")
if file_size > settings.max_upload_size_bytes:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="File size exceeds limit")
def _get_s3_client():
return boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
async def generate_upload_url(payload: UploadUrlRequest) -> UploadUrlResponse:
_validate_media(payload.file_type, payload.file_size)
file_name = _sanitize_filename(payload.file_name)
object_key = f"uploads/{uuid4()}-{file_name}"
bucket = settings.s3_bucket_name
try:
s3_client = _get_s3_client()
upload_url = s3_client.generate_presigned_url(
"put_object",
Params={
"Bucket": bucket,
"Key": object_key,
"ContentType": payload.file_type,
},
ExpiresIn=settings.s3_presign_expire_seconds,
HttpMethod="PUT",
)
except (BotoCoreError, ClientError) as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Storage service unavailable") from exc
return UploadUrlResponse(
upload_url=upload_url,
file_url=_build_file_url(bucket, object_key),
object_key=object_key,
expires_in=settings.s3_presign_expire_seconds,
required_headers={"Content-Type": payload.file_type},
)
async def store_attachment_metadata(
db: AsyncSession,
*,
user_id: int,
payload: AttachmentCreateRequest,
) -> AttachmentRead:
_validate_media(payload.file_type, payload.file_size)
if not payload.file_url.startswith(_allowed_file_url_prefix()):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid file URL")
message = await get_message_by_id(db, payload.message_id)
if not message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
if message.sender_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the message sender can attach files",
)
attachment = await repository.create_attachment(
db,
message_id=payload.message_id,
file_url=payload.file_url,
file_type=payload.file_type,
file_size=payload.file_size,
)
await db.commit()
await db.refresh(attachment)
return AttachmentRead.model_validate(attachment)