From 1da075f48f7afc1ca89157f02c55e7a0fc34b6ee Mon Sep 17 00:00:00 2001 From: Nocturn9x Date: Thu, 6 Oct 2022 12:27:53 +0200 Subject: [PATCH] Added media endpoints, various style modifications and minor fixes, added API.md --- API.md | 6 + endpoints/media.py | 54 ++++ endpoints/users.py | 529 +++++++++++++++++++++---------------- main.py | 9 +- orm/media.py | 16 +- responses/media.py | 12 + util/exception_handlers.py | 30 ++- 7 files changed, 419 insertions(+), 237 deletions(-) create mode 100644 API.md create mode 100644 endpoints/media.py create mode 100644 responses/media.py diff --git a/API.md b/API.md new file mode 100644 index 0000000..271e6ea --- /dev/null +++ b/API.md @@ -0,0 +1,6 @@ +# PySimpleSocial - API Documentation + +## Disclaimer + +This is a technical page that only describes the API's functionality. For information about licensing, +setup and credits, please check out the README.md file included with the project. \ No newline at end of file diff --git a/endpoints/media.py b/endpoints/media.py new file mode 100644 index 0000000..db10409 --- /dev/null +++ b/endpoints/media.py @@ -0,0 +1,54 @@ +from fastapi import Request, APIRouter as FastAPI, Depends +from fastapi.exceptions import HTTPException +from responses.media import MediaResponse +from responses import UnprocessableEntity, Response as APIResponse, NotFound +from config import MANAGER, LIMITER +from orm.media import Media, PublicMediaModel +from orm.users import UserModel + +router = FastAPI() + + +@router.get("/media/{media_id}", + tags=["Media"], + status_code=200, + responses={ + 200: {"model": MediaResponse}, + 422: {"model": UnprocessableEntity}, + 404: {"model": NotFound} + }, + ) +@LIMITER.limit("2/second") +async def get_media(request: Request, media_id: str, _user: UserModel = Depends(MANAGER)): + """ + Gets a media object by its ID + """ + + if (m := await Media.select(Media.media_id).where(Media.media_id == media_id).first()) is None: + raise HTTPException(status_code=404, detail="Media not found") + m = Media(**m) + return MediaResponse(data=PublicMediaModel(media_id=m.media_id, content=m.content, content_type=m.content_type, + creation_date=m.creation_date)) + + +@router.get("/media/{media_id}/report", + tags=["Media"], + status_code=200, + responses={ + 200: {"model": APIResponse}, + 422: {"model": UnprocessableEntity}, + 404: {"model": NotFound} + }, + ) +@LIMITER.limit("2/second") +async def report_media(request: Request, media_id: str, _user: UserModel = Depends(MANAGER)): + """ + Reports a piece of media by its ID. This creates + a report that can be seen by admins, which can + then decide what to do + """ + + if (m := await Media.select(Media.media_id).where(Media.media_id == media_id).first()) is None: + raise HTTPException(status_code=404, detail="Media not found") + # TODO: Create report + return APIResponse(msg="Success") diff --git a/endpoints/users.py b/endpoints/users.py index c53887b..12ce9e5 100644 --- a/endpoints/users.py +++ b/endpoints/users.py @@ -50,8 +50,15 @@ from config import ( FORCE_EMAIL_VERIFICATION, UNVERIFIED_MANAGER, ) -from responses import Response as APIResponse, UnprocessableEntity, BadRequest, NotFound, \ - MediaTypeNotAcceptable, PayloadTooLarge, InternalServerError +from responses import ( + Response as APIResponse, + UnprocessableEntity, + BadRequest, + NotFound, + MediaTypeNotAcceptable, + PayloadTooLarge, + InternalServerError, +) from responses.users import ( PrivateUserResponse, PublicUserResponse, @@ -88,12 +95,21 @@ async def get_self_by_id_unverified(public_id: UUID) -> UserModel: # Here follow our *beautifully* documented path operations -@router.post("/user", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 400: {"model": BadRequest}, - 422: {"model": UnprocessableEntity}}) + +@router.post( + "/user", + tags=["Users"], + status_code=200, + responses={ + 200: {"model": APIResponse}, + 400: {"model": BadRequest}, + 422: {"model": UnprocessableEntity}, + }, +) @LIMITER.limit("5/minute") -async def login(request: Request, response: Response, data: OAuth2PasswordRequestForm = Depends()): +async def login( + request: Request, response: Response, data: OAuth2PasswordRequestForm = Depends() +): """ Performs user authentication. Endpoint is limited to 5 hits per minute """ @@ -120,9 +136,9 @@ async def login(request: Request, response: Response, data: OAuth2PasswordReques detail="Authentication failed: invalid characters in password", ) if not ( - user := await get_user_by_username( - username, include_secrets=True, restricted_ok=True - ) + user := await get_user_by_username( + username, include_secrets=True, restricted_ok=True + ) ): raise HTTPException( status_code=413, @@ -134,7 +150,8 @@ async def login(request: Request, response: Response, data: OAuth2PasswordReques detail="Authentication failed: password mismatch", ) token = MANAGER.create_access_token( - expires=timedelta(seconds=SESSION_EXPIRE_LIMIT), data={"sub": str(user.public_id)} + expires=timedelta(seconds=SESSION_EXPIRE_LIMIT), + data={"sub": str(user.public_id)}, ) response.set_cookie( secure=SECURE_COOKIE, @@ -149,11 +166,16 @@ async def login(request: Request, response: Response, data: OAuth2PasswordReques return APIResponse(status_code=200, msg="Authentication successful") -@router.get("/user/logout", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 422: {"model": UnprocessableEntity}}) +@router.get( + "/user/logout", + tags=["Users"], + status_code=200, + responses={200: {"model": APIResponse}, 422: {"model": UnprocessableEntity}}, +) @LIMITER.limit("5/minute") -async def logout(request: Request, response: Response, _user: UserModel = Depends(UNVERIFIED_MANAGER)): +async def logout( + request: Request, response: Response, _user: UserModel = Depends(UNVERIFIED_MANAGER) +): """ Deletes a user's session cookie, logging them out. Endpoint is limited to 5 hits per minute @@ -174,8 +196,13 @@ async def logout(request: Request, response: Response, _user: UserModel = Depend "/user/me", tags=["Users"], status_code=200, - responses={200: {"model": PrivateUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, - 422: {"model": UnprocessableEntity}}, + responses={ + 200: { + "model": PrivateUserResponse, + "exclude": {"password_hash", "internal_id", "deleted"}, + }, + 422: {"model": UnprocessableEntity}, + }, ) @LIMITER.limit("2/second") async def get_self(request: Request, user: UserModel = Depends(UNVERIFIED_MANAGER)): @@ -194,14 +221,18 @@ async def get_self(request: Request, user: UserModel = Depends(UNVERIFIED_MANAGE "/user/username/{username}", tags=["Users"], status_code=200, - responses={200: {"model": PublicUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, - 404: {"model": NotFound}, - 422: {"model": UnprocessableEntity} - }, + responses={ + 200: { + "model": PublicUserResponse, + "exclude": {"password_hash", "internal_id", "deleted"}, + }, + 404: {"model": NotFound}, + 422: {"model": UnprocessableEntity}, + }, ) @LIMITER.limit("30/second") async def get_user_by_name( - request: Request, username: str, _auth: UserModel = Depends(MANAGER) + request: Request, username: str, _auth: UserModel = Depends(MANAGER) ): """ Fetches a single user by its public username @@ -216,31 +247,37 @@ async def get_user_by_name( "/user/id/{public_id}", tags=["Users"], status_code=200, - responses={200: {"model": PublicUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, - 404: {"model": NotFound}, - 422: {"model": UnprocessableEntity} - }, + responses={ + 200: { + "model": PublicUserResponse, + "exclude": {"password_hash", "internal_id", "deleted"}, + }, + 404: {"model": NotFound}, + 422: {"model": UnprocessableEntity}, + }, ) @LIMITER.limit("30/second") async def get_user_by_public_id( - request: Request, public_id: str, _auth: UserModel = Depends(MANAGER) + request: Request, public_id: str, _auth: UserModel = Depends(MANAGER) ): """ Fetches a single user by its public ID """ if not (user := await get_user_by_id(UUID(public_id))): - raise HTTPException(status_code=404, detail="Lookup failed: the user does not exist") + raise HTTPException( + status_code=404, detail="Lookup failed: the user does not exist" + ) return PublicUserResponse(data=user) async def validate_user( - first_name: str | None, - last_name: str | None, - username: str | None, - email: str | None, - password: str | None, - bio: str | None, + first_name: str | None, + last_name: str | None, + username: str | None, + email: str | None, + password: str | None, + bio: str | None, ): """ Performs some validation upon user creation. Returns @@ -261,9 +298,9 @@ async def validate_user( if username and len(username) > 32: return False, "username is too long" if ( - username - and VALIDATE_USERNAME_REGEX - and not re.match(VALIDATE_USERNAME_REGEX, username) + username + and VALIDATE_USERNAME_REGEX + and not re.match(VALIDATE_USERNAME_REGEX, username) ): return False, "username is invalid" if email and not validators.email(email): @@ -271,13 +308,13 @@ async def validate_user( if password and len(password) > 72: return False, "password is too long" if ( - password - and VALIDATE_PASSWORD_REGEX - and not re.match(VALIDATE_PASSWORD_REGEX, password) + password + and VALIDATE_PASSWORD_REGEX + and not re.match(VALIDATE_PASSWORD_REGEX, password) ): return False, "password is too weak" if username and await get_user_by_username( - username, deleted_ok=True, restricted_ok=True + username, deleted_ok=True, restricted_ok=True ): return False, "username is already taken" if email and await get_user_by_email(email, deleted_ok=True, restricted_ok=True): @@ -292,12 +329,15 @@ async def validate_user( return True, "" -@router.delete("/user", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 422: {"model": UnprocessableEntity}}) +@router.delete( + "/user", + tags=["Users"], + status_code=200, + responses={200: {"model": APIResponse}, 422: {"model": UnprocessableEntity}}, +) @LIMITER.limit("1/minute") async def delete( - request: Request, response: Response, user: UserModel = Depends(UNVERIFIED_MANAGER) + request: Request, response: Response, user: UserModel = Depends(UNVERIFIED_MANAGER) ): """ Sets the user's deleted flag in the database, @@ -317,25 +357,33 @@ async def delete( return APIResponse(status_code=200, msg="Success") -@router.get("/user/verifyEmail/{verification_id}", tags=["Users"], status_code=200, - responses={200: {"model": PrivateUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, - 404: {"model": NotFound}, - 422: {"model": UnprocessableEntity} - }) +@router.get( + "/user/verifyEmail/{verification_id}", + tags=["Users"], + status_code=200, + responses={ + 200: { + "model": PrivateUserResponse, + "exclude": {"password_hash", "internal_id", "deleted"}, + }, + 404: {"model": NotFound}, + 422: {"model": UnprocessableEntity}, + }, +) @LIMITER.limit("3/second") async def verify_email( - request: Request, - verification_id: str, - user: UserModel = Depends(UNVERIFIED_MANAGER), + request: Request, + verification_id: str, + user: UserModel = Depends(UNVERIFIED_MANAGER), ): """ Verifies a user's email address """ if not ( - verification := await EmailVerification.select(*EmailVerification.all_columns()) - .where(EmailVerification.id == verification_id) - .first() + verification := await EmailVerification.select(*EmailVerification.all_columns()) + .where(EmailVerification.id == verification_id) + .first() ): raise HTTPException(status_code=404, detail="Verification ID is invalid") elif not verification["pending"]: @@ -351,29 +399,37 @@ async def verify_email( await EmailVerification.update({EmailVerification.pending: False}).where( EmailVerification.user == user.public_id ) - await User.update({User.email_verified: True}).where(User.public_id == user.public_id) + await User.update({User.email_verified: True}).where( + User.public_id == user.public_id + ) return APIResponse(status_code=200, msg="Verification successful") -@router.get("/user/resetPassword/{verification_id}", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 400: {"model": BadRequest}, - 422: {"model": UnprocessableEntity}, - 404: {"model": NotFound}}) +@router.get( + "/user/resetPassword/{verification_id}", + tags=["Users"], + status_code=200, + responses={ + 200: {"model": APIResponse}, + 400: {"model": BadRequest}, + 422: {"model": UnprocessableEntity}, + 404: {"model": NotFound}, + }, +) @LIMITER.limit("3/second") async def reset_password( - request: Request, - verification_id: str, - user: UserModel = Depends(UNVERIFIED_MANAGER), + request: Request, + verification_id: str, + user: UserModel = Depends(UNVERIFIED_MANAGER), ): """ Modifies a user's password """ if not ( - verification := await EmailVerification.select(*EmailVerification.all_columns()) - .where(EmailVerification.id == verification_id) - .first() + verification := await EmailVerification.select(*EmailVerification.all_columns()) + .where(EmailVerification.id == verification_id) + .first() ): raise HTTPException(status_code=404, detail="Request ID is invalid") elif not verification["pending"]: @@ -395,25 +451,31 @@ async def reset_password( return APIResponse(status_code=200, msg="Password updated") -@router.get("/user/changeEmail/{verification_id}", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 400: {"model": BadRequest}, - 422: {"model": UnprocessableEntity}, - 404: {"model": NotFound}}) +@router.get( + "/user/changeEmail/{verification_id}", + tags=["Users"], + status_code=200, + responses={ + 200: {"model": APIResponse}, + 400: {"model": BadRequest}, + 422: {"model": UnprocessableEntity}, + 404: {"model": NotFound}, + }, +) @LIMITER.limit("3/second") async def change_email( - request: Request, - verification_id: str, - user: UserModel = Depends(UNVERIFIED_MANAGER), + request: Request, + verification_id: str, + user: UserModel = Depends(UNVERIFIED_MANAGER), ): """ Modifies a user's email """ if not ( - verification := await EmailVerification.select(*EmailVerification.all_columns()) - .where(EmailVerification.id == verification_id) - .first() + verification := await EmailVerification.select(*EmailVerification.all_columns()) + .where(EmailVerification.id == verification_id) + .first() ): raise HTTPException(status_code=404, detail="Request ID is invalid") elif not verification["pending"]: @@ -438,11 +500,17 @@ async def change_email( return APIResponse(status_code=200, msg="Email updated") -@router.put("user/resendMail", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 400: {"model": BadRequest}, - 422: {"model": UnprocessableEntity}, - 500: {"model": InternalServerError}}) +@router.put( + "user/resendMail", + tags=["Users"], + status_code=200, + responses={ + 200: {"model": APIResponse}, + 400: {"model": BadRequest}, + 422: {"model": UnprocessableEntity}, + 500: {"model": InternalServerError}, + }, +) @LIMITER.limit("6/minute") async def resend_email(request: Request, user: UserModel = Depends(UNVERIFIED_MANAGER)): """ @@ -460,30 +528,30 @@ async def resend_email(request: Request, user: UserModel = Depends(UNVERIFIED_MA email_message = json.load(f)["signup"] verification_id = uuid.uuid4() if await send_email( - SMTP_HOST, - SMTP_PORT, - email_message["content"].format( - first_name=user["first_name"], - last_name=user["last_name"], - username=user["username"], - email=user["email_address"], - link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" - f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}", - platformName=PLATFORM_NAME, - ), - SMTP_TIMEOUT, - SMTP_FROM_USER, - user["email_address"], - email_message["subject"].format( - first_name=user["first_name"], - last_name=user["last_name"], - username=user["username"], - email=user["email_address"], - platformName=PLATFORM_NAME, - ), - SMTP_USER, - SMTP_PASSWORD, - use_tls=SMTP_USE_TLS, + SMTP_HOST, + SMTP_PORT, + email_message["content"].format( + first_name=user["first_name"], + last_name=user["last_name"], + username=user["username"], + email=user["email_address"], + link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" + f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}", + platformName=PLATFORM_NAME, + ), + SMTP_TIMEOUT, + SMTP_FROM_USER, + user["email_address"], + email_message["subject"].format( + first_name=user["first_name"], + last_name=user["last_name"], + username=user["username"], + email=user["email_address"], + platformName=PLATFORM_NAME, + ), + SMTP_USER, + SMTP_PASSWORD, + use_tls=SMTP_USE_TLS, ): await EmailVerification.update( { @@ -493,26 +561,35 @@ async def resend_email(request: Request, user: UserModel = Depends(UNVERIFIED_MA ) return APIResponse(status_code=200, msg="Success") else: - raise HTTPException(status_code=500, detail="An error occurred while trying to resend the email," - " please try again later") + raise HTTPException( + status_code=500, + detail="An error occurred while trying to resend the email," + " please try again later", + ) -@router.put("/user", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 400: {"model": BadRequest}, - 422: {"model": UnprocessableEntity}, - 500: {"model": InternalServerError}, - 413: {"model": PayloadTooLarge}}) +@router.put( + "/user", + tags=["Users"], + status_code=200, + responses={ + 200: {"model": APIResponse}, + 400: {"model": BadRequest}, + 422: {"model": UnprocessableEntity}, + 500: {"model": InternalServerError}, + 413: {"model": PayloadTooLarge}, + }, +) @LIMITER.limit("2/minute") async def signup( - request: Request, - first_name: str, - last_name: str, - username: str, - email: str, - password: str, - bio: str | None = None, - locale: str = "en_US", + request: Request, + first_name: str, + last_name: str, + username: str, + email: str, + password: str, + bio: str | None = None, + locale: str = "en_US", ): """ Endpoint used to create new users @@ -546,30 +623,30 @@ async def signup( email_message = json.load(f)["signup"] verification_id = uuid.uuid4() if await send_email( - SMTP_HOST, - SMTP_PORT, - email_message["content"].format( - first_name=first_name, - last_name=last_name, - username=username, - email=email, - link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" - f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}", - platformName=PLATFORM_NAME, - ), - SMTP_TIMEOUT, - SMTP_FROM_USER, - email, - email_message["subject"].format( - first_name=first_name, - last_name=last_name, - username=username, - email=email, - platformName=PLATFORM_NAME, - ), - SMTP_USER, - SMTP_PASSWORD, - use_tls=SMTP_USE_TLS, + SMTP_HOST, + SMTP_PORT, + email_message["content"].format( + first_name=first_name, + last_name=last_name, + username=username, + email=email, + link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" + f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}", + platformName=PLATFORM_NAME, + ), + SMTP_TIMEOUT, + SMTP_FROM_USER, + email, + email_message["subject"].format( + first_name=first_name, + last_name=last_name, + username=username, + email=email, + platformName=PLATFORM_NAME, + ), + SMTP_USER, + SMTP_PASSWORD, + use_tls=SMTP_USE_TLS, ): await User.insert(user) await EmailVerification.insert( @@ -585,12 +662,12 @@ async def signup( raise HTTPException( status_code=500, detail="An error occurred while sending verification email, please" - " try again later", + " try again later", ) async def validate_profile_picture( - file: UploadFile, + file: UploadFile, ) -> tuple[bool | None, str, bytes, str]: """ Validates a profile picture's size and content to see if it fits @@ -619,23 +696,30 @@ async def validate_profile_picture( return None, "", b"", "" -@router.patch("/user", tags=["Users"], status_code=200, - responses={200: {"model": APIResponse}, - 400: {"model": BadRequest}, - 422: {"model": UnprocessableEntity}, - 500: {"model": InternalServerError}, - 413: {"model": PayloadTooLarge}}) +@router.patch( + "/user", + tags=["Users"], + status_code=200, + responses={ + 200: {"model": APIResponse}, + 400: {"model": BadRequest}, + 422: {"model": UnprocessableEntity}, + 500: {"model": InternalServerError}, + 413: {"model": PayloadTooLarge}, + 415: {"model": MediaTypeNotAcceptable} + }, +) async def update_user( - request: Request, - user: UserModel = Depends(UNVERIFIED_MANAGER), - first_name: str | None = None, - last_name: str | None = None, - username: str | None = None, - profile_picture: UploadFile | None = None, - email_address: str | None = None, - password: str | None = None, - bio: str | None = None, - delete: bool = False, + request: Request, + user: UserModel = Depends(UNVERIFIED_MANAGER), + first_name: str | None = None, + last_name: str | None = None, + username: str | None = None, + profile_picture: UploadFile | None = None, + email_address: str | None = None, + password: str | None = None, + bio: str | None = None, + delete: bool = False, ): """ Updates a user's profile information. Parameters that are not specified are left unchanged unless @@ -650,7 +734,7 @@ async def update_user( """ if not delete and not any( - (first_name, last_name, username, profile_picture, email_address, bio, password) + (first_name, last_name, username, profile_picture, email_address, bio, password) ): raise HTTPException( status_code=400, detail="At least one value has to be specified" @@ -678,14 +762,7 @@ async def update_user( ) elif result is None: raise HTTPException(status_code=413, detail="The file is too large") - elif ( - await ( - old_media := Media.select(Media.media_id) - .where(Media.media_id == digest) - .first() - ) - is None - ): + elif (m := await Media.select(Media.media_id).where(Media.media_id == digest).first()) is None: # This media hasn't been already uploaded (either by this user or by someone # else), so we save it now. If it has been already uploaded, there's no need # to do it again (that's what the hash is for) @@ -726,30 +803,30 @@ async def update_user( email_message = json.load(f)["password_change"] verification_id = uuid.uuid4() if not await send_email( - SMTP_HOST, - SMTP_PORT, - email_message["content"].format( - first_name=user.first_name, - last_name=user.last_name, - username=user.username, - email=user.email_address, - link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" - f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/resetPassword/{verification_id}", - platformName=PLATFORM_NAME, - ), - SMTP_TIMEOUT, - SMTP_FROM_USER, - user.email_address, - email_message["subject"].format( - first_name=user.first_name, - last_name=user.last_name, - username=user.username, - email=user.email_address, - platformName=PLATFORM_NAME, - ), - SMTP_USER, - SMTP_PASSWORD, - use_tls=SMTP_USE_TLS, + SMTP_HOST, + SMTP_PORT, + email_message["content"].format( + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + email=user.email_address, + link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" + f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/resetPassword/{verification_id}", + platformName=PLATFORM_NAME, + ), + SMTP_TIMEOUT, + SMTP_FROM_USER, + user.email_address, + email_message["subject"].format( + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + email=user.email_address, + platformName=PLATFORM_NAME, + ), + SMTP_USER, + SMTP_PASSWORD, + use_tls=SMTP_USE_TLS, ): raise HTTPException( 500, @@ -782,31 +859,31 @@ async def update_user( email_message = json.load(f)["email_change"] verification_id = uuid.uuid4() if not await send_email( - SMTP_HOST, - SMTP_PORT, - email_message["content"].format( - first_name=user.first_name, - last_name=user.last_name, - username=user.username, - email=user.email_address, - platformName=PLATFORM_NAME, - link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" - f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/changeEmail/{verification_id}", - newMail=email_address, - ), - SMTP_TIMEOUT, - SMTP_FROM_USER, - user.email_address, - email_message["subject"].format( - first_name=user.first_name, - last_name=user.last_name, - username=user.username, - email=user.email_address, - platformName=PLATFORM_NAME, - ), - SMTP_USER, - SMTP_PASSWORD, - use_tls=SMTP_USE_TLS, + SMTP_HOST, + SMTP_PORT, + email_message["content"].format( + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + email=user.email_address, + platformName=PLATFORM_NAME, + link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" + f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/changeEmail/{verification_id}", + newMail=email_address, + ), + SMTP_TIMEOUT, + SMTP_FROM_USER, + user.email_address, + email_message["subject"].format( + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + email=user.email_address, + platformName=PLATFORM_NAME, + ), + SMTP_USER, + SMTP_PASSWORD, + use_tls=SMTP_USE_TLS, ): raise HTTPException( 500, @@ -830,8 +907,8 @@ async def update_user( fields = [] for field in user: if ( - field not in ["email_address", "password"] - and orig_user[field] != user[field] + field not in ["email_address", "password"] + and orig_user[field] != user[field] ): fields.append((field, user[field])) if fields: diff --git a/main.py b/main.py index b3e7d22..c472e44 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ from slowapi.errors import RateLimitExceeded from pathlib import Path -from endpoints import users +from endpoints import users, media from config import ( LOGGER, LIMITER, @@ -39,7 +39,7 @@ from util.exception_handlers import ( from util.email import test_smtp -with (Path(__file__).parent / "README.md").resolve(strict=True).open() as f: +with (Path(__file__).parent / "API.md").resolve(strict=True).open() as f: description = f.read() @@ -67,6 +67,10 @@ app = FastAPI( "name": "Posts", "description": "Endpoints that handle post creation, modification and deletion", }, + { + "name": "Media", + "description": "Endpoints that allow fetching individual media objects by their ID" + } ], ) @@ -140,6 +144,7 @@ if __name__ == "__main__": LOGGER.info("Backend starting up!") LOGGER.debug("Including modules") app.include_router(users.router) + app.include_router(media.router) app.state.limiter = LIMITER LOGGER.debug("Setting exception handlers") app.add_exception_handler(RateLimitExceeded, rate_limited) diff --git a/orm/media.py b/orm/media.py index e497b38..67851b0 100644 --- a/orm/media.py +++ b/orm/media.py @@ -4,7 +4,17 @@ Media relation from piccolo.table import Table from piccolo.utils.pydantic import create_pydantic_model -from piccolo.columns import Text, Boolean, Date, SmallInt, Varchar, Column, ForeignKey, OnUpdate, OnDelete +from piccolo.columns import ( + Text, + Boolean, + Date, + SmallInt, + Varchar, + Column, + ForeignKey, + OnUpdate, + OnDelete, +) from piccolo.columns.defaults.date import DateNow from enum import Enum, auto from typing import Any @@ -35,7 +45,9 @@ class Media(Table): MediaModel = create_pydantic_model(Media) -PublicMediaModel = create_pydantic_model(Media, exclude_columns=(Media.flagged, Media.deleted, Media.media_type)) +PublicMediaModel = create_pydantic_model( + Media, exclude_columns=(Media.flagged, Media.deleted, Media.media_type) +) async def get_media_by_column( diff --git a/responses/media.py b/responses/media.py new file mode 100644 index 0000000..6eb52c6 --- /dev/null +++ b/responses/media.py @@ -0,0 +1,12 @@ +from responses import Response +from orm.media import PublicMediaModel + + +class MediaResponse(Response): + """ + A media response object + """ + + status_code: int = 200 + msg: str = "Lookup successful" + data: PublicMediaModel diff --git a/util/exception_handlers.py b/util/exception_handlers.py index 3f2963d..cbf093d 100644 --- a/util/exception_handlers.py +++ b/util/exception_handlers.py @@ -21,8 +21,13 @@ async def rate_limited(request: Request, error: RateLimitExceeded) -> JSONRespon f"{request.client.host} got rate-limited at {str(request.url)} " f"(exceeded {error.detail})" ) - return JSONResponse(status_code=200, content=dict(status_code=429, - msg=f"Too many requests, retry after {error.detail[error.detail.find('per') + 4:]}")) + return JSONResponse( + status_code=200, + content=dict( + status_code=429, + msg=f"Too many requests, retry after {error.detail[error.detail.find('per') + 4:]}", + ), + ) def not_authenticated(request: Request, _: NotAuthenticated) -> JSONResponse: @@ -31,7 +36,9 @@ def not_authenticated(request: Request, _: NotAuthenticated) -> JSONResponse: """ LOGGER.info(f"{request.client.host} failed to authenticate at {str(request.url)}") - return JSONResponse(status_code=200, content=dict(status_code=401, msg="Authentication is required")) + return JSONResponse( + status_code=200, content=dict(status_code=401, msg="Authentication is required") + ) def request_invalid(request: Request, exc: RequestValidationError) -> JSONResponse: @@ -42,7 +49,10 @@ def request_invalid(request: Request, exc: RequestValidationError) -> JSONRespon LOGGER.info( f"{request.client.host} sent an invalid request at {request.url!r}: {type(exc).__name__}: {exc}" ) - return JSONResponse(status_code=200, content=dict(status_code=400, msg=f"Bad request: {type(exc).__name__}: {exc}")) + return JSONResponse( + status_code=200, + content=dict(status_code=400, msg=f"Bad request: {type(exc).__name__}: {exc}"), + ) def http_exception( @@ -58,12 +68,16 @@ def http_exception( f"{request.client.host} raised a {exc.status_code} error at {request.url!r}:" f"{type(exc).__name__}: {exc}" ) - return JSONResponse(status_code=200, content=dict(status_code=500, msg="Internal Server Error")) + return JSONResponse( + status_code=200, content=dict(status_code=500, msg="Internal Server Error") + ) else: LOGGER.info( f"{request.client.host} raised an HTTP error ({exc.status_code}) at {str(request.url)}" ) - return JSONResponse(status_code=200, content=dict(status_code=exc.status_code, msg=exc.detail)) + return JSONResponse( + status_code=200, content=dict(status_code=exc.status_code, msg=exc.detail) + ) async def generic_error(request: Request, exc: Exception) -> JSONResponse: @@ -75,4 +89,6 @@ async def generic_error(request: Request, exc: Exception) -> JSONResponse: f"{request.client.host} raised an unexpected error ({type(exc).__name__}: {exc}) at {str(request.url)}" ) # We can't leak anything about the error, it would be too risky - return JSONResponse(status_code=200, content=dict(status_code=500, msg="Internal Server Error")) + return JSONResponse( + status_code=200, content=dict(status_code=500, msg="Internal Server Error") + )