diff --git a/classes/exceptions.py b/classes/exceptions.py index 1bec769..73c739a 100644 --- a/classes/exceptions.py +++ b/classes/exceptions.py @@ -1,7 +1,9 @@ from typing import Literal +from fastapi import HTTPException -class AlbumNotFoundError(Exception): + +class AlbumNotFoundError(HTTPException): """Raises HTTP 404 if no album with this ID found.""" def __init__(self, id: str): @@ -16,7 +18,7 @@ class AlbumNotFoundError(Exception): } -class AlbumNameNotFoundError(Exception): +class AlbumNameNotFoundError(HTTPException): """Raises HTTP 404 if no album with this name found.""" def __init__(self, name: str): @@ -29,9 +31,15 @@ class AlbumNameNotFoundError(Exception): } }, } + super().__init__( + status_code=404, + detail=self.openapi["content"]["application/json"]["example"][ + "detail" + ].format(name=self.name), + ) -class AlbumAlreadyExistsError(Exception): +class AlbumAlreadyExistsError(HTTPException): """Raises HTTP 409 if album with this name already exists.""" def __init__(self, name: str): @@ -44,9 +52,15 @@ class AlbumAlreadyExistsError(Exception): } }, } + super().__init__( + status_code=409, + detail=self.openapi["content"]["application/json"]["example"][ + "detail" + ].format(name=self.name), + ) -class AlbumIncorrectError(Exception): +class AlbumIncorrectError(HTTPException): """Raises HTTP 406 if album's title or name is invalid.""" def __init__(self, place: Literal["name", "title"], error: str) -> None: @@ -56,13 +70,19 @@ class AlbumIncorrectError(Exception): "description": "Album Name/Title Invalid", "content": { "application/json": { - "example": {"detail": "Album {name/title} invalid: {error}"} + "example": {"detail": "Album {place} invalid: {error}"} } }, } + super().__init__( + status_code=406, + detail=self.openapi["content"]["application/json"]["example"][ + "detail" + ].format(place=self.place, error=self.error), + ) -class PhotoNotFoundError(Exception): +class PhotoNotFoundError(HTTPException): """Raises HTTP 404 if no photo with this ID found.""" def __init__(self, id: str): @@ -75,9 +95,15 @@ class PhotoNotFoundError(Exception): } }, } + super().__init__( + status_code=404, + detail=self.openapi["content"]["application/json"]["example"][ + "detail" + ].format(id=self.id), + ) -class PhotoSearchQueryEmptyError(Exception): +class PhotoSearchQueryEmptyError(HTTPException): """Raises HTTP 422 if no photo search query provided.""" def __init__(self): @@ -91,9 +117,13 @@ class PhotoSearchQueryEmptyError(Exception): } }, } + super().__init__( + status_code=422, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) -class VideoNotFoundError(Exception): +class VideoNotFoundError(HTTPException): """Raises HTTP 404 if no video with this ID found.""" def __init__(self, id: str): @@ -106,9 +136,15 @@ class VideoNotFoundError(Exception): } }, } + super().__init__( + status_code=404, + detail=self.openapi["content"]["application/json"]["example"][ + "detail" + ].format(id=self.id), + ) -class VideoSearchQueryEmptyError(Exception): +class VideoSearchQueryEmptyError(HTTPException): """Raises HTTP 422 if no video search query provided.""" def __init__(self): @@ -122,9 +158,13 @@ class VideoSearchQueryEmptyError(Exception): } }, } + super().__init__( + status_code=422, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) -class SearchPageInvalidError(Exception): +class SearchPageInvalidError(HTTPException): """Raises HTTP 400 if page or page size are not in valid range.""" def __init__(self): @@ -138,9 +178,13 @@ class SearchPageInvalidError(Exception): } }, } + super().__init__( + status_code=400, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) -class SearchTokenInvalidError(Exception): +class SearchTokenInvalidError(HTTPException): """Raises HTTP 401 if search token is not valid.""" def __init__(self): @@ -150,9 +194,13 @@ class SearchTokenInvalidError(Exception): "application/json": {"example": {"detail": "Invalid search token."}} }, } + super().__init__( + status_code=401, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) -class UserEmailCodeInvalid(Exception): +class UserEmailCodeInvalid(HTTPException): """Raises HTTP 400 if email confirmation code is not valid.""" def __init__(self): @@ -164,9 +212,13 @@ class UserEmailCodeInvalid(Exception): } }, } + super().__init__( + status_code=400, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) -class UserAlreadyExists(Exception): +class UserAlreadyExists(HTTPException): """Raises HTTP 409 if user with this name already exists.""" def __init__(self): @@ -178,9 +230,13 @@ class UserAlreadyExists(Exception): } }, } + super().__init__( + status_code=409, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) -class AccessTokenInvalidError(Exception): +class AccessTokenInvalidError(HTTPException): """Raises HTTP 401 if access token is not valid.""" def __init__(self): @@ -190,9 +246,13 @@ class AccessTokenInvalidError(Exception): "application/json": {"example": {"detail": "Invalid access token."}} }, } + super().__init__( + status_code=401, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) -class UserCredentialsInvalid(Exception): +class UserCredentialsInvalid(HTTPException): """Raises HTTP 401 if user credentials are not valid.""" def __init__(self): @@ -202,3 +262,7 @@ class UserCredentialsInvalid(Exception): "application/json": {"example": {"detail": "Invalid credentials."}} }, } + super().__init__( + status_code=401, + detail=self.openapi["content"]["application/json"]["example"]["detail"], + ) diff --git a/classes/models.py b/classes/models.py index 5e072b8..9ce232a 100644 --- a/classes/models.py +++ b/classes/models.py @@ -1,4 +1,5 @@ from typing import List, Union + from pydantic import BaseModel diff --git a/extensions/exceptions.py b/extensions/exceptions.py index 2bfda00..d8e7364 100644 --- a/extensions/exceptions.py +++ b/extensions/exceptions.py @@ -1,7 +1,5 @@ from fastapi import Request from fastapi.responses import UJSONResponse -from modules.app import app -from classes.exceptions import * from starlette.status import ( HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, @@ -11,6 +9,23 @@ from starlette.status import ( HTTP_422_UNPROCESSABLE_ENTITY, ) +from classes.exceptions import ( + AlbumNotFoundError, + AlbumAlreadyExistsError, + AlbumIncorrectError, + PhotoNotFoundError, + PhotoSearchQueryEmptyError, + VideoNotFoundError, + VideoSearchQueryEmptyError, + SearchPageInvalidError, + SearchTokenInvalidError, + AccessTokenInvalidError, + UserEmailCodeInvalid, + UserAlreadyExists, + UserCredentialsInvalid, +) +from modules.app import app + @app.exception_handler(AlbumNotFoundError) async def album_not_found_exception_handler(request: Request, exc: AlbumNotFoundError): diff --git a/extensions/pages.py b/extensions/pages.py index b712a84..d32dad6 100644 --- a/extensions/pages.py +++ b/extensions/pages.py @@ -1,31 +1,42 @@ from os import path -from modules.app import app + +import aiofiles from fastapi.responses import HTMLResponse, Response +from modules.app import app + @app.get("/pages/matter.css", include_in_schema=False) async def page_matter(): - with open(path.join("pages", "matter.css"), "r", encoding="utf-8") as f: - output = f.read() + async with aiofiles.open( + path.join("pages", "matter.css"), "r", encoding="utf-8" + ) as f: + output = await f.read() return Response(content=output) @app.get("/pages/{page}/{file}", include_in_schema=False) async def page_assets(page: str, file: str): - with open(path.join("pages", page, file), "r", encoding="utf-8") as f: - output = f.read() + async with aiofiles.open( + path.join("pages", page, file), "r", encoding="utf-8" + ) as f: + output = await f.read() return Response(content=output) @app.get("/", include_in_schema=False) async def page_home(): - with open(path.join("pages", "home", "index.html"), "r", encoding="utf-8") as f: - output = f.read() + async with aiofiles.open( + path.join("pages", "home", "index.html"), "r", encoding="utf-8" + ) as f: + output = await f.read() return HTMLResponse(content=output) @app.get("/register", include_in_schema=False) async def page_register(): - with open(path.join("pages", "register", "index.html"), "r", encoding="utf-8") as f: - output = f.read() + async with aiofiles.open( + path.join("pages", "register", "index.html"), "r", encoding="utf-8" + ) as f: + output = await f.read() return HTMLResponse(content=output) diff --git a/extensions/photos.py b/extensions/photos.py index 3077ada..1ca11c0 100644 --- a/extensions/photos.py +++ b/extensions/photos.py @@ -1,15 +1,24 @@ import re -import pickle +from datetime import datetime, timedelta, timezone +from os import makedirs, path, remove, system from secrets import token_urlsafe from shutil import move from threading import Thread from typing import Union from uuid import uuid4 -from magic import Magic -from datetime import datetime, timedelta, timezone -from os import makedirs, path, remove, system +import aiofiles +from bson.errors import InvalidId +from bson.objectid import ObjectId +from fastapi import Security, UploadFile +from fastapi.responses import Response, UJSONResponse +from jose import JWTError, jwt +from magic import Magic +from plum.exceptions import UnpackError from pydantic import ValidationError +from pymongo import DESCENDING +from starlette.status import HTTP_204_NO_CONTENT, HTTP_409_CONFLICT + from classes.exceptions import ( AccessTokenInvalidError, AlbumNameNotFoundError, @@ -19,8 +28,10 @@ from classes.exceptions import ( SearchTokenInvalidError, ) from classes.models import Photo, PhotoPublic, SearchResultsPhoto +from modules.app import app +from modules.database import col_albums, col_photos, col_tokens from modules.exif_reader import extract_location -from modules.hasher import get_phash, get_duplicates +from modules.hasher import get_duplicates, get_phash from modules.scheduler import scheduler from modules.security import ( ALGORITHM, @@ -31,23 +42,6 @@ from modules.security import ( get_current_active_user, get_user, ) -from modules.app import app -from modules.database import col_photos, col_albums, col_tokens -from pymongo import DESCENDING -from bson.objectid import ObjectId -from bson.errors import InvalidId -from plum.exceptions import UnpackError -from jose import JWTError, jwt - -from fastapi import UploadFile, Security -from fastapi.responses import UJSONResponse, Response -from fastapi.exceptions import HTTPException -from starlette.status import ( - HTTP_204_NO_CONTENT, - HTTP_401_UNAUTHORIZED, - HTTP_409_CONFLICT, -) - from modules.utils import configGet, logWrite @@ -130,7 +124,7 @@ async def photo_upload( ".".join(base_name) + f"_{int(datetime.now().timestamp())}." + extension ) - with open( + async with aiofiles.open( path.join("data", "users", current_user.user, "albums", album, filename), "wb" ) as f: f.write(await file.read()) @@ -266,16 +260,24 @@ if configGet("media_token_access") is True: mime = Magic(mime=True).from_file(image_path) - with open(image_path, "rb") as f: - image_file = f.read() + async with aiofiles.open(image_path, "rb") as f: + image_file = await f.read() return Response(image_file, media_type=mime) -photo_get_responses = {404: PhotoNotFoundError("id").openapi} +photo_get_responses = { + 200: {"content": {"image/*": {}}}, + 404: PhotoNotFoundError("id").openapi, +} -@app.get("/photos/{id}", description="Get a photo by id", responses=photo_get_responses) +@app.get( + "/photos/{id}", + description="Get a photo by id", + responses=photo_get_responses, + response_class=Response, +) async def photo_get( id: str, current_user: User = Security(get_current_active_user, scopes=["photos.read"]), @@ -293,8 +295,8 @@ async def photo_get( mime = Magic(mime=True).from_file(image_path) - with open(image_path, "rb") as f: - image_file = f.read() + async with aiofiles.open(image_path, "rb") as f: + image_file = await f.read() return Response(image_file, media_type=mime) diff --git a/extensions/security.py b/extensions/security.py index 8590fc6..7b7db55 100644 --- a/extensions/security.py +++ b/extensions/security.py @@ -1,12 +1,10 @@ from datetime import timedelta -from classes.exceptions import UserCredentialsInvalid -from modules.app import app from fastapi import Depends -from fastapi.security import ( - OAuth2PasswordRequestForm, -) +from fastapi.security import OAuth2PasswordRequestForm +from classes.exceptions import UserCredentialsInvalid +from modules.app import app from modules.security import ( ACCESS_TOKEN_EXPIRE_DAYS, Token, diff --git a/extensions/users.py b/extensions/users.py index 81820df..6371d8f 100644 --- a/extensions/users.py +++ b/extensions/users.py @@ -1,27 +1,19 @@ from datetime import datetime, timedelta +from uuid import uuid1 + +from fastapi import Depends, Form +from fastapi.responses import Response, UJSONResponse +from starlette.status import HTTP_204_NO_CONTENT + from classes.exceptions import ( UserAlreadyExists, UserCredentialsInvalid, UserEmailCodeInvalid, ) -from modules.database import ( - col_users, - col_albums, - col_photos, - col_emails, - col_videos, - col_emails, -) from modules.app import app -from modules.utils import configGet, logWrite -from modules.scheduler import scheduler +from modules.database import col_albums, col_emails, col_photos, col_users, col_videos from modules.mailer import mail_sender - -from uuid import uuid1 -from fastapi import Depends, Form -from fastapi.responses import Response, UJSONResponse -from starlette.status import HTTP_204_NO_CONTENT - +from modules.scheduler import scheduler from modules.security import ( User, get_current_active_user, @@ -29,6 +21,7 @@ from modules.security import ( get_user, verify_password, ) +from modules.utils import configGet, logWrite async def send_confirmation(user: str, email: str): diff --git a/extensions/videos.py b/extensions/videos.py index e19d2d7..12fd20b 100644 --- a/extensions/videos.py +++ b/extensions/videos.py @@ -1,11 +1,19 @@ import re -import pickle +from datetime import datetime, timezone +from os import makedirs, path, remove from secrets import token_urlsafe from shutil import move from typing import Union + +import aiofiles +from bson.errors import InvalidId +from bson.objectid import ObjectId +from fastapi import Security, UploadFile +from fastapi.responses import Response, UJSONResponse from magic import Magic -from datetime import datetime, timezone -from os import makedirs, path, remove +from pymongo import DESCENDING +from starlette.status import HTTP_204_NO_CONTENT + from classes.exceptions import ( AlbumNameNotFoundError, SearchPageInvalidError, @@ -13,17 +21,10 @@ from classes.exceptions import ( VideoNotFoundError, VideoSearchQueryEmptyError, ) -from classes.models import Video, SearchResultsVideo, VideoPublic -from modules.security import User, get_current_active_user +from classes.models import SearchResultsVideo, Video, VideoPublic from modules.app import app -from modules.database import col_videos, col_albums, col_tokens -from bson.objectid import ObjectId -from bson.errors import InvalidId -from pymongo import DESCENDING - -from fastapi import UploadFile, Security -from fastapi.responses import UJSONResponse, Response -from starlette.status import HTTP_204_NO_CONTENT +from modules.database import col_albums, col_tokens, col_videos +from modules.security import User, get_current_active_user video_post_responses = {404: AlbumNameNotFoundError("name").openapi} @@ -59,10 +60,10 @@ async def video_upload( ".".join(base_name) + f"_{int(datetime.now().timestamp())}." + extension ) - with open( + async with aiofiles.open( path.join("data", "users", current_user.user, "albums", album, filename), "wb" ) as f: - f.write(await file.read()) + await f.write(await file.read()) # Hashing and duplicates check should be here @@ -91,10 +92,18 @@ async def video_upload( ) -video_get_responses = {404: VideoNotFoundError("id").openapi} +video_get_responses = { + 200: {"content": {"video/*": {}}}, + 404: VideoNotFoundError("id").openapi, +} -@app.get("/videos/{id}", description="Get a video by id", responses=video_get_responses) +@app.get( + "/videos/{id}", + description="Get a video by id", + responses=video_get_responses, + response_class=Response, +) async def video_get( id: str, current_user: User = Security(get_current_active_user, scopes=["videos.read"]), @@ -112,10 +121,10 @@ async def video_get( mime = Magic(mime=True).from_file(video_path) - with open(video_path, "rb") as f: - video_file = f.read() + async with aiofiles.open(video_path, "rb") as f: + video_file = await f.read() - return Response(video_file, media_type=mime) + return Response(content=video_file, media_type=mime) video_move_responses = {404: VideoNotFoundError("id").openapi} diff --git a/modules/app.py b/modules/app.py index c7b8ca7..0117169 100644 --- a/modules/app.py +++ b/modules/app.py @@ -1,6 +1,5 @@ from fastapi import FastAPI -from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html - +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html app = FastAPI(title="END PLAY Photos", docs_url=None, redoc_url=None, version="0.2") diff --git a/modules/database.py b/modules/database.py index 46a18e4..964cf54 100644 --- a/modules/database.py +++ b/modules/database.py @@ -1,5 +1,6 @@ +from pymongo import GEOSPHERE, MongoClient + from modules.utils import configGet -from pymongo import MongoClient, GEOSPHERE db_config = configGet("database") diff --git a/modules/hasher.py b/modules/hasher.py index 3ad56e9..afd2389 100644 --- a/modules/hasher.py +++ b/modules/hasher.py @@ -1,8 +1,9 @@ -from modules.database import col_photos +import cv2 import numpy as np from numpy.typing import NDArray from scipy import spatial -import cv2 + +from modules.database import col_photos def hash_array_to_hash_hex(hash_array): diff --git a/modules/mailer.py b/modules/mailer.py index d637fb6..0ff0927 100644 --- a/modules/mailer.py +++ b/modules/mailer.py @@ -1,6 +1,7 @@ from smtplib import SMTP, SMTP_SSL -from traceback import print_exc from ssl import create_default_context +from traceback import print_exc + from modules.utils import configGet, logWrite try: diff --git a/modules/security.py b/modules/security.py index 8703ce6..5ae9191 100644 --- a/modules/security.py +++ b/modules/security.py @@ -1,16 +1,13 @@ from datetime import datetime, timedelta, timezone from typing import List, Union -from modules.database import col_users from fastapi import Depends, HTTPException, Security, status -from fastapi.security import ( - OAuth2PasswordBearer, - SecurityScopes, -) +from fastapi.security import OAuth2PasswordBearer, SecurityScopes from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel, ValidationError +from modules.database import col_users with open("secret_key", "r", encoding="utf-8") as f: SECRET_KEY = f.read() diff --git a/modules/utils.py b/modules/utils.py index 7163ffa..1cf907e 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1,6 +1,7 @@ -from typing import Any, Union -from ujson import loads, dumps, JSONDecodeError from traceback import print_exc +from typing import Any, Union + +from ujson import JSONDecodeError, dumps, loads # Print to stdout and then to log diff --git a/photos_api.py b/photos_api.py index e359c90..714924b 100644 --- a/photos_api.py +++ b/photos_api.py @@ -1,10 +1,12 @@ from os import makedirs, path -from modules.app import app -from modules.utils import * -from modules.scheduler import scheduler -from modules.extensions_loader import dynamic_import_from_src + from fastapi.responses import FileResponse +from modules.app import app +from modules.extensions_loader import dynamic_import_from_src +from modules.scheduler import scheduler +from modules.utils import * + makedirs(path.join("data", "users"), exist_ok=True) diff --git a/requirements.txt b/requirements.txt index 8019647..a9f3be1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ -fastapi[all]==0.97.0 -pymongo==4.4.0 -ujson~=5.8.0 -scipy~=1.10.1 -python-magic~=0.4.27 -opencv-python~=4.7.0.72 -python-jose[cryptography]~=3.3.0 -passlib~=1.7.4 +aiofiles==23.1.0 apscheduler~=3.10.1 -exif==1.6.0 \ No newline at end of file +exif==1.6.0 +fastapi[all]==0.97.0 +opencv-python~=4.7.0.72 +passlib~=1.7.4 +pymongo==4.4.0 +python-jose[cryptography]~=3.3.0 +python-magic~=0.4.27 +scipy~=1.10.1 +ujson~=5.8.0 \ No newline at end of file