Skip to content
Open
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
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ extend-ignore =
VNE003 WPS115

# # weird
PIE803 C101 FNE007 FNE008 N812 ANN101 ANN102 WPS110 WPS111 WPS114 WPS338 WPS407 WPS414 WPS440 VNE001 VNE002 CM001
PIE803 C101 FNE007 FNE008 N812 ANN101 ANN102 PT004 WPS110 WPS111 WPS114 WPS338 WPS407 WPS414 WPS440 VNE001 VNE002 CM001
# too many
WPS200 WPS201 WPS202 WPS203 WPS204 WPS210 WPS211 WPS212 WPS213 WPS214 WPS217 WPS218 WPS221 WPS224 WPS230 WPS231 WPS234 WPS235 WPS238
# "vague" imports
Expand Down
26 changes: 26 additions & 0 deletions app/common/filetype_ext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from typing import Final

import filetype # type: ignore[import-untyped]
from filetype.types import image # type: ignore[import-untyped]

FILE_HEADER_SIZE: Final[int] = 8192

SUPPORTED_IMAGE_FORMATS: list[filetype.Type] = [
image.Avif(),
image.Bmp(),
image.Gif(),
image.Ico(),
image.Jpeg(),
image.Jpx(),
image.Png(),
image.Tiff(),
image.Webp(),
]


def match_filetype(obj: bytes, matchers: list[filetype.Type]) -> filetype.Type | None:
return filetype.match(obj, matchers)


def match_image_filetype(obj: bytes) -> filetype.Type | None:
return match_filetype(obj, SUPPORTED_IMAGE_FORMATS)
22 changes: 22 additions & 0 deletions app/storage_v2/dependencies/storage_token_dep.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.common.dependencies.authorization_dep import AuthorizationData
from app.common.fastapi_ext import Responses, with_responses
from app.common.schemas.storage_sch import StorageTokenPayloadSchema
from app.storage_v2.models.access_groups_db import AccessGroup


class StorageTokenResponses(Responses):
Expand Down Expand Up @@ -36,3 +37,24 @@ def validate_and_deserialize_storage_token(
StorageTokenPayload = Annotated[
StorageTokenPayloadSchema, Depends(validate_and_deserialize_storage_token)
]


@with_responses(StorageTokenResponses)
async def validate_upload_permissions(
storage_token_payload: StorageTokenPayload,
) -> StorageTokenPayloadSchema:
if not storage_token_payload.can_upload_files:
raise StorageTokenResponses.INVALID_STORAGE_TOKEN

access_group = await AccessGroup.find_first_by_id(
storage_token_payload.access_group_id
)
if access_group is None:
raise StorageTokenResponses.INVALID_STORAGE_TOKEN

return storage_token_payload


UploadAllowedStorageTokenPayload = Annotated[
StorageTokenPayloadSchema, Depends(validate_upload_permissions)
]
19 changes: 15 additions & 4 deletions app/storage_v2/dependencies/uploads_dep.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
from typing import Annotated

from fastapi import Depends, UploadFile
from filetype import filetype # type: ignore[import-untyped]
from filetype.types.image import Webp # type: ignore[import-untyped]
from starlette import status

from app.common.fastapi_ext import Responses, with_responses
from app.common.filetype_ext import FILE_HEADER_SIZE, match_image_filetype


class FileFormatResponses(Responses):
WRONG_FORMAT = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Invalid file format"
CONTENT_TYPE_MISMATCH = (
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"File content doesn't match the content-type header",
)


@with_responses(FileFormatResponses)
def validate_image_upload(upload: UploadFile) -> UploadFile:
if not filetype.match(upload.file, [Webp()]):
async def validate_image_upload(upload: UploadFile) -> UploadFile:
upload_header_data = await upload.read(FILE_HEADER_SIZE)
image_type = match_image_filetype(upload_header_data)

if image_type is None:
raise FileFormatResponses.WRONG_FORMAT

if image_type.mime != upload.content_type:
raise FileFormatResponses.CONTENT_TYPE_MISMATCH

await upload.seek(0)
return upload


Expand Down
12 changes: 6 additions & 6 deletions app/storage_v2/models/files_db.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from enum import StrEnum
from pathlib import Path
from shutil import copyfileobj
from typing import BinaryIO, Literal, Self
from typing import Literal, Self
from uuid import UUID, uuid4

import aiofiles
from pydantic_marshals.sqlalchemy import MappedModel
from sqlalchemy import Enum
from sqlalchemy.orm import Mapped, mapped_column
Expand Down Expand Up @@ -57,16 +57,16 @@ def content_disposition(self) -> ContentDisposition:
@classmethod
async def create_with_content(
cls,
content: BinaryIO,
filename: str | None,
content: bytes,
filename: str,
file_kind: FileKind,
) -> Self:
file = await cls.create(
name=filename or "upload",
kind=file_kind,
)
with file.path.open("wb") as f:
copyfileobj(content, f) # TODO maybe convert to async
async with aiofiles.open(file.path, "wb") as f:
await f.write(content)
return file

async def delete(self) -> None:
Expand Down
37 changes: 20 additions & 17 deletions app/storage_v2/routers/files_rst.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from datetime import datetime
from io import BytesIO
from os import stat
from typing import Annotated

from fastapi import Header, UploadFile
from PIL import Image
from starlette import status
from starlette.responses import FileResponse, Response
from starlette.staticfiles import NotModifiedResponse
Expand All @@ -13,31 +15,24 @@
from app.storage_v2.dependencies.storage_token_dep import (
StorageTokenPayload,
StorageTokenResponses,
UploadAllowedStorageTokenPayload,
)
from app.storage_v2.dependencies.uploads_dep import ValidatedImageUpload
from app.storage_v2.models.access_groups_db import AccessGroup, AccessGroupFile
from app.storage_v2.models.access_groups_db import AccessGroupFile
from app.storage_v2.models.files_db import File, FileKind

router = APIRouterExt(tags=["files"])


async def upload_file(
storage_token_payload: StorageTokenPayloadSchema,
upload: UploadFile,
upload_content: bytes,
upload_filename: str | None,
file_kind: FileKind,
) -> File:
if not storage_token_payload.can_upload_files:
raise StorageTokenResponses.INVALID_STORAGE_TOKEN

access_group = await AccessGroup.find_first_by_id(
storage_token_payload.access_group_id
)
if access_group is None:
raise StorageTokenResponses.INVALID_STORAGE_TOKEN

file = await File.create_with_content(
content=upload.file,
filename=upload.filename,
content=upload_content,
filename=upload_filename or "upload",
file_kind=file_kind,
)

Expand All @@ -56,12 +51,13 @@ async def upload_file(
summary="Upload a new uncategorized file",
)
async def upload_uncategorized_file(
storage_token_payload: StorageTokenPayload,
storage_token_payload: UploadAllowedStorageTokenPayload,
upload: UploadFile,
) -> File:
return await upload_file(
storage_token_payload=storage_token_payload,
upload=upload,
upload_content=await upload.read(),
upload_filename=upload.filename,
file_kind=FileKind.UNCATEGORIZED,
)

Expand All @@ -73,12 +69,19 @@ async def upload_uncategorized_file(
summary="Upload a new image file",
)
async def upload_image_file(
storage_token_payload: StorageTokenPayload,
storage_token_payload: UploadAllowedStorageTokenPayload,
upload: ValidatedImageUpload,
) -> File:
image = Image.open(BytesIO(await upload.read()))
processed_image = BytesIO()
image.save(processed_image, format="webp")
processed_image.seek(0)
upload_content = processed_image.read()

return await upload_file(
storage_token_payload=storage_token_payload,
upload=upload,
upload_content=upload_content,
upload_filename=upload.filename,
file_kind=FileKind.IMAGE,
)

Expand Down
4 changes: 3 additions & 1 deletion app/users/models/users_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime, timedelta
from enum import StrEnum
from pathlib import Path
from typing import Annotated, Self
from typing import Annotated, ClassVar, Self

from passlib.handlers.pbkdf2 import pbkdf2_sha256
from pydantic import AfterValidator, AwareDatetime, StringConstraints
Expand Down Expand Up @@ -30,6 +30,8 @@ class OnboardingStage(StrEnum):
class User(Base):
__tablename__ = "users"

avatar_shape: ClassVar[tuple[int, int]] = 128, 128

@staticmethod
def generate_hash(password: str) -> str:
return pbkdf2_sha256.hash(password)
Expand Down
29 changes: 21 additions & 8 deletions app/users/routes/avatar_rst.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from typing import Annotated
from io import BytesIO

import filetype # type: ignore[import-untyped]
from fastapi import File, UploadFile
from filetype.types.image import Webp # type: ignore[import-untyped]
import aiofiles
from fastapi import UploadFile
from PIL import Image
from starlette import status

from app.common.fastapi_ext import APIRouterExt, Responses
from app.common.filetype_ext import FILE_HEADER_SIZE, match_image_filetype
from app.users.dependencies.users_dep import AuthorizedUser
from app.users.models.users_db import User

router = APIRouterExt(tags=["current user avatar"])

Expand All @@ -23,13 +25,24 @@ class AvatarResponses(Responses):
)
async def update_or_create_avatar(
user: AuthorizedUser,
avatar: Annotated[UploadFile, File(description="image/webp")],
avatar: UploadFile,
) -> None:
if not filetype.match(avatar.file, [Webp()]):
avatar_header_data = await avatar.read(FILE_HEADER_SIZE)

if match_image_filetype(avatar_header_data) is None:
raise AvatarResponses.WRONG_FORMAT

with user.avatar_path.open("wb") as file:
file.write(await avatar.read())
await avatar.seek(0)
avatar_image: Image.Image = Image.open(BytesIO(await avatar.read()))

avatar_image = avatar_image.resize(User.avatar_shape)

processed_avatar = BytesIO()
avatar_image.save(processed_avatar, format="webp")
processed_avatar.seek(0)

async with aiofiles.open(user.avatar_path, "wb") as file:
await file.write(processed_avatar.read())


@router.delete(
Expand Down
51 changes: 31 additions & 20 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading