From 6c7dfda51f936286c6c470649820b1666a9bc716 Mon Sep 17 00:00:00 2001 From: Benjamin Bolte Date: Thu, 15 Aug 2024 10:35:40 -0700 Subject: [PATCH] remove old stuff --- linguaphoto/crud/__init__.py | 0 linguaphoto/crud/base.py | 174 ------------------ linguaphoto/crud/images.py | 90 --------- linguaphoto/crud/transcriptions.py | 12 -- linguaphoto/crud/users.py | 78 -------- linguaphoto/crypto.py | 33 ---- linguaphoto/db.py | 133 -------------- linguaphoto/main.py | 11 +- linguaphoto/model.py | 49 ----- linguaphoto/routers/__init__.py | 0 linguaphoto/routers/images.py | 93 ---------- linguaphoto/routers/users.py | 182 ------------------- linguaphoto/settings/__init__.py | 50 ----- linguaphoto/settings/configs/local.yaml | 10 - linguaphoto/settings/configs/production.yaml | 6 - linguaphoto/settings/environment.py | 63 ------- 16 files changed, 4 insertions(+), 980 deletions(-) delete mode 100644 linguaphoto/crud/__init__.py delete mode 100644 linguaphoto/crud/base.py delete mode 100644 linguaphoto/crud/images.py delete mode 100644 linguaphoto/crud/transcriptions.py delete mode 100644 linguaphoto/crud/users.py delete mode 100644 linguaphoto/crypto.py delete mode 100644 linguaphoto/db.py delete mode 100644 linguaphoto/model.py delete mode 100644 linguaphoto/routers/__init__.py delete mode 100644 linguaphoto/routers/images.py delete mode 100644 linguaphoto/routers/users.py delete mode 100644 linguaphoto/settings/__init__.py delete mode 100644 linguaphoto/settings/configs/local.yaml delete mode 100644 linguaphoto/settings/configs/production.yaml delete mode 100644 linguaphoto/settings/environment.py diff --git a/linguaphoto/crud/__init__.py b/linguaphoto/crud/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/linguaphoto/crud/base.py b/linguaphoto/crud/base.py deleted file mode 100644 index f7d6801..0000000 --- a/linguaphoto/crud/base.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Defines the base CRUD interface.""" - -import asyncio -import itertools -import logging -from typing import Any, AsyncContextManager, Literal, Self - -import aioboto3 -from botocore.exceptions import ClientError -from redis.asyncio import Redis -from types_aiobotocore_cloudfront.client import CloudFrontClient -from types_aiobotocore_dynamodb.service_resource import DynamoDBServiceResource -from types_aiobotocore_s3.client import S3Client - -from linguaphoto.settings import settings - -logger = logging.getLogger(__name__) - - -class BaseCrud(AsyncContextManager["BaseCrud"]): - def __init__(self) -> None: - super().__init__() - - self.__db: DynamoDBServiceResource | None = None - self.__kv: Redis | None = None - self.__s3: S3Client | None = None - self.__cf: CloudFrontClient | None = None - - @property - def db(self) -> DynamoDBServiceResource: - if self.__db is None: - raise RuntimeError("Must call __aenter__ first!") - return self.__db - - @property - def kv(self) -> Redis: - if self.__kv is None: - raise RuntimeError("Must call __aenter__ first!") - return self.__kv - - @property - def s3(self) -> S3Client: - if self.__s3 is None: - raise RuntimeError("Must call __aenter__ first!") - return self.__s3 - - @property - def cf(self) -> CloudFrontClient: - if self.__cf is None: - raise RuntimeError("Must call __aenter__ first!") - return self.__cf - - async def _init_dynamodb(self, session: aioboto3.Session) -> Self: - db = session.resource("dynamodb") - self.__db = await db.__aenter__() - return self - - async def _init_cloudfront(self, session: aioboto3.Session) -> Self: - cf = session.client("cloudfront") - self.__cf = await cf.__aenter__() - return self - - async def _init_s3(self, session: aioboto3.Session) -> Self: - s3 = session.client("s3") - self.__s3 = await s3.__aenter__() - return self - - async def _init_redis(self) -> Self: - kv = Redis( - host=settings.redis.host, - password=settings.redis.password, - port=settings.redis.port, - db=settings.redis.db, - ) - self.__kv = await kv.__aenter__() - return self - - async def __aenter__(self) -> Self: - session = aioboto3.Session() - - await asyncio.gather( - self._init_dynamodb(session), - self._init_cloudfront(session), - self._init_s3(session), - self._init_redis(), - ) - - return self - - async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # noqa: ANN401 - await asyncio.gather( - self.db.__aexit__(exc_type, exc_val, exc_tb), - self.cf.__aexit__(exc_type, exc_val, exc_tb), - self.kv.__aexit__(exc_type, exc_val, exc_tb), - self.s3.__aexit__(exc_type, exc_val, exc_tb), - ) - self.__db = None - self.__kv = None - self.__s3 = None - - async def _create_dynamodb_table( - self, - name: str, - keys: list[tuple[str, Literal["S", "N", "B"], Literal["HASH", "RANGE"]]], - gsis: list[tuple[str, str, Literal["S", "N", "B"], Literal["HASH", "RANGE"]]] | None = None, - deletion_protection: bool = False, - ) -> None: - """Creates a table in the Dynamo database if a table of that name does not already exist. - - Args: - name: Name of the table. - keys: Primary and secondary keys. Do not include non-key attributes. - gsis: Making an attribute a GSI is required in order to query - against it. Note HASH on a GSI does not actually enforce - uniqueness. Instead, the difference is: you cannot query - RANGE fields alone, but you may query HASH fields. - deletion_protection: Whether the table is protected from being - deleted. - """ - try: - await self.db.meta.client.describe_table(TableName=name) - logger.info("Found existing table %s", name) - - except ClientError: - logger.info("Creating %s table", name) - - if gsis is None: - table = await self.db.create_table( - AttributeDefinitions=[ - {"AttributeName": n, "AttributeType": t} for n, t in ((n, t) for (n, t, _) in keys) - ], - TableName=name, - KeySchema=[{"AttributeName": n, "KeyType": t} for n, _, t in keys], - DeletionProtectionEnabled=deletion_protection, - BillingMode="PAY_PER_REQUEST", - ) - - else: - table = await self.db.create_table( - AttributeDefinitions=[ - {"AttributeName": n, "AttributeType": t} - for n, t in itertools.chain(((n, t) for (n, t, _) in keys), ((n, t) for _, n, t, _ in gsis)) - ], - TableName=name, - KeySchema=[{"AttributeName": n, "KeyType": t} for n, _, t in keys], - GlobalSecondaryIndexes=( - [ - { - "IndexName": i, - "KeySchema": [{"AttributeName": n, "KeyType": t}], - "Projection": {"ProjectionType": "ALL"}, - } - for i, n, _, t in gsis - ] - ), - DeletionProtectionEnabled=deletion_protection, - BillingMode="PAY_PER_REQUEST", - ) - - await table.wait_until_exists() - - async def _delete_dynamodb_table(self, name: str) -> None: - """Deletes a table in the Dynamo database. - - Args: - name: Name of the table. - """ - try: - table = await self.db.Table(name) - await table.delete() - await table.wait_until_not_exists() - except ClientError: - logger.info("Table %s does not exist", name) - return diff --git a/linguaphoto/crud/images.py b/linguaphoto/crud/images.py deleted file mode 100644 index 84c37ac..0000000 --- a/linguaphoto/crud/images.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Defines CRUD interface for images API.""" - -import asyncio -import uuid -from io import BytesIO - -from PIL import Image - -from linguaphoto.crud.base import BaseCrud -from linguaphoto.settings import settings - - -def crop_image(img: Image.Image) -> Image.Image: - width, height = img.size - - if height > width: - diff = height - width - box = (0, diff // 2, width, height - (diff + 1) // 2) - else: - diff = width - height - box = (diff // 2, 0, width - (diff + 1) // 2, height) - - target_hw = settings.image.thumbnail_width - return img.resize((target_hw, target_hw), resample=Image.Resampling.BICUBIC, box=box) - - -def get_bytes(img: Image.Image) -> bytes: - img_bytes = BytesIO() - img.save(img_bytes, format="WEBP") - img_bytes.seek(0) - return img_bytes.read() - - -class ImagesCrud(BaseCrud): - async def add_image(self, image_id: uuid.UUID, user_id: uuid.UUID, img: Image.Image) -> None: - # Adds the image to the database. - table = await self.db.Table("Images") - await table.put_item(Item={"image_id": str(image_id), "user_id": str(user_id)}) - - # Uploads the image to S3. - key = f"{user_id}/{image_id}" - - cropped_img = crop_image(img) - - await asyncio.gather( - self.s3.put_object( - Bucket=settings.aws.image_bucket_id, - Key=f"{key}/orig.webp", - Body=get_bytes(img), - ), - self.s3.put_object( - Bucket=settings.aws.image_bucket_id, - Key=f"{key}/thumb.webp", - Body=get_bytes(cropped_img), - ), - ) - - async def get_image_ids(self, user_id: uuid.UUID, offset: int = 0, limit: int = 10) -> list[uuid.UUID]: - # Gets the image IDs from the database. - table = await self.db.Table("Images") - result = await table.query(IndexName="SOMETHING", Limit=limit) - return [uuid.UUID(key) for key in result.keys()] - - async def delete_image(self, image_id: uuid.UUID, user_id: uuid.UUID) -> None: - # Deletes the image from the database. - table = await self.db.Table("Images") - await table.delete_item(Key={"image_id": str(image_id), "user_id": str(user_id)}) - - # Deletes the image from S3. - key = f"{user_id}/{image_id}.webp" - await self.s3.delete_object(Bucket=settings.aws.image_bucket_id, Key=key) - - async def get_image_url(self, image_id: uuid.UUID, user_id: uuid.UUID, thumb: bool = False) -> str: - key = f"{user_id}/{image_id}/{'thumb' if thumb else 'orig'}.webp" - - # If CloudFront is enabled, gets the image URL from CloudFront. - if settings.aws.cloudfront_url is not None: - return await self.cf.generate_presigned_url( - "get_object", - Params={"Bucket": settings.aws.image_bucket_id, "Key": key}, - ExpiresIn=3600, - ) - - # Gets the image URL from S3. - url = await self.s3.generate_presigned_url( - "get_object", - Params={"Bucket": settings.aws.image_bucket_id, "Key": key}, - ExpiresIn=3600, - ) - return url diff --git a/linguaphoto/crud/transcriptions.py b/linguaphoto/crud/transcriptions.py deleted file mode 100644 index 8b0592d..0000000 --- a/linguaphoto/crud/transcriptions.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Defines CRUD interface for transcriptions API.""" - -import uuid - -from PIL import Image - -from linguaphoto.crud.base import BaseCrud - - -class ImagesCrud(BaseCrud): - async def add_image_transcription(self, transcription_id: uuid.UUID, image_id: uuid.UUID, img: Image.Image) -> None: - asdf diff --git a/linguaphoto/crud/users.py b/linguaphoto/crud/users.py deleted file mode 100644 index 7a44e4b..0000000 --- a/linguaphoto/crud/users.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Defines CRUD interface for user API.""" - -import asyncio -import uuid -import warnings - -from boto3.dynamodb.conditions import Key as KeyCondition - -from linguaphoto.crud.base import BaseCrud -from linguaphoto.crypto import hash_api_key -from linguaphoto.model import ApiKey, User - - -class UserCrud(BaseCrud): - async def add_user(self, user: User) -> None: - table = await self.db.Table("Users") - await table.put_item( - Item=user.model_dump(), - ConditionExpression="attribute_not_exists(email) AND attribute_not_exists(username)", - ) - - async def get_user(self, user_id: uuid.UUID) -> User | None: - table = await self.db.Table("Users") - user_dict = await table.get_item(Key={"user_id": str(user_id)}) - if "Item" not in user_dict: - return None - return User.model_validate(user_dict["Item"]) - - async def get_user_from_email(self, email: str) -> User | None: - table = await self.db.Table("Users") - user_dict = await table.query(IndexName="emailIndex", KeyConditionExpression=KeyCondition("email").eq(email)) - items = user_dict["Items"] - if len(items) == 0: - return None - if len(items) > 1: - raise ValueError(f"Multiple users found with email {email}") - return User.model_validate(items[0]) - - async def get_user_id_from_api_key(self, api_key: uuid.UUID) -> uuid.UUID | None: - api_key_hash = hash_api_key(api_key) - user_id = await self.kv.get(api_key_hash) - if user_id is None: - return None - return uuid.UUID(user_id.decode("utf-8")) - - async def delete_user(self, user: User) -> None: - table = await self.db.Table("Users") - await table.delete_item(Key={"user_id": user.user_id}) - - async def list_users(self) -> list[User]: - warnings.warn("`list_users` probably shouldn't be called in production", ResourceWarning) - table = await self.db.Table("Users") - return [User.model_validate(user) for user in await table.scan()] - - async def get_user_count(self) -> int: - table = await self.db.Table("Users") - return await table.item_count - - async def add_api_key(self, api_key: uuid.UUID, user_id: uuid.UUID, lifetime: int) -> None: - row = ApiKey.from_api_key(api_key, user_id, lifetime) - await self.kv.setex(row.api_key_hash, row.lifetime, row.user_id) - - async def check_api_key(self, api_key: uuid.UUID, user_id: uuid.UUID) -> bool: - row = await self.kv.get(hash_api_key(api_key)) - return row is not None and row == user_id - - async def delete_api_key(self, api_key: uuid.UUID) -> None: - await self.kv.delete(hash_api_key(api_key)) - - -async def test_adhoc() -> None: - async with UserCrud() as crud: - await crud.add_user(User(user_id=str(uuid.uuid4()), email="ben@kscale.dev")) - - -if __name__ == "__main__": - # python -m linguaphoto.crud.users - asyncio.run(test_adhoc()) diff --git a/linguaphoto/crypto.py b/linguaphoto/crypto.py deleted file mode 100644 index b22a62b..0000000 --- a/linguaphoto/crypto.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Defines crypto functions.""" - -import datetime -import hashlib -import uuid -from typing import Any - -import jwt - -from linguaphoto.settings import settings - - -def hash_api_key(api_key: uuid.UUID) -> str: - return hashlib.sha256(api_key.bytes).hexdigest() - - -def get_new_user_id() -> uuid.UUID: - return uuid.uuid4() - - -def get_new_api_key(user_id: uuid.UUID) -> uuid.UUID: - user_id_hash = hashlib.sha1(user_id.bytes).digest() - return uuid.UUID(bytes=user_id_hash[:16], version=5) - - -def encode_jwt(data: dict[str, Any], expire_after: datetime.timedelta | None = None) -> str: # noqa: ANN401 - if expire_after is not None: - data["exp"] = datetime.datetime.utcnow() + expire_after - return jwt.encode(data, settings.crypto.jwt_secret, algorithm=settings.crypto.algorithm) - - -def decode_jwt(token: str) -> dict[str, Any]: # noqa: ANN401 - return jwt.decode(token, settings.crypto.jwt_secret, algorithms=[settings.crypto.algorithm]) diff --git a/linguaphoto/db.py b/linguaphoto/db.py deleted file mode 100644 index eeb1d2f..0000000 --- a/linguaphoto/db.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Defines base tools for interacting with the database.""" - -import argparse -import asyncio -import logging -import uuid -from typing import AsyncGenerator, Self - -from linguaphoto.crud.base import BaseCrud -from linguaphoto.crud.images import ImagesCrud -from linguaphoto.crud.users import UserCrud -from linguaphoto.model import User -from linguaphoto.settings import settings - - -class Crud( - UserCrud, - ImagesCrud, - BaseCrud, -): - """Composes the various CRUD classes into a single class.""" - - @classmethod - async def get(cls) -> AsyncGenerator[Self, None]: - async with cls() as crud: - yield crud - - -async def create_tables(crud: Crud | None = None, deletion_protection: bool = False) -> None: - """Initializes all of the database tables. - - Args: - crud: The top-level CRUD class. - deletion_protection: Whether to enable deletion protection on the tables. - """ - logging.basicConfig(level=logging.INFO) - - if crud is None: - async with Crud() as crud: - await create_tables(crud) - - else: - await asyncio.gather( - crud._create_dynamodb_table( - name="Users", - keys=[ - ("user_id", "S", "HASH"), - ], - gsis=[ - ("emailIndex", "email", "S", "HASH"), - ], - deletion_protection=deletion_protection, - ), - crud._create_dynamodb_table( - name="Images", - keys=[ - ("image_id", "S", "HASH"), - ], - gsis=[ - ("userIndex", "user_id", "S", "HASH"), - ], - deletion_protection=deletion_protection, - ), - crud._create_dynamodb_table( - name="Transcriptions", - keys=[ - ("transcription_id", "S", "HASH"), - ], - gsis=[ - ("userIndex", "user_id", "S", "HASH"), - ("imagesIndex", "image_id", "S", "HASH"), - ], - deletion_protection=deletion_protection, - ), - ) - - -async def delete_tables(crud: Crud | None = None) -> None: - """Deletes all of the database tables. - - Args: - crud: The top-level CRUD class. - """ - logging.basicConfig(level=logging.INFO) - - if crud is None: - async with Crud() as crud: - await delete_tables(crud) - - else: - await asyncio.gather( - crud._delete_dynamodb_table("Users"), - crud._delete_dynamodb_table("Images"), - ) - - -async def populate_with_dummy_data(crud: Crud | None = None) -> None: - """Populates the database with dummy data. - - Args: - crud: The top-level CRUD class. - """ - logging.basicConfig(level=logging.INFO) - - if crud is None: - async with Crud() as crud: - await populate_with_dummy_data(crud) - - else: - assert (test_user := settings.user.test_user) is not None - await crud.add_user(user=User(user_id=str(uuid.uuid4()), email=test_user.email)) - - -async def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("action", choices=["create", "delete", "populate"]) - args = parser.parse_args() - - async with Crud() as crud: - match args.action: - case "create": - await create_tables(crud) - case "delete": - await delete_tables(crud) - case "populate": - await populate_with_dummy_data(crud) - case _: - raise ValueError(f"Invalid action: {args.action}") - - -if __name__ == "__main__": - # python -m linguaphoto.db - asyncio.run(create_tables()) diff --git a/linguaphoto/main.py b/linguaphoto/main.py index 723e9d4..c508313 100644 --- a/linguaphoto/main.py +++ b/linguaphoto/main.py @@ -4,16 +4,12 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from linguaphoto.routers.images import images_router -from linguaphoto.routers.users import users_router -from linguaphoto.settings import settings - app = FastAPI() # Adds CORS middleware. app.add_middleware( CORSMiddleware, - allow_origins=[settings.site.homepage], + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -28,5 +24,6 @@ async def value_error_exception_handler(request: Request, exc: ValueError) -> JS ) -app.include_router(users_router, prefix="/users", tags=["users"]) -app.include_router(images_router, prefix="/images", tags=["images"]) +@app.get("/") +async def root() -> dict[str, str]: + return {"message": "Hello, World!"} diff --git a/linguaphoto/model.py b/linguaphoto/model.py deleted file mode 100644 index 8fdee87..0000000 --- a/linguaphoto/model.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Defines the table models for the API. - -These correspond directly with the rows in our database, and provide helper -methods for converting from our input data into the format the database -expects (for example, converting a UUID into a string). -""" - -import uuid - -from pydantic import BaseModel - -from linguaphoto.crypto import hash_api_key - - -class User(BaseModel): - user_id: str # Primary key - email: str - - -class ApiKey(BaseModel): - """Stored in Redis rather than DynamoDB.""" - - api_key_hash: str # Primary key - user_id: str - lifetime: int - - @classmethod - def from_api_key(cls, api_key: uuid.UUID, user_id: uuid.UUID, lifetime: int) -> "ApiKey": - api_key_hash = hash_api_key(api_key) - return cls(api_key_hash=api_key_hash, user_id=str(user_id), lifetime=lifetime) - - -class Image(BaseModel): - image_id: str # Primary key - user_id: str - url: str - - -class Transcription(BaseModel): - transcript: str - pinyin: str - translation: str - - -class Transcriptions(BaseModel): - transcript_id: str # Primary key - image_id: str - user_id: str - transcriptions: list[Transcription] diff --git a/linguaphoto/routers/__init__.py b/linguaphoto/routers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/linguaphoto/routers/images.py b/linguaphoto/routers/images.py deleted file mode 100644 index b64fa84..0000000 --- a/linguaphoto/routers/images.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Defines the API endpoint for creating and deleting images.""" - -import logging -import uuid -from io import BytesIO -from typing import Annotated - -from fastapi import APIRouter, Depends, File, Request, UploadFile, status -from fastapi.exceptions import HTTPException -from fastapi.responses import RedirectResponse -from PIL import Image -from pydantic.main import BaseModel - -from linguaphoto.db import Crud -from linguaphoto.routers.users import ApiKeyData, get_api_key -from linguaphoto.settings import settings - -logger = logging.getLogger(__name__) - -images_router = APIRouter() - - -class UploadImageResponse(BaseModel): - success: bool = True - - -@images_router.post("/upload") -async def upload_image( - image: Annotated[UploadFile, File(...)], - data: Annotated[ApiKeyData, Depends(get_api_key)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> UploadImageResponse: - if image.content_type is None or not image.content_type.startswith("image/"): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type") - max_bytes = settings.image.max_upload_height * settings.image.max_upload_width * 3 - if (fsize := image.size) is None or fsize >= max_bytes: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"File size exceeds {max_bytes} bytes") - - # Spools the file to an in-memory PIL image. - fp = BytesIO(image.file.read()) - fp.seek(0) - img = Image.open(fp, formats=("PNG", "JPEG", "WEBP")) - - if img.width >= settings.image.max_upload_width: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"File width exceeds {settings.image.max_upload_width} pixels", - ) - if img.height >= settings.image.max_upload_height: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"File height exceeds {settings.image.max_upload_height} pixels", - ) - - # Adds the image to the database. - image_id = uuid.uuid4() - if (user_id := await crud.get_user_id_from_api_key(data.api_key)) is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - await crud.add_image(image_id, user_id, img) - - return UploadImageResponse() - - -@images_router.delete("/delete") -async def delete_image( - image_id: uuid.UUID, - data: Annotated[ApiKeyData, Depends(get_api_key)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> None: - if (user_id := await crud.get_user_id_from_api_key(data.api_key)) is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - await crud.delete_image(image_id, user_id) - - -@images_router.get("/see") -async def see_image( - image_id: uuid.UUID, - thumb: Annotated[bool, False], - data: Annotated[ApiKeyData, Depends(get_api_key)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> RedirectResponse: - if (user_id := await crud.get_user_id_from_api_key(data.api_key)) is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - image_url = await crud.get_image_url(image_id, user_id, thumb) - return RedirectResponse(url=image_url) - - -@images_router.get("/list") -async def list_images( - request: Request, - data: Annotated[ApiKeyData, Depends(get_api_key)], -) -> None: - pass diff --git a/linguaphoto/routers/users.py b/linguaphoto/routers/users.py deleted file mode 100644 index 7044aff..0000000 --- a/linguaphoto/routers/users.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Defines the API endpoint for creating, deleting and updating user information.""" - -import logging -import uuid -from email.utils import parseaddr as parse_email_address -from typing import Annotated - -import aiohttp -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from fastapi.security.utils import get_authorization_scheme_param -from pydantic.main import BaseModel - -from linguaphoto.crypto import get_new_api_key, get_new_user_id -from linguaphoto.db import Crud -from linguaphoto.model import User -from linguaphoto.settings import settings - -logger = logging.getLogger(__name__) - -users_router = APIRouter() - -TOKEN_TYPE = "Bearer" - - -def set_token_cookie(response: Response, token: str, key: str) -> None: - response.set_cookie( - key=key, - value=token, - httponly=True, - secure=False, - samesite="lax", - ) - - -class ApiKeyData(BaseModel): - api_key: uuid.UUID - - -async def get_api_key(request: Request) -> ApiKeyData: - # Tries Authorization header. - authorization = request.headers.get("Authorization") or request.headers.get("authorization") - if authorization: - scheme, credentials = get_authorization_scheme_param(authorization) - if not (scheme and credentials): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") - if scheme.lower() != TOKEN_TYPE.lower(): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") - return ApiKeyData(api_key=uuid.UUID(credentials)) - - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") - - -class UserSignup(BaseModel): - email: str - login_url: str - lifetime: int - - -def validate_email(email: str) -> str: - try: - email = parse_email_address(email)[1] - except Exception: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid email") - return email - - -class OneTimePass(BaseModel): - payload: str - - -class UserLoginResponse(BaseModel): - api_key: str - - -async def get_login_response(email: str, lifetime: int, crud: Crud) -> UserLoginResponse: - """Takes the user email and returns an API key. - - This function gets a user API key for an email which has been validated, - either through an OTP or through Google OAuth. - - Args: - email: The validated email of the user. - crud: The database CRUD object. - lifetime: The lifetime (in seconds) of the API key to be returned. - - Returns: - The API key for the user. - """ - # If the user doesn't exist, then create a new user. - user_obj = await crud.get_user_from_email(email) - if user_obj is None: - await crud.add_user(User(user_id=str(get_new_user_id()), email=email)) - if (user_obj := await crud.get_user_from_email(email)) is None: - raise RuntimeError("Failed to add user to the database") - - # Issue a new API key for the user. - user_id: uuid.UUID = user_obj.to_uuid() - api_key: uuid.UUID = get_new_api_key(user_id) - await crud.add_api_key(api_key, user_id, lifetime) - - return UserLoginResponse(api_key=str(api_key)) - - -async def get_google_user_info(token: str) -> dict: - async with aiohttp.ClientSession() as session: - response = await session.get("https://www.googleapis.com/oauth2/v3/userinfo", params={"access_token": token}) - if response.status != 200: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Google token") - return await response.json() - - -class GoogleLogin(BaseModel): - token: str # This is the token that Google gives us for authenticated users. - - -@users_router.post("/google") -async def google_login_endpoint( - data: GoogleLogin, - crud: Annotated[Crud, Depends(Crud.get)], -) -> UserLoginResponse: - if (test_user := settings.user.test_user) is not None and data.token == test_user.google_token: - return await get_login_response(test_user.email, settings.user.auth_lifetime_seconds, crud) - - try: - idinfo = await get_google_user_info(data.token) - email = idinfo["email"] - except ValueError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Google token") - if idinfo.get("email_verified") is not True: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google email not verified") - - return await get_login_response(email, settings.user.auth_lifetime_seconds, crud) - - -class UserInfoResponse(BaseModel): - email: str - - -@users_router.get("/me", response_model=UserInfoResponse) -async def get_user_info_endpoint( - data: Annotated[ApiKeyData, Depends(get_api_key)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> UserInfoResponse: - user_id = await crud.get_user_id_from_api_key(data.api_key) - if user_id is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - user_obj = await crud.get_user(user_id) - if user_obj is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - return UserInfoResponse(email=user_obj.email) - - -@users_router.delete("/me") -async def delete_user_endpoint( - data: Annotated[ApiKeyData, Depends(get_api_key)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> bool: - user_id = await crud.get_user_id_from_api_key(data.api_key) - if user_id is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - user_obj = await crud.get_user(user_id) - if user_obj is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - await crud.delete_user(user_obj) - return True - - -@users_router.delete("/logout") -async def logout_user_endpoint( - data: Annotated[ApiKeyData, Depends(get_api_key)], - crud: Annotated[Crud, Depends(Crud.get)], -) -> bool: - await crud.delete_api_key(data.api_key) - return True - - -@users_router.get("/{user_id}", response_model=UserInfoResponse) -async def get_user_info_by_id_endpoint(user_id: str, crud: Annotated[Crud, Depends(Crud.get)]) -> UserInfoResponse: - user_obj = await crud.get_user(uuid.UUID(user_id)) - if user_obj is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - return UserInfoResponse(email=user_obj.email) diff --git a/linguaphoto/settings/__init__.py b/linguaphoto/settings/__init__.py deleted file mode 100644 index 74baf7d..0000000 --- a/linguaphoto/settings/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Defines the bot settings.""" - -import os -from pathlib import Path -from typing import Any, Callable, Generic, TypeVar, cast - -from dotenv import load_dotenv -from omegaconf import OmegaConf - -from linguaphoto.settings.environment import EnvironmentSettings - -T = TypeVar("T") - - -def _check_exists(path: Path) -> Path: - if not path.exists(): - raise ValueError(f"Directory not found: {path}") - return path - - -def _load_environment_settings() -> EnvironmentSettings: - if "LINGUAPHOTO_ENVIRONMENT_SECRETS" in os.environ: - load_dotenv(os.environ["LINGUAPHOTO_ENVIRONMENT_SECRETS"]) - environment = os.environ["LINGUAPHOTO_ENVIRONMENT"] - base_dir = (Path(__file__).parent / "configs").resolve() - config_path = _check_exists(base_dir / f"{environment}.yaml") - config = OmegaConf.load(config_path) - config = OmegaConf.merge(OmegaConf.structured(EnvironmentSettings), config) - return cast(EnvironmentSettings, config) - - -class _LazyLoadSettings(Generic[T]): - def __init__(self, func: Callable[[], T]) -> None: - super().__init__() - - self._lazy_load_func = func - self._lazy_load_value: T | None = None - - def __getattribute__(self, name: str) -> Any: # noqa: ANN401 - if name in ("_lazy_load_func", "_lazy_load_value"): - return super().__getattribute__(name) - value = super().__getattribute__("_lazy_load_value") - if value is None: - func = super().__getattribute__("_lazy_load_func") - value = func() - super().__setattr__("_lazy_load_value", value) - return getattr(value, name) - - -settings = cast(EnvironmentSettings, _LazyLoadSettings(_load_environment_settings)) diff --git a/linguaphoto/settings/configs/local.yaml b/linguaphoto/settings/configs/local.yaml deleted file mode 100644 index 9f65b57..0000000 --- a/linguaphoto/settings/configs/local.yaml +++ /dev/null @@ -1,10 +0,0 @@ -crypto: - jwt_secret: fakeJwtSecret -site: - homepage: http://127.0.0.1:3000 -aws: - image_bucket_id: linguaphoto-images -user: - test_user: - email: test@example.com - google_token: fakeGoogleToken diff --git a/linguaphoto/settings/configs/production.yaml b/linguaphoto/settings/configs/production.yaml deleted file mode 100644 index f32d1c2..0000000 --- a/linguaphoto/settings/configs/production.yaml +++ /dev/null @@ -1,6 +0,0 @@ -crypto: - jwt_secret: ${oc.env:JWT_SECRET} -site: - homepage: https://linguaphoto.com - image_url: https://media.linguaphoto.com - api: https://api.linguaphoto.com diff --git a/linguaphoto/settings/environment.py b/linguaphoto/settings/environment.py deleted file mode 100644 index 5b05bbe..0000000 --- a/linguaphoto/settings/environment.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Defines the bot environment settings.""" - -from dataclasses import dataclass, field - -from omegaconf import II, MISSING - - -@dataclass -class RedisSettings: - host: str = field(default=II("oc.env:LINGUAPHOTO_REDIS_HOST")) - password: str = field(default=II("oc.env:LINGUAPHOTO_REDIS_PASSWORD")) - port: int = field(default=6379) - db: int = field(default=0) - - -@dataclass -class CryptoSettings: - expire_token_minutes: int = field(default=10) - expire_otp_minutes: int = field(default=10) - jwt_secret: str = field(default=MISSING) - algorithm: str = field(default="HS256") - - -@dataclass -class TestUserSettings: - email: str = field(default=MISSING) - google_token: str = field(default=MISSING) - - -@dataclass -class UserSettings: - test_user: TestUserSettings | None = field(default_factory=TestUserSettings) - auth_lifetime_seconds: int = field(default=604800) # 1 week - - -@dataclass -class SiteSettings: - homepage: str = field(default=MISSING) - image_url: str | None = field(default=None) - - -@dataclass -class AWSSettings: - image_bucket_id: str = field(default=MISSING) - cloudfront_url: str | None = field(default=None) - - -@dataclass -class ImageSettings: - max_upload_width: int = field(default=4096) - max_upload_height: int = field(default=4096) - thumbnail_width: int = field(default=256) - - -@dataclass -class EnvironmentSettings: - redis: RedisSettings = field(default_factory=RedisSettings) - user: UserSettings = field(default_factory=UserSettings) - crypto: CryptoSettings = field(default_factory=CryptoSettings) - site: SiteSettings = field(default_factory=SiteSettings) - aws: AWSSettings = field(default_factory=AWSSettings) - image: ImageSettings = field(default_factory=ImageSettings) - debug: bool = field(default=False)