diff options
Diffstat (limited to 'backend/routes')
-rw-r--r-- | backend/routes/auth/authorize.py | 12 | ||||
-rw-r--r-- | backend/routes/discord.py | 83 | ||||
-rw-r--r-- | backend/routes/forms/discover.py | 2 | ||||
-rw-r--r-- | backend/routes/forms/form.py | 61 | ||||
-rw-r--r-- | backend/routes/forms/index.py | 6 | ||||
-rw-r--r-- | backend/routes/forms/response.py | 11 | ||||
-rw-r--r-- | backend/routes/forms/responses.py | 15 | ||||
-rw-r--r-- | backend/routes/forms/submit.py | 2 |
8 files changed, 142 insertions, 50 deletions
diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index d4587f0..42fb3ec 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -17,7 +17,7 @@ from starlette.requests import Request from backend import constants from backend.authentication.user import User from backend.constants import SECRET_KEY -from backend.discord import fetch_bearer_token, fetch_user_details +from backend.discord import fetch_bearer_token, fetch_user_details, get_member from backend.route import Route from backend.validation import ErrorMessage, api @@ -34,8 +34,8 @@ class AuthorizeResponse(BaseModel): async def process_token( - bearer_token: dict, - request: Request + bearer_token: dict, + request: Request ) -> Union[AuthorizeResponse, AUTH_FAILURE]: """Post a bearer token to Discord, and return a JWT and username.""" interaction_start = datetime.datetime.now() @@ -46,6 +46,9 @@ async def process_token( AUTH_FAILURE.delete_cookie("token") return AUTH_FAILURE + user_id = user_details["id"] + member = await get_member(request.state.db, user_id, force_refresh=True) + max_age = datetime.timedelta(seconds=int(bearer_token["expires_in"])) token_expiry = interaction_start + max_age @@ -53,11 +56,12 @@ async def process_token( "token": bearer_token["access_token"], "refresh": bearer_token["refresh_token"], "user_details": user_details, + "in_guild": bool(member), "expiry": token_expiry.isoformat() } token = jwt.encode(data, SECRET_KEY, algorithm="HS256") - user = User(token, user_details) + user = User(token, user_details, member) response = responses.JSONResponse({ "username": user.display_name, diff --git a/backend/routes/discord.py b/backend/routes/discord.py new file mode 100644 index 0000000..bca1edb --- /dev/null +++ b/backend/routes/discord.py @@ -0,0 +1,83 @@ +"""Routes which directly interact with discord related data.""" + +import pydantic +from spectree import Response +from starlette.authentication import requires +from starlette.responses import JSONResponse +from starlette.routing import Request + +from backend import discord, models, route +from backend.validation import ErrorMessage, api + +NOT_FOUND_EXCEPTION = JSONResponse( + {"error": "Could not find the requested resource in the guild or cache."}, status_code=404 +) + + +class RolesRoute(route.Route): + """Refreshes the roles database.""" + + name = "roles" + path = "/roles" + + class RolesResponse(pydantic.BaseModel): + """A list of all roles on the configured server.""" + + roles: list[models.DiscordRole] + + @requires(["authenticated", "admin"]) + @api.validate( + resp=Response(HTTP_200=RolesResponse), + tags=["roles"] + ) + async def patch(self, request: Request) -> JSONResponse: + """Refresh the roles database.""" + roles = await discord.get_roles(request.state.db, force_refresh=True) + + return JSONResponse( + {"roles": [role.dict() for role in roles]}, + ) + + +class MemberRoute(route.Route): + """Retrieve information about a server member.""" + + name = "member" + path = "/member" + + class MemberRequest(pydantic.BaseModel): + """An ID of the member to update.""" + + user_id: str + + @requires(["authenticated", "admin"]) + @api.validate( + resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage), + json=MemberRequest, + tags=["auth"] + ) + async def delete(self, request: Request) -> JSONResponse: + """Force a resync of the cache for the given user.""" + body = await request.json() + member = await discord.get_member(request.state.db, body["user_id"], force_refresh=True) + + if member: + return JSONResponse(member.dict()) + else: + return NOT_FOUND_EXCEPTION + + @requires(["authenticated", "admin"]) + @api.validate( + resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage), + json=MemberRequest, + tags=["auth"] + ) + async def get(self, request: Request) -> JSONResponse: + """Get a user's roles on the configured server.""" + body = await request.json() + member = await discord.get_member(request.state.db, body["user_id"]) + + if member: + return JSONResponse(member.dict()) + else: + return NOT_FOUND_EXCEPTION diff --git a/backend/routes/forms/discover.py b/backend/routes/forms/discover.py index d7351d5..b993075 100644 --- a/backend/routes/forms/discover.py +++ b/backend/routes/forms/discover.py @@ -29,7 +29,7 @@ EMPTY_FORM = Form( features=__FEATURES, questions=[__QUESTION], name="Auth form", - description="An empty form to help you get a token." + description="An empty form to help you get a token.", ) diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index 0f96b85..567c197 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -10,13 +10,15 @@ from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse -from backend import constants +from backend import constants, discord from backend.models import Form from backend.route import Route from backend.routes.forms.discover import EMPTY_FORM from backend.routes.forms.unittesting import filter_unittests from backend.validation import ErrorMessage, OkayResponse, api +PUBLIC_FORM_FEATURES = (constants.FormFeatures.OPEN, constants.FormFeatures.DISCOVERABLE) + class SingleForm(Route): """ @@ -31,9 +33,19 @@ class SingleForm(Route): @api.validate(resp=Response(HTTP_200=Form, HTTP_404=ErrorMessage), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Returns single form information by ID.""" - admin = request.user.admin if request.user.is_authenticated else False form_id = request.path_params["form_id"].lower() + try: + await discord.verify_edit_access(form_id, request) + admin = True + except discord.FormNotFoundError: + if not constants.PRODUCTION and form_id == EMPTY_FORM.id: + # Empty form to help with authentication in development. + return JSONResponse(EMPTY_FORM.dict(admin=False)) + raise + except discord.UnauthorizedError: + admin = False + filters = { "_id": form_id } @@ -41,25 +53,18 @@ class SingleForm(Route): if not admin: filters["features"] = {"$in": ["OPEN", "DISCOVERABLE"]} - if raw_form := await request.state.db.forms.find_one(filters): - form = Form(**raw_form) - if not admin: - form = filter_unittests(form) - - return JSONResponse(form.dict(admin=admin)) - - elif not constants.PRODUCTION and form_id == EMPTY_FORM.id: - # Empty form to help with authentication in development. - return JSONResponse(EMPTY_FORM.dict(admin=admin)) + form = Form(**await request.state.db.forms.find_one(filters)) + if not admin: + form = filter_unittests(form) - return JSONResponse({"error": "not_found"}, status_code=404) + return JSONResponse(form.dict(admin=admin)) - @requires(["authenticated", "admin"]) + @requires(["authenticated"]) @api.validate( resp=Response( HTTP_200=OkayResponse, HTTP_400=ErrorMessage, - HTTP_404=ErrorMessage + HTTP_404=ErrorMessage, ), tags=["forms"] ) @@ -70,10 +75,12 @@ class SingleForm(Route): except json.decoder.JSONDecodeError: return JSONResponse("Expected a JSON body.", 400) - form_id = {"_id": request.path_params["form_id"].lower()} - if raw_form := await request.state.db.forms.find_one(form_id): + form_id = request.path_params["form_id"].lower() + await discord.verify_edit_access(form_id, request) + + if raw_form := await request.state.db.forms.find_one({"_id": form_id}): if "_id" in data or "id" in data: - if (data.get("id") or data.get("_id")) != form_id["_id"]: + if (data.get("id") or data.get("_id")) != form_id: return JSONResponse({"error": "locked_field"}, status_code=400) # Build Data Merger @@ -90,7 +97,7 @@ class SingleForm(Route): except ValidationError as e: return JSONResponse(e.errors(), status_code=422) - await request.state.db.forms.replace_one(form_id, form.dict()) + await request.state.db.forms.replace_one({"_id": form_id}, form.dict()) return JSONResponse(form.dict()) else: @@ -98,21 +105,15 @@ class SingleForm(Route): @requires(["authenticated", "admin"]) @api.validate( - resp=Response(HTTP_200=OkayResponse, HTTP_404=ErrorMessage), + resp=Response(HTTP_200=OkayResponse, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage), tags=["forms"] ) async def delete(self, request: Request) -> JSONResponse: """Deletes form by ID.""" - if not await request.state.db.forms.find_one( - {"_id": request.path_params["form_id"].lower()} - ): - return JSONResponse({"error": "not_found"}, status_code=404) + form_id = request.path_params["form_id"].lower() + await discord.verify_edit_access(form_id, request) - await request.state.db.forms.delete_one( - {"_id": request.path_params["form_id"].lower()} - ) - await request.state.db.responses.delete_many( - {"form_id": request.path_params["form_id"].lower()} - ) + await request.state.db.forms.delete_one({"_id": form_id}) + await request.state.db.responses.delete_many({"form_id": form_id}) return JSONResponse({"status": "ok"}) diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py index 22171fa..38be693 100644 --- a/backend/routes/forms/index.py +++ b/backend/routes/forms/index.py @@ -15,13 +15,13 @@ from backend.validation import ErrorMessage, OkayResponse, api class FormsList(Route): """ - List all available forms for administrator viewing. + List all available forms for authorized viewers. """ name = "forms_list_create" path = "/" - @requires(["authenticated", "admin"]) + @requires(["authenticated", "Admins"]) @api.validate(resp=Response(HTTP_200=FormList), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Return a list of all forms to authenticated users.""" @@ -38,7 +38,7 @@ class FormsList(Route): forms ) - @requires(["authenticated", "admin"]) + @requires(["authenticated", "Helpers"]) @api.validate( json=Form, resp=Response(HTTP_200=OkayResponse, HTTP_400=ErrorMessage), diff --git a/backend/routes/forms/response.py b/backend/routes/forms/response.py index d8d8d17..565701f 100644 --- a/backend/routes/forms/response.py +++ b/backend/routes/forms/response.py @@ -1,11 +1,13 @@ """ Returns or deletes form response by ID. """ + from spectree import Response as RouteResponse from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse +from backend import discord from backend.models import FormResponse from backend.route import Route from backend.validation import ErrorMessage, OkayResponse, api @@ -17,23 +19,26 @@ class Response(Route): name = "response" path = "/{form_id:str}/responses/{response_id:str}" - @requires(["authenticated", "admin"]) + @requires(["authenticated"]) @api.validate( resp=RouteResponse(HTTP_200=FormResponse, HTTP_404=ErrorMessage), tags=["forms", "responses"] ) async def get(self, request: Request) -> JSONResponse: """Return a single form response by ID.""" + form_id = request.path_params["form_id"] + await discord.verify_response_access(form_id, request) + if raw_response := await request.state.db.responses.find_one( { "_id": request.path_params["response_id"], - "form_id": request.path_params["form_id"] + "form_id": form_id } ): response = FormResponse(**raw_response) return JSONResponse(response.dict()) else: - return JSONResponse({"error": "not_found"}, status_code=404) + return JSONResponse({"error": "response_not_found"}, status_code=404) @requires(["authenticated", "admin"]) @api.validate( diff --git a/backend/routes/forms/responses.py b/backend/routes/forms/responses.py index f3c4cd7..818ebce 100644 --- a/backend/routes/forms/responses.py +++ b/backend/routes/forms/responses.py @@ -7,9 +7,10 @@ from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse +from backend import discord from backend.models import FormResponse, ResponseList from backend.route import Route -from backend.validation import api, ErrorMessage, OkayResponse +from backend.validation import ErrorMessage, OkayResponse, api class ResponseIdList(BaseModel): @@ -24,20 +25,18 @@ class Responses(Route): name = "form_responses" path = "/{form_id:str}/responses" - @requires(["authenticated", "admin"]) + @requires(["authenticated"]) @api.validate( - resp=Response(HTTP_200=ResponseList, HTTP_404=ErrorMessage), + resp=Response(HTTP_200=ResponseList), tags=["forms", "responses"] ) async def get(self, request: Request) -> JSONResponse: """Returns all form responses by form ID.""" - if not await request.state.db.forms.find_one( - {"_id": request.path_params["form_id"]} - ): - return JSONResponse({"error": "not_found"}, 404) + form_id = request.path_params["form_id"] + await discord.verify_response_access(form_id, request) cursor = request.state.db.responses.find( - {"form_id": request.path_params["form_id"]} + {"form_id": form_id} ) responses = [ FormResponse(**response) for response in await cursor.to_list(None) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 95e30b0..baf403d 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -83,7 +83,7 @@ class SubmitForm(Route): try: if hasattr(request.user, User.refresh_data.__name__): old = request.user.token - await request.user.refresh_data() + await request.user.refresh_data(request.state.db) if old != request.user.token: try: |