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