Added media endpoints, various style modifications and minor fixes, added API.md

This commit is contained in:
Nocturn9x 2022-10-06 12:27:53 +02:00
parent efa1ebf669
commit 1da075f48f
7 changed files with 419 additions and 237 deletions

6
API.md Normal file
View File

@ -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.

54
endpoints/media.py Normal file
View File

@ -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")

View File

@ -50,8 +50,15 @@ from config import (
FORCE_EMAIL_VERIFICATION, FORCE_EMAIL_VERIFICATION,
UNVERIFIED_MANAGER, UNVERIFIED_MANAGER,
) )
from responses import Response as APIResponse, UnprocessableEntity, BadRequest, NotFound, \ from responses import (
MediaTypeNotAcceptable, PayloadTooLarge, InternalServerError Response as APIResponse,
UnprocessableEntity,
BadRequest,
NotFound,
MediaTypeNotAcceptable,
PayloadTooLarge,
InternalServerError,
)
from responses.users import ( from responses.users import (
PrivateUserResponse, PrivateUserResponse,
PublicUserResponse, PublicUserResponse,
@ -88,12 +95,21 @@ async def get_self_by_id_unverified(public_id: UUID) -> UserModel:
# Here follow our *beautifully* documented path operations # Here follow our *beautifully* documented path operations
@router.post("/user", tags=["Users"], status_code=200,
responses={200: {"model": APIResponse}, @router.post(
400: {"model": BadRequest}, "/user",
422: {"model": UnprocessableEntity}}) tags=["Users"],
status_code=200,
responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
},
)
@LIMITER.limit("5/minute") @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 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", detail="Authentication failed: invalid characters in password",
) )
if not ( if not (
user := await get_user_by_username( user := await get_user_by_username(
username, include_secrets=True, restricted_ok=True username, include_secrets=True, restricted_ok=True
) )
): ):
raise HTTPException( raise HTTPException(
status_code=413, status_code=413,
@ -134,7 +150,8 @@ async def login(request: Request, response: Response, data: OAuth2PasswordReques
detail="Authentication failed: password mismatch", detail="Authentication failed: password mismatch",
) )
token = MANAGER.create_access_token( 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( response.set_cookie(
secure=SECURE_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") return APIResponse(status_code=200, msg="Authentication successful")
@router.get("/user/logout", tags=["Users"], status_code=200, @router.get(
responses={200: {"model": APIResponse}, "/user/logout",
422: {"model": UnprocessableEntity}}) tags=["Users"],
status_code=200,
responses={200: {"model": APIResponse}, 422: {"model": UnprocessableEntity}},
)
@LIMITER.limit("5/minute") @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 Deletes a user's session cookie, logging them
out. Endpoint is limited to 5 hits per minute 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", "/user/me",
tags=["Users"], tags=["Users"],
status_code=200, status_code=200,
responses={200: {"model": PrivateUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, responses={
422: {"model": UnprocessableEntity}}, 200: {
"model": PrivateUserResponse,
"exclude": {"password_hash", "internal_id", "deleted"},
},
422: {"model": UnprocessableEntity},
},
) )
@LIMITER.limit("2/second") @LIMITER.limit("2/second")
async def get_self(request: Request, user: UserModel = Depends(UNVERIFIED_MANAGER)): 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}", "/user/username/{username}",
tags=["Users"], tags=["Users"],
status_code=200, status_code=200,
responses={200: {"model": PublicUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, responses={
404: {"model": NotFound}, 200: {
422: {"model": UnprocessableEntity} "model": PublicUserResponse,
}, "exclude": {"password_hash", "internal_id", "deleted"},
},
404: {"model": NotFound},
422: {"model": UnprocessableEntity},
},
) )
@LIMITER.limit("30/second") @LIMITER.limit("30/second")
async def get_user_by_name( 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 Fetches a single user by its public username
@ -216,31 +247,37 @@ async def get_user_by_name(
"/user/id/{public_id}", "/user/id/{public_id}",
tags=["Users"], tags=["Users"],
status_code=200, status_code=200,
responses={200: {"model": PublicUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, responses={
404: {"model": NotFound}, 200: {
422: {"model": UnprocessableEntity} "model": PublicUserResponse,
}, "exclude": {"password_hash", "internal_id", "deleted"},
},
404: {"model": NotFound},
422: {"model": UnprocessableEntity},
},
) )
@LIMITER.limit("30/second") @LIMITER.limit("30/second")
async def get_user_by_public_id( 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 Fetches a single user by its public ID
""" """
if not (user := await get_user_by_id(UUID(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) return PublicUserResponse(data=user)
async def validate_user( async def validate_user(
first_name: str | None, first_name: str | None,
last_name: str | None, last_name: str | None,
username: str | None, username: str | None,
email: str | None, email: str | None,
password: str | None, password: str | None,
bio: str | None, bio: str | None,
): ):
""" """
Performs some validation upon user creation. Returns Performs some validation upon user creation. Returns
@ -261,9 +298,9 @@ async def validate_user(
if username and len(username) > 32: if username and len(username) > 32:
return False, "username is too long" return False, "username is too long"
if ( if (
username username
and VALIDATE_USERNAME_REGEX and VALIDATE_USERNAME_REGEX
and not re.match(VALIDATE_USERNAME_REGEX, username) and not re.match(VALIDATE_USERNAME_REGEX, username)
): ):
return False, "username is invalid" return False, "username is invalid"
if email and not validators.email(email): if email and not validators.email(email):
@ -271,13 +308,13 @@ async def validate_user(
if password and len(password) > 72: if password and len(password) > 72:
return False, "password is too long" return False, "password is too long"
if ( if (
password password
and VALIDATE_PASSWORD_REGEX and VALIDATE_PASSWORD_REGEX
and not re.match(VALIDATE_PASSWORD_REGEX, password) and not re.match(VALIDATE_PASSWORD_REGEX, password)
): ):
return False, "password is too weak" return False, "password is too weak"
if username and await get_user_by_username( 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" return False, "username is already taken"
if email and await get_user_by_email(email, deleted_ok=True, restricted_ok=True): 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, "" return True, ""
@router.delete("/user", tags=["Users"], status_code=200, @router.delete(
responses={200: {"model": APIResponse}, "/user",
422: {"model": UnprocessableEntity}}) tags=["Users"],
status_code=200,
responses={200: {"model": APIResponse}, 422: {"model": UnprocessableEntity}},
)
@LIMITER.limit("1/minute") @LIMITER.limit("1/minute")
async def delete( 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, Sets the user's deleted flag in the database,
@ -317,25 +357,33 @@ async def delete(
return APIResponse(status_code=200, msg="Success") return APIResponse(status_code=200, msg="Success")
@router.get("/user/verifyEmail/{verification_id}", tags=["Users"], status_code=200, @router.get(
responses={200: {"model": PrivateUserResponse, "exclude": {"password_hash", "internal_id", "deleted"}}, "/user/verifyEmail/{verification_id}",
404: {"model": NotFound}, tags=["Users"],
422: {"model": UnprocessableEntity} status_code=200,
}) responses={
200: {
"model": PrivateUserResponse,
"exclude": {"password_hash", "internal_id", "deleted"},
},
404: {"model": NotFound},
422: {"model": UnprocessableEntity},
},
)
@LIMITER.limit("3/second") @LIMITER.limit("3/second")
async def verify_email( async def verify_email(
request: Request, request: Request,
verification_id: str, verification_id: str,
user: UserModel = Depends(UNVERIFIED_MANAGER), user: UserModel = Depends(UNVERIFIED_MANAGER),
): ):
""" """
Verifies a user's email address Verifies a user's email address
""" """
if not ( if not (
verification := await EmailVerification.select(*EmailVerification.all_columns()) verification := await EmailVerification.select(*EmailVerification.all_columns())
.where(EmailVerification.id == verification_id) .where(EmailVerification.id == verification_id)
.first() .first()
): ):
raise HTTPException(status_code=404, detail="Verification ID is invalid") raise HTTPException(status_code=404, detail="Verification ID is invalid")
elif not verification["pending"]: elif not verification["pending"]:
@ -351,29 +399,37 @@ async def verify_email(
await EmailVerification.update({EmailVerification.pending: False}).where( await EmailVerification.update({EmailVerification.pending: False}).where(
EmailVerification.user == user.public_id 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") return APIResponse(status_code=200, msg="Verification successful")
@router.get("/user/resetPassword/{verification_id}", tags=["Users"], status_code=200, @router.get(
responses={200: {"model": APIResponse}, "/user/resetPassword/{verification_id}",
400: {"model": BadRequest}, tags=["Users"],
422: {"model": UnprocessableEntity}, status_code=200,
404: {"model": NotFound}}) responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
404: {"model": NotFound},
},
)
@LIMITER.limit("3/second") @LIMITER.limit("3/second")
async def reset_password( async def reset_password(
request: Request, request: Request,
verification_id: str, verification_id: str,
user: UserModel = Depends(UNVERIFIED_MANAGER), user: UserModel = Depends(UNVERIFIED_MANAGER),
): ):
""" """
Modifies a user's password Modifies a user's password
""" """
if not ( if not (
verification := await EmailVerification.select(*EmailVerification.all_columns()) verification := await EmailVerification.select(*EmailVerification.all_columns())
.where(EmailVerification.id == verification_id) .where(EmailVerification.id == verification_id)
.first() .first()
): ):
raise HTTPException(status_code=404, detail="Request ID is invalid") raise HTTPException(status_code=404, detail="Request ID is invalid")
elif not verification["pending"]: elif not verification["pending"]:
@ -395,25 +451,31 @@ async def reset_password(
return APIResponse(status_code=200, msg="Password updated") return APIResponse(status_code=200, msg="Password updated")
@router.get("/user/changeEmail/{verification_id}", tags=["Users"], status_code=200, @router.get(
responses={200: {"model": APIResponse}, "/user/changeEmail/{verification_id}",
400: {"model": BadRequest}, tags=["Users"],
422: {"model": UnprocessableEntity}, status_code=200,
404: {"model": NotFound}}) responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
404: {"model": NotFound},
},
)
@LIMITER.limit("3/second") @LIMITER.limit("3/second")
async def change_email( async def change_email(
request: Request, request: Request,
verification_id: str, verification_id: str,
user: UserModel = Depends(UNVERIFIED_MANAGER), user: UserModel = Depends(UNVERIFIED_MANAGER),
): ):
""" """
Modifies a user's email Modifies a user's email
""" """
if not ( if not (
verification := await EmailVerification.select(*EmailVerification.all_columns()) verification := await EmailVerification.select(*EmailVerification.all_columns())
.where(EmailVerification.id == verification_id) .where(EmailVerification.id == verification_id)
.first() .first()
): ):
raise HTTPException(status_code=404, detail="Request ID is invalid") raise HTTPException(status_code=404, detail="Request ID is invalid")
elif not verification["pending"]: elif not verification["pending"]:
@ -438,11 +500,17 @@ async def change_email(
return APIResponse(status_code=200, msg="Email updated") return APIResponse(status_code=200, msg="Email updated")
@router.put("user/resendMail", tags=["Users"], status_code=200, @router.put(
responses={200: {"model": APIResponse}, "user/resendMail",
400: {"model": BadRequest}, tags=["Users"],
422: {"model": UnprocessableEntity}, status_code=200,
500: {"model": InternalServerError}}) responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
500: {"model": InternalServerError},
},
)
@LIMITER.limit("6/minute") @LIMITER.limit("6/minute")
async def resend_email(request: Request, user: UserModel = Depends(UNVERIFIED_MANAGER)): 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"] email_message = json.load(f)["signup"]
verification_id = uuid.uuid4() verification_id = uuid.uuid4()
if await send_email( if await send_email(
SMTP_HOST, SMTP_HOST,
SMTP_PORT, SMTP_PORT,
email_message["content"].format( email_message["content"].format(
first_name=user["first_name"], first_name=user["first_name"],
last_name=user["last_name"], last_name=user["last_name"],
username=user["username"], username=user["username"],
email=user["email_address"], email=user["email_address"],
link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" 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}", f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}",
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
), ),
SMTP_TIMEOUT, SMTP_TIMEOUT,
SMTP_FROM_USER, SMTP_FROM_USER,
user["email_address"], user["email_address"],
email_message["subject"].format( email_message["subject"].format(
first_name=user["first_name"], first_name=user["first_name"],
last_name=user["last_name"], last_name=user["last_name"],
username=user["username"], username=user["username"],
email=user["email_address"], email=user["email_address"],
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
), ),
SMTP_USER, SMTP_USER,
SMTP_PASSWORD, SMTP_PASSWORD,
use_tls=SMTP_USE_TLS, use_tls=SMTP_USE_TLS,
): ):
await EmailVerification.update( 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") return APIResponse(status_code=200, msg="Success")
else: else:
raise HTTPException(status_code=500, detail="An error occurred while trying to resend the email," raise HTTPException(
" please try again later") 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, @router.put(
responses={200: {"model": APIResponse}, "/user",
400: {"model": BadRequest}, tags=["Users"],
422: {"model": UnprocessableEntity}, status_code=200,
500: {"model": InternalServerError}, responses={
413: {"model": PayloadTooLarge}}) 200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
500: {"model": InternalServerError},
413: {"model": PayloadTooLarge},
},
)
@LIMITER.limit("2/minute") @LIMITER.limit("2/minute")
async def signup( async def signup(
request: Request, request: Request,
first_name: str, first_name: str,
last_name: str, last_name: str,
username: str, username: str,
email: str, email: str,
password: str, password: str,
bio: str | None = None, bio: str | None = None,
locale: str = "en_US", locale: str = "en_US",
): ):
""" """
Endpoint used to create new users Endpoint used to create new users
@ -546,30 +623,30 @@ async def signup(
email_message = json.load(f)["signup"] email_message = json.load(f)["signup"]
verification_id = uuid.uuid4() verification_id = uuid.uuid4()
if await send_email( if await send_email(
SMTP_HOST, SMTP_HOST,
SMTP_PORT, SMTP_PORT,
email_message["content"].format( email_message["content"].format(
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
username=username, username=username,
email=email, email=email,
link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" 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}", f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/verifyEmail/{verification_id}",
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
), ),
SMTP_TIMEOUT, SMTP_TIMEOUT,
SMTP_FROM_USER, SMTP_FROM_USER,
email, email,
email_message["subject"].format( email_message["subject"].format(
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
username=username, username=username,
email=email, email=email,
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
), ),
SMTP_USER, SMTP_USER,
SMTP_PASSWORD, SMTP_PASSWORD,
use_tls=SMTP_USE_TLS, use_tls=SMTP_USE_TLS,
): ):
await User.insert(user) await User.insert(user)
await EmailVerification.insert( await EmailVerification.insert(
@ -585,12 +662,12 @@ async def signup(
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="An error occurred while sending verification email, please" detail="An error occurred while sending verification email, please"
" try again later", " try again later",
) )
async def validate_profile_picture( async def validate_profile_picture(
file: UploadFile, file: UploadFile,
) -> tuple[bool | None, str, bytes, str]: ) -> tuple[bool | None, str, bytes, str]:
""" """
Validates a profile picture's size and content to see if it fits 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"", "" return None, "", b"", ""
@router.patch("/user", tags=["Users"], status_code=200, @router.patch(
responses={200: {"model": APIResponse}, "/user",
400: {"model": BadRequest}, tags=["Users"],
422: {"model": UnprocessableEntity}, status_code=200,
500: {"model": InternalServerError}, responses={
413: {"model": PayloadTooLarge}}) 200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
500: {"model": InternalServerError},
413: {"model": PayloadTooLarge},
415: {"model": MediaTypeNotAcceptable}
},
)
async def update_user( async def update_user(
request: Request, request: Request,
user: UserModel = Depends(UNVERIFIED_MANAGER), user: UserModel = Depends(UNVERIFIED_MANAGER),
first_name: str | None = None, first_name: str | None = None,
last_name: str | None = None, last_name: str | None = None,
username: str | None = None, username: str | None = None,
profile_picture: UploadFile | None = None, profile_picture: UploadFile | None = None,
email_address: str | None = None, email_address: str | None = None,
password: str | None = None, password: str | None = None,
bio: str | None = None, bio: str | None = None,
delete: bool = False, delete: bool = False,
): ):
""" """
Updates a user's profile information. Parameters that are not specified are left unchanged unless 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( 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( raise HTTPException(
status_code=400, detail="At least one value has to be specified" status_code=400, detail="At least one value has to be specified"
@ -678,14 +762,7 @@ async def update_user(
) )
elif result is None: elif result is None:
raise HTTPException(status_code=413, detail="The file is too large") raise HTTPException(status_code=413, detail="The file is too large")
elif ( elif (m := await Media.select(Media.media_id).where(Media.media_id == digest).first()) is None:
await (
old_media := 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 # 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 # 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) # 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"] email_message = json.load(f)["password_change"]
verification_id = uuid.uuid4() verification_id = uuid.uuid4()
if not await send_email( if not await send_email(
SMTP_HOST, SMTP_HOST,
SMTP_PORT, SMTP_PORT,
email_message["content"].format( email_message["content"].format(
first_name=user.first_name, first_name=user.first_name,
last_name=user.last_name, last_name=user.last_name,
username=user.username, username=user.username,
email=user.email_address, email=user.email_address,
link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" 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}", f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/resetPassword/{verification_id}",
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
), ),
SMTP_TIMEOUT, SMTP_TIMEOUT,
SMTP_FROM_USER, SMTP_FROM_USER,
user.email_address, user.email_address,
email_message["subject"].format( email_message["subject"].format(
first_name=user.first_name, first_name=user.first_name,
last_name=user.last_name, last_name=user.last_name,
username=user.username, username=user.username,
email=user.email_address, email=user.email_address,
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
), ),
SMTP_USER, SMTP_USER,
SMTP_PASSWORD, SMTP_PASSWORD,
use_tls=SMTP_USE_TLS, use_tls=SMTP_USE_TLS,
): ):
raise HTTPException( raise HTTPException(
500, 500,
@ -782,31 +859,31 @@ async def update_user(
email_message = json.load(f)["email_change"] email_message = json.load(f)["email_change"]
verification_id = uuid.uuid4() verification_id = uuid.uuid4()
if not await send_email( if not await send_email(
SMTP_HOST, SMTP_HOST,
SMTP_PORT, SMTP_PORT,
email_message["content"].format( email_message["content"].format(
first_name=user.first_name, first_name=user.first_name,
last_name=user.last_name, last_name=user.last_name,
username=user.username, username=user.username,
email=user.email_address, email=user.email_address,
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
link=f"http{'s' if HAS_HTTPS else ''}://{HOST}" 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}", f"{'' if PORT == 443 and HAS_HTTPS or PORT == 80 else f':{PORT}'}/user/changeEmail/{verification_id}",
newMail=email_address, newMail=email_address,
), ),
SMTP_TIMEOUT, SMTP_TIMEOUT,
SMTP_FROM_USER, SMTP_FROM_USER,
user.email_address, user.email_address,
email_message["subject"].format( email_message["subject"].format(
first_name=user.first_name, first_name=user.first_name,
last_name=user.last_name, last_name=user.last_name,
username=user.username, username=user.username,
email=user.email_address, email=user.email_address,
platformName=PLATFORM_NAME, platformName=PLATFORM_NAME,
), ),
SMTP_USER, SMTP_USER,
SMTP_PASSWORD, SMTP_PASSWORD,
use_tls=SMTP_USE_TLS, use_tls=SMTP_USE_TLS,
): ):
raise HTTPException( raise HTTPException(
500, 500,
@ -830,8 +907,8 @@ async def update_user(
fields = [] fields = []
for field in user: for field in user:
if ( if (
field not in ["email_address", "password"] field not in ["email_address", "password"]
and orig_user[field] != user[field] and orig_user[field] != user[field]
): ):
fields.append((field, user[field])) fields.append((field, user[field]))
if fields: if fields:

View File

@ -12,7 +12,7 @@ from slowapi.errors import RateLimitExceeded
from pathlib import Path from pathlib import Path
from endpoints import users from endpoints import users, media
from config import ( from config import (
LOGGER, LOGGER,
LIMITER, LIMITER,
@ -39,7 +39,7 @@ from util.exception_handlers import (
from util.email import test_smtp 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() description = f.read()
@ -67,6 +67,10 @@ app = FastAPI(
"name": "Posts", "name": "Posts",
"description": "Endpoints that handle post creation, modification and deletion", "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.info("Backend starting up!")
LOGGER.debug("Including modules") LOGGER.debug("Including modules")
app.include_router(users.router) app.include_router(users.router)
app.include_router(media.router)
app.state.limiter = LIMITER app.state.limiter = LIMITER
LOGGER.debug("Setting exception handlers") LOGGER.debug("Setting exception handlers")
app.add_exception_handler(RateLimitExceeded, rate_limited) app.add_exception_handler(RateLimitExceeded, rate_limited)

View File

@ -4,7 +4,17 @@ Media relation
from piccolo.table import Table from piccolo.table import Table
from piccolo.utils.pydantic import create_pydantic_model 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 piccolo.columns.defaults.date import DateNow
from enum import Enum, auto from enum import Enum, auto
from typing import Any from typing import Any
@ -35,7 +45,9 @@ class Media(Table):
MediaModel = create_pydantic_model(Media) 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( async def get_media_by_column(

12
responses/media.py Normal file
View File

@ -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

View File

@ -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"{request.client.host} got rate-limited at {str(request.url)} "
f"(exceeded {error.detail})" f"(exceeded {error.detail})"
) )
return JSONResponse(status_code=200, content=dict(status_code=429, return JSONResponse(
msg=f"Too many requests, retry after {error.detail[error.detail.find('per') + 4:]}")) 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: 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)}") 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: def request_invalid(request: Request, exc: RequestValidationError) -> JSONResponse:
@ -42,7 +49,10 @@ def request_invalid(request: Request, exc: RequestValidationError) -> JSONRespon
LOGGER.info( LOGGER.info(
f"{request.client.host} sent an invalid request at {request.url!r}: {type(exc).__name__}: {exc}" 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( 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"{request.client.host} raised a {exc.status_code} error at {request.url!r}:"
f"{type(exc).__name__}: {exc}" 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: else:
LOGGER.info( LOGGER.info(
f"{request.client.host} raised an HTTP error ({exc.status_code}) at {str(request.url)}" 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: 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)}" 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 # 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")
)