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,
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},
@router.post(
"/user",
tags=["Users"],
status_code=200,
responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity}})
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
"""
@ -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,9 +221,13 @@ 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"}},
responses={
200: {
"model": PublicUserResponse,
"exclude": {"password_hash", "internal_id", "deleted"},
},
404: {"model": NotFound},
422: {"model": UnprocessableEntity}
422: {"model": UnprocessableEntity},
},
)
@LIMITER.limit("30/second")
@ -216,9 +247,13 @@ 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"}},
responses={
200: {
"model": PublicUserResponse,
"exclude": {"password_hash", "internal_id", "deleted"},
},
404: {"model": NotFound},
422: {"model": UnprocessableEntity}
422: {"model": UnprocessableEntity},
},
)
@LIMITER.limit("30/second")
@ -230,7 +265,9 @@ async def get_user_by_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)
@ -292,9 +329,12 @@ 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)
@ -317,11 +357,19 @@ 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"}},
@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}
})
422: {"model": UnprocessableEntity},
},
)
@LIMITER.limit("3/second")
async def verify_email(
request: Request,
@ -351,15 +399,23 @@ 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},
@router.get(
"/user/resetPassword/{verification_id}",
tags=["Users"],
status_code=200,
responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
404: {"model": NotFound}})
404: {"model": NotFound},
},
)
@LIMITER.limit("3/second")
async def reset_password(
request: Request,
@ -395,11 +451,17 @@ 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},
@router.get(
"/user/changeEmail/{verification_id}",
tags=["Users"],
status_code=200,
responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
404: {"model": NotFound}})
404: {"model": NotFound},
},
)
@LIMITER.limit("3/second")
async def change_email(
request: Request,
@ -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},
@router.put(
"user/resendMail",
tags=["Users"],
status_code=200,
responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
500: {"model": InternalServerError}})
500: {"model": InternalServerError},
},
)
@LIMITER.limit("6/minute")
async def resend_email(request: Request, user: UserModel = Depends(UNVERIFIED_MANAGER)):
"""
@ -493,16 +561,25 @@ 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},
@router.put(
"/user",
tags=["Users"],
status_code=200,
responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
500: {"model": InternalServerError},
413: {"model": PayloadTooLarge}})
413: {"model": PayloadTooLarge},
},
)
@LIMITER.limit("2/minute")
async def signup(
request: Request,
@ -619,12 +696,19 @@ async def validate_profile_picture(
return None, "", b"", ""
@router.patch("/user", tags=["Users"], status_code=200,
responses={200: {"model": APIResponse},
@router.patch(
"/user",
tags=["Users"],
status_code=200,
responses={
200: {"model": APIResponse},
400: {"model": BadRequest},
422: {"model": UnprocessableEntity},
500: {"model": InternalServerError},
413: {"model": PayloadTooLarge}})
413: {"model": PayloadTooLarge},
415: {"model": MediaTypeNotAcceptable}
},
)
async def update_user(
request: Request,
user: UserModel = Depends(UNVERIFIED_MANAGER),
@ -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)

View File

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

View File

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

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