diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0fe8f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +SQLALCHEMY_DATABASE_URL=sqlite+aiosqlite:///./dev_database.db +IMAGE_TARGET_SIZE=2019600 +PREVIEW_TARGET_SIZE=504900 +JWT_SECRET_KEY=09d25e094faa6ca2446c818166b7a9565b93f7099f6f2f4caa6cf63b88e8d3e7 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 \ No newline at end of file diff --git a/database/crud/__init__.py b/database/crud/__init__.py index f1d38aa..ffc3a23 100644 --- a/database/crud/__init__.py +++ b/database/crud/__init__.py @@ -1 +1,2 @@ from .games import * +from .users import * diff --git a/database/crud/games.py b/database/crud/games.py index ca9be8b..be2568f 100644 --- a/database/crud/games.py +++ b/database/crud/games.py @@ -7,6 +7,14 @@ from .. import schemas as sch from ..database import add_transaction +async def get_games(db: AsyncSession): + return (await db.execute(select(mdl.Game))).scalars().all() + + +async def get_game(db: AsyncSession, game_id: int): + return await db.get(mdl.Game, game_id) + + async def add_game(db: AsyncSession, game_info: sch.GameCreate, user_id: int): @@ -21,18 +29,16 @@ async def edit_game(db: AsyncSession, game_id: int, game_info: sch.GameCreate): game = await db.get(mdl.Game, game_id) - game_fields = [c.name for c in mdl.Game.__table__.columns] - new_game_info = { - **{k: v for k, v in vars(game).items() if k in game_fields}, - **game_info.model_dump()} - print(game_fields, new_game_info) - game = mdl.Game(**new_game_info) + for key, value in vars(game_info).items(): + if (value and value is not None and getattr(game, key) != value): + setattr(game, key, value) await db.commit() return game -async def get_games(db: AsyncSession): - return (await db.execute(select(mdl.Game))).scalars().all() - - async def get_game(db: AsyncSession, game_id: int): - return await db.get(mdl.Game, game_id) +async def delete_game(db: AsyncSession, + game_id: int): + game = await get_game(db, game_id) + await db.delete(game) + await db.commit() + return game diff --git a/database/crud/users.py b/database/crud/users.py new file mode 100644 index 0000000..c36a62a --- /dev/null +++ b/database/crud/users.py @@ -0,0 +1,26 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from .. import models as mdl +from .. import schemas as sch +from ..database import add_transaction + + +async def get_user(db: AsyncSession, username: str): + return (await db.execute(select(mdl.User).where(mdl.User.name == username))).scalar() + + +async def add_user(db: AsyncSession, + user_data: sch.UserCreate, hash_of_password: str): + user_data_db = \ + {k: v for k, v in user_data.model_dump().items() + if k != "password"} + user = mdl.User(**user_data_db, + hash_of_password=hash_of_password) + return await add_transaction(db, user) + + +async def check_email(db: AsyncSession, email: str): + users = (await db.execute(select(mdl.User) + .where(mdl.User.email == email))).scalars().all() + return True if len(users) == 0 else False diff --git a/database/database.py b/database/database.py index 4b17b45..c538e70 100644 --- a/database/database.py +++ b/database/database.py @@ -2,7 +2,9 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -DATABASE_URL = "sqlite+aiosqlite:///./dev_database.db" +from env import Env + +DATABASE_URL = Env.get_strict("SQLALCHEMY_DATABASE_URL", str) # DATABASE_URL = "postgresql://user:password@postgresserver/db" engine = create_async_engine( diff --git a/database/schemas/__init__.py b/database/schemas/__init__.py index f1d38aa..ffc3a23 100644 --- a/database/schemas/__init__.py +++ b/database/schemas/__init__.py @@ -1 +1,2 @@ from .games import * +from .users import * diff --git a/database/schemas/users.py b/database/schemas/users.py new file mode 100644 index 0000000..e39aa08 --- /dev/null +++ b/database/schemas/users.py @@ -0,0 +1,19 @@ +from typing import Optional +from fastapi import Body +from pydantic import BaseModel, ConfigDict, Field + + +class UserBase(BaseModel): + email: str = Field(examples=["email@gmail.com"]) + name: str = Field(examples=["username"]) + + +class UserCreate(UserBase): + password: str = Field(examples=["password"]) + + +class User(UserBase): + id: int = Field(examples=[1]) + hash_of_password: str = Field(examples=["hash_of_password"]) + + model_config = ConfigDict(from_attributes=True) diff --git a/env.py b/env.py new file mode 100644 index 0000000..9835d2b --- /dev/null +++ b/env.py @@ -0,0 +1,28 @@ +import os +from dotenv import dotenv_values, load_dotenv + + +class Env: + env: dict[str, str | None] = { + **dotenv_values(".env.example"), + **dotenv_values(".env") + } + + @staticmethod + def load_environment(path: str): + load_dotenv(path) + Env.env = {**Env.env, **os.environ} + + @staticmethod + def get(key: str): + return Env.env.get(key) + + @staticmethod + def get_strict[T](key: str, type_: type[T]) -> T: + env_var = Env.env.get(key) + if (env_var is None): + raise ValueError(f"Environment variable {key} not found") + try: + return type_(env_var) + except: + raise ValueError("Environment variable IMAGE_TARGET_SIZE is wrong") diff --git a/file_handler.py b/file_handler.py index 722b343..9cb67b9 100644 --- a/file_handler.py +++ b/file_handler.py @@ -7,6 +7,11 @@ import aiofiles from fastapi import UploadFile from PIL import Image +from env import Env + +IMAGE_TARGET_SIZE = Env.get_strict("IMAGE_TARGET_SIZE", int) +PREVIEW_TARGET_SIZE = Env.get_strict("PREVIEW_TARGET_SIZE", int) + def create_hash_name(filename: str): # TODO: Hash from file data @@ -48,8 +53,9 @@ async def save_image(cover: UploadFile, type: Literal["cover", "screenshot"]): raise ValueError("Invalid image file") cover_full_size = Image.open(BytesIO(cover_data)) - compressed_coefficient = (cover_full_size.size[0] * - cover_full_size.size[1]) / (1920*1080) + compressed_coefficient = \ + (cover_full_size.size[0] * cover_full_size.size[1] + ) / IMAGE_TARGET_SIZE if (compressed_coefficient < 1): compressed_coefficient = 1 @@ -63,7 +69,9 @@ async def save_image(cover: UploadFile, type: Literal["cover", "screenshot"]): await full_size_file.write(buf.getbuffer()) cover_preview = Image.open(BytesIO(cover_data)) - compressed_coefficient /= 4 + compressed_coefficient /= \ + (cover_preview.size[0] * cover_preview.size[1] + ) / PREVIEW_TARGET_SIZE if (compressed_coefficient < 1): compressed_coefficient = 1 diff --git a/main.py b/main.py index 2ab1b42..f3c1ddd 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ app = FastAPI( app.include_router(startup_router) app.include_router(games_router) app.include_router(files_router) +app.include_router(auth_router) app.mount("/content", StaticFiles(directory="content"), name="content") cli = typer.Typer() diff --git a/requirements.txt b/requirements.txt index 5893404..1d72fa8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,9 @@ SQLAlchemy==2.0.30 aiosqlite==0.20.0 typer==0.12.3 aiofiles==23.2.1 -Pillow==10.3.0 \ No newline at end of file +Pillow==10.3.0 +bcrypt==4.1.3 +passlib==1.7.4 +cryptography==42.0.7 +python-jose==3.3.0 +python-dotenv==1.0.1 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py index faaef3d..d2331c4 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1,3 +1,4 @@ from .games import games_router as games_router from .files import files_router as files_router from .startup import startup_router as startup_router +from .auth import auth_router as auth_router diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..d750e29 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,115 @@ +from typing import Annotated, Any +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from passlib.context import CryptContext +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, status, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from jose import JWTError, jwt + +import database as db +from env import Env + +SECRET_KEY = Env.get_strict("JWT_SECRET_KEY", str) +ACCESS_TOKEN_EXPIRE_MINUTES = \ + Env.get_strict("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", int) + + +crypt = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth") + +auth_router = APIRouter(prefix="/auth", tags=["Auth"]) + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str + email: str + + +def check_password(password, hash): return crypt.verify(password, hash) +def get_hash(password): return crypt.hash(password) + + +async def get_user(token: str = Depends(oauth2_scheme), + db_session: AsyncSession = Depends(db.get_session)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY) + token_data = TokenData(**payload) + except Exception: + raise credentials_exception + user = await db.get_user(db_session, token_data.username) + if user is None: + raise credentials_exception + return user + + +def create_token(user: db.User): + access_token_expires = \ + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + access_token_expires + to_encode = { + "username": user.name, + "email": user.email, + "expire": str(expire) + } + encoded_jwt = jwt.encode(to_encode, SECRET_KEY) + return Token(access_token=encoded_jwt, token_type="bearer") + + +@auth_router.post("/registration") +async def registration_user( + user_data: db.UserCreate, + db_session: AsyncSession = Depends(db.get_session) +) -> Token: + if (not await db.check_email(db_session, user_data.email)): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="This email is occupied by another user", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif (await db.get_user(db_session, user_data.name) is not None): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User with the same name already exists", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + user = await db.add_user(db_session, user_data, + get_hash(user_data.password)) + return create_token(user) + + +@auth_router.post("") +async def login_user( + auth_data: OAuth2PasswordRequestForm = Depends(), + db_session: AsyncSession = Depends(db.get_session) +) -> Token: + user = await db.get_user(db_session, auth_data.username) + if (user is None): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + if (not check_password(auth_data.password, user.hash_of_password)): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect password", + headers={"WWW-Authenticate": "Bearer"}, + ) + return create_token(user) + + +@auth_router.get("/me", response_model=db.User) +async def read_me(user: db.User = Depends(get_user)): + return user diff --git a/routes/games.py b/routes/games.py index 1b976e8..5debaf5 100644 --- a/routes/games.py +++ b/routes/games.py @@ -3,15 +3,23 @@ from fastapi import APIRouter, Depends import database as db from file_handler import * +from routes.auth import get_user games_router = APIRouter(prefix="/games", tags=["Games"]) -@games_router.get("/", response_model=list[db.Game]) +@games_router.get("", response_model=list[db.Game]) async def get_games(db_session: AsyncSession = Depends(db.get_session)): return await db.get_games(db_session) +@games_router.post("", response_model=db.Game) +async def add_game(game: db.GameCreate, + user: db.User = Depends(get_user), + db_session: AsyncSession = Depends(db.get_session)): + return await db.add_game(db_session, game, user.id) + + @games_router.get("/cards", response_model=list[db.GameCard]) async def get_games_cards(db_session: AsyncSession = Depends(db.get_session)): return await db.get_games(db_session) @@ -29,8 +37,7 @@ async def edit_game(game_id: int, return await db.edit_game(db_session, game_id, game) -@games_router.post("/", response_model=db.Game) -async def add_game(game: db.GameCreate, - user_id: int, - db_session: AsyncSession = Depends(db.get_session)): - return await db.add_game(db_session, game, user_id) +@games_router.delete("/{game_id}", response_model=db.Game) +async def delete_game(game_id: int, + db_session: AsyncSession = Depends(db.get_session)): + return await db.delete_game(db_session, game_id) diff --git a/routes/startup.py b/routes/startup.py index d35aae2..19e5838 100644 --- a/routes/startup.py +++ b/routes/startup.py @@ -1,11 +1,12 @@ from fastapi import APIRouter from pathlib import Path +from env import Env + startup_router = APIRouter() -@startup_router.on_event("startup") -def startup(): +def create_folders(): need_paths = [ Path() / "content" / "images" / "cover" / "full_size", Path() / "content" / "images" / "cover" / "preview", @@ -15,3 +16,8 @@ def startup(): ] for path in need_paths: path.mkdir(parents=True, exist_ok=True) + + +@startup_router.on_event("startup") +def startup(): + create_folders()