diff options
-rw-r--r-- | SCHEMA.md | 37 | ||||
-rw-r--r-- | backend/authentication/backend.py | 9 | ||||
-rw-r--r-- | backend/authentication/user.py | 41 | ||||
-rw-r--r-- | backend/discord.py | 171 | ||||
-rw-r--r-- | backend/models/__init__.py | 5 | ||||
-rw-r--r-- | backend/models/discord_role.py | 40 | ||||
-rw-r--r-- | backend/models/discord_user.py | 34 | ||||
-rw-r--r-- | backend/models/form.py | 13 | ||||
-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 | ||||
-rw-r--r-- | tox.ini | 2 |
17 files changed, 455 insertions, 89 deletions
@@ -12,21 +12,24 @@ In this document: ## Form -| Field | Type | Description | Example | -| ------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------- | -| `id` | Unique identifier | A user selected, unique, descriptive identifier (used in URL routes, so no spaces) | `"ban-appeals"` | -| `features` | List of [form features](#form-features) | A list of features to change the behaviour of the form, described in the features section | `["OPEN", "COLLECT_EMAIL"]` | -| `questions` | List of [form questions](#form-question) | The list of questions to render on a specific form | Too long! See below | -| `name` | String | Name of the form | `"Summer Code Jam 2100"` | -| `description` | String | Form description | `"This is my amazing form description."` | -| `webhook` | [Webhook object](#webhooks) | An optional discord webhook. | See webhook documentation. | -| `submitted_text` | Optional[String] | An optional string for the response upon submitting. | `"This is my amazing form response."` | -| `discord_role` | String (optional) | Discord role ID what will be assigned, required when `ASSIGN_ROLE` flag provided. | `784467518298259466` | +| Field | Type | Description | Example | +|--------------------|------------------------------------------|------------------------------------------------------------------------------------------------------------------|------------------------------------------------| +| `id` | Unique identifier | A user selected, unique, descriptive identifier (used in URL routes, so no spaces) | `"ban-appeals"` | +| `features` | List of [form features](#form-features) | A list of features to change the behaviour of the form, described in the features section | `["OPEN", "COLLECT_EMAIL"]` | +| `questions` | List of [form questions](#form-question) | The list of questions to render on a specific form | Too long! See below | +| `name` | String | Name of the form | `"Summer Code Jam 2100"` | +| `description` | String | Form description | `"This is my amazing form description."` | +| `webhook` | [Webhook object](#webhooks) | An optional discord webhook. | See webhook documentation. | +| `submitted_text` | Optional[String] | An optional string for the response upon submitting. | `"This is my amazing form response."` | +| `discord_role` | String (optional) | Discord role ID what will be assigned, required when `ASSIGN_ROLE` flag provided. | `784467518298259466` | +| `response_readers` | List[String] | Discord roles which can view the responses of the form. Can not be the everyone role. | `["267629731250176001", "825337057181696020"]` | +| `editors` | List[String] | Discord roles which have permission to edit, delete, or otherwise modify the form. Can not be the everyone role. | `["409416496733880320"]` | + ### Form features | Flag | Description | -| ------------------ | ----------------------------------------------------------------------------- | +|--------------------|-------------------------------------------------------------------------------| | `DISCOVERABLE` | The form should be displayed on the homepage of the forms application. | | `REQUIRES_LOGIN` | Requires the user to authenticate with Discord before completing the form. | | `OPEN` | The form is currently accepting responses. | @@ -39,7 +42,7 @@ In this document: Discord webhooks to send information upon form submission. | Field | Type | Description | -| ----------| ------ | --------------------------------------------------------------------------------------------------------- | +|-----------|--------|-----------------------------------------------------------------------------------------------------------| | `url` | String | Discord webhook URL. | | `message` | String | An optional message to include before the embed. Can use certain [context variables](#webhook-variables). | @@ -48,7 +51,7 @@ Discord webhooks to send information upon form submission. The following variables can be used in a webhook's message. The variables must be wrapped by braces (`{}`). | Name | Description | -| ------------- | ---------------------------------------------------------------------------- | +|---------------|------------------------------------------------------------------------------| | `user` | A discord mention of the user submitting the form, or "User" if unavailable. | | `response_id` | ID of the submitted response. | | `form` | Name of the submitted form. | @@ -59,7 +62,7 @@ The following variables can be used in a webhook's message. The variables must b ### Form question | Field | Type | Description | Example | -| ---------- | ---------------------------------------- | ------------------------------------------------ | -------------------- | +|------------|------------------------------------------|--------------------------------------------------|----------------------| | `id` | string | Unique identifier of the question | `"aabbcc"` | | `name` | string | Name of the question | `"What's the time?"` | | `type` | one of [Question types](#question-types) | The type of input for this question | `"radio"` | @@ -69,7 +72,7 @@ The following variables can be used in a webhook's message. The variables must b #### Question types | Name | Description | -| ------------ | --------------------------------------------------------- | +|--------------|-----------------------------------------------------------| | `radio` | Radio buttons | | `checkbox` | Checkbox toggle | | `select` | Dropdown list | @@ -165,7 +168,7 @@ Textareas require no additional configuration. ## Form response | Field | Type | Description | -| ----------- | ---------------------------------------------------- | --------------------------------------------------------------------------- | +|-------------|------------------------------------------------------|-----------------------------------------------------------------------------| | `_id`/`id` | MongoDB ObjectID | Random identifier used for the response | | `user` | Optional [user details object](#user-details-object) | An object describing the user that submitted if the form is not anonymous | | `antispam` | Optional [anti spam object](#anti-spam-object) | An object containing information about the anti-spam on the form submission | @@ -197,7 +200,7 @@ The user details contains the information returned by Discord alongside an `admi The anti-spam object contains information about the source of the form submission. | Field | Type | Description | -| ----------------- | ------- | ----------------------------------------------- | +|-------------------|---------|-------------------------------------------------| | `ip_hash` | String | hash of the submitting users IP address | | `user_agent_hash` | String | hash of the submitting users user agent | | `captcha_pass` | Boolean | Whether the user passsed the hCaptcha | diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index c7590e9..54385e2 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -5,6 +5,7 @@ from starlette import authentication from starlette.requests import Request from backend import constants +from backend import discord # We must import user such way here to avoid circular imports from .user import User @@ -60,8 +61,12 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): except Exception: raise authentication.AuthenticationError("Could not parse user details.") - user = User(token, user_details) - if await user.fetch_admin_status(request): + user = User( + token, user_details, await discord.get_member(request.state.db, user_details["id"]) + ) + if await user.fetch_admin_status(request.state.db): scopes.append("admin") + scopes.extend(await user.get_user_roles(request.state.db)) + return authentication.AuthCredentials(scopes), user diff --git a/backend/authentication/user.py b/backend/authentication/user.py index 857c2ed..6256cae 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -1,20 +1,27 @@ +import typing import typing as t import jwt +from pymongo.database import Database from starlette.authentication import BaseUser -from starlette.requests import Request +from backend import discord, models from backend.constants import SECRET_KEY -from backend.discord import fetch_user_details class User(BaseUser): """Starlette BaseUser implementation for JWT authentication.""" - def __init__(self, token: str, payload: dict[str, t.Any]) -> None: + def __init__( + self, + token: str, + payload: dict[str, t.Any], + member: typing.Optional[models.DiscordMember], + ) -> None: self.token = token self.payload = payload self.admin = False + self.member = member @property def is_authenticated(self) -> bool: @@ -34,16 +41,36 @@ class User(BaseUser): def decoded_token(self) -> dict[str, any]: return jwt.decode(self.token, SECRET_KEY, algorithms=["HS256"]) - async def fetch_admin_status(self, request: Request) -> bool: - self.admin = await request.state.db.admins.find_one( + async def get_user_roles(self, database: Database) -> list[str]: + """Get a list of the user's discord roles.""" + if not self.member: + return [] + + server_roles = await discord.get_roles(database) + roles = [role.name for role in server_roles if role.id in self.member.roles] + + if "admin" in roles: + # Protect against collision with the forms admin role + roles.remove("admin") + roles.append("discord admin") + + return roles + + async def fetch_admin_status(self, database: Database) -> bool: + self.admin = await database.admins.find_one( {"_id": self.payload["id"]} ) is not None return self.admin - async def refresh_data(self) -> None: + async def refresh_data(self, database: Database) -> None: """Fetches user data from discord, and updates the instance.""" - self.payload = await fetch_user_details(self.decoded_token.get("token")) + self.member = await discord.get_member(database, self.payload["id"]) + + if self.member: + self.payload = self.member.user.dict() + else: + self.payload = await discord.fetch_user_details(self.decoded_token.get("token")) updated_info = self.decoded_token updated_info["user_details"] = self.payload diff --git a/backend/discord.py b/backend/discord.py index e5c7f8f..be12109 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -1,16 +1,22 @@ """Various utilities for working with the Discord API.""" + +import datetime +import json +import typing + import httpx +import starlette.requests +from pymongo.database import Database +from starlette import exceptions -from backend.constants import ( - DISCORD_API_BASE_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET -) +from backend import constants, models async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict: async with httpx.AsyncClient() as client: data = { - "client_id": OAUTH2_CLIENT_ID, - "client_secret": OAUTH2_CLIENT_SECRET, + "client_id": constants.OAUTH2_CLIENT_ID, + "client_secret": constants.OAUTH2_CLIENT_SECRET, "redirect_uri": f"{redirect}/callback" } @@ -21,7 +27,7 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict data["grant_type"] = "authorization_code" data["code"] = code - r = await client.post(f"{DISCORD_API_BASE_URL}/oauth2/token", headers={ + r = await client.post(f"{constants.DISCORD_API_BASE_URL}/oauth2/token", headers={ "Content-Type": "application/x-www-form-urlencoded" }, data=data) @@ -32,10 +38,161 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict async def fetch_user_details(bearer_token: str) -> dict: async with httpx.AsyncClient() as client: - r = await client.get(f"{DISCORD_API_BASE_URL}/users/@me", headers={ + r = await client.get(f"{constants.DISCORD_API_BASE_URL}/users/@me", headers={ "Authorization": f"Bearer {bearer_token}" }) r.raise_for_status() return r.json() + + +async def _get_role_info() -> list[models.DiscordRole]: + """Get information about the roles in the configured guild.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}/roles", + headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"} + ) + + r.raise_for_status() + return [models.DiscordRole(**role) for role in r.json()] + + +async def get_roles( + database: Database, *, force_refresh: bool = False +) -> list[models.DiscordRole]: + """ + Get a list of all roles from the cache, or discord API if not available. + + If `force_refresh` is True, the cache is skipped and the roles are updated. + """ + collection = database.get_collection("roles") + + if force_refresh: + # Drop all values in the collection + await collection.delete_many({}) + + # `create_index` creates the index if it does not exist, or passes + # This handles TTL on role objects + await collection.create_index( + "inserted_at", + expireAfterSeconds=60 * 60 * 24, # 1 day + name="inserted_at", + ) + + roles = [models.DiscordRole(**json.loads(role["data"])) async for role in collection.find()] + + if len(roles) == 0: + # Fetch roles from the API and insert into the database + roles = await _get_role_info() + await collection.insert_many({ + "name": role.name, + "id": role.id, + "data": role.json(), + "inserted_at": datetime.datetime.now(tz=datetime.timezone.utc), + } for role in roles) + + return roles + + +async def _fetch_member_api(member_id: str) -> typing.Optional[models.DiscordMember]: + """Get a member by ID from the configured guild using the discord API.""" + async with httpx.AsyncClient() as client: + r = await client.get( + f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}" + f"/members/{member_id}", + headers={"Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}"} + ) + + if r.status_code == 404: + return None + + r.raise_for_status() + return models.DiscordMember(**r.json()) + + +async def get_member( + database: Database, user_id: str, *, force_refresh: bool = False +) -> typing.Optional[models.DiscordMember]: + """ + Get a member from the cache, or from the discord API. + + If `force_refresh` is True, the cache is skipped and the entry is updated. + None may be returned if the member object does not exist. + """ + collection = database.get_collection("discord_members") + + if force_refresh: + await collection.delete_one({"user": user_id}) + + # `create_index` creates the index if it does not exist, or passes + # This handles TTL on member objects + await collection.create_index( + "inserted_at", + expireAfterSeconds=60 * 60, # 1 hour + name="inserted_at", + ) + + result = await collection.find_one({"user": user_id}) + + if result is not None: + return models.DiscordMember(**json.loads(result["data"])) + + member = await _fetch_member_api(user_id) + + if not member: + return None + + await collection.insert_one({ + "user": user_id, + "data": member.json(), + "inserted_at": datetime.datetime.now(tz=datetime.timezone.utc), + }) + return member + + +class FormNotFoundError(exceptions.HTTPException): + """The requested form was not found.""" + + +class UnauthorizedError(exceptions.HTTPException): + """You are not authorized to use this resource.""" + + +async def _verify_access_helper( + form_id: str, request: starlette.requests.Request, attribute: str +) -> None: + """A low level helper to validate access to a form resource based on the user's scopes.""" + form = await request.state.db.forms.find_one({"_id": form_id}) + + if not form: + raise FormNotFoundError(status_code=404) + + # Short circuit all resources for forms admins + if "admin" in request.auth.scopes: + return + + form = models.Form(**form) + + for role_id in getattr(form, attribute, []): + role = await request.state.db.roles.find_one({"id": role_id}) + if not role: + continue + + role = models.DiscordRole(**json.loads(role["data"])) + + if role.name in request.auth.scopes: + return + + raise UnauthorizedError(status_code=401) + + +async def verify_response_access(form_id: str, request: starlette.requests.Request) -> None: + """Ensure the user can access responses on the requested resource.""" + await _verify_access_helper(form_id, request, "response_readers") + + +async def verify_edit_access(form_id: str, request: starlette.requests.Request) -> None: + """Ensure the user can view and modify the requested resource.""" + await _verify_access_helper(form_id, request, "editors") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 8ad7f7f..a9f76e0 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,12 +1,15 @@ from .antispam import AntiSpam -from .discord_user import DiscordUser +from .discord_role import DiscordRole +from .discord_user import DiscordMember, DiscordUser from .form import Form, FormList from .form_response import FormResponse, ResponseList from .question import CodeQuestion, Question __all__ = [ "AntiSpam", + "DiscordRole", "DiscordUser", + "DiscordMember", "Form", "FormResponse", "CodeQuestion", diff --git a/backend/models/discord_role.py b/backend/models/discord_role.py new file mode 100644 index 0000000..c05c9de --- /dev/null +++ b/backend/models/discord_role.py @@ -0,0 +1,40 @@ +import typing + +from pydantic import BaseModel + + +class RoleTags(BaseModel): + """Meta information about a discord role.""" + + bot_id: typing.Optional[str] + integration_id: typing.Optional[str] + premium_subscriber: bool + + def __init__(self, **data: typing.Any) -> None: + """ + Handle the terrible discord API. + + Discord only returns the premium_subscriber field if it's true, + meaning the typical validation process wouldn't work. + + We manually parse the raw data to determine if the field exists, and give it a useful + bool value. + """ + data["premium_subscriber"] = "premium_subscriber" in data.keys() + super().__init__(**data) + + +class DiscordRole(BaseModel): + """Schema model of Discord guild roles.""" + + id: str + name: str + color: int + hoist: bool + icon: typing.Optional[str] + unicode_emoji: typing.Optional[str] + position: int + permissions: str + managed: bool + mentionable: bool + tags: typing.Optional[RoleTags] diff --git a/backend/models/discord_user.py b/backend/models/discord_user.py index 9f246ba..0eca15b 100644 --- a/backend/models/discord_user.py +++ b/backend/models/discord_user.py @@ -1,10 +1,11 @@ +import datetime import typing as t from pydantic import BaseModel -class DiscordUser(BaseModel): - """Schema model of Discord user for form response.""" +class _User(BaseModel): + """Base for discord users and members.""" # Discord default fields. username: str @@ -20,5 +21,34 @@ class DiscordUser(BaseModel): premium_type: t.Optional[int] public_flags: t.Optional[int] + +class DiscordUser(_User): + """Schema model of Discord user for form response.""" + # Custom fields admin: bool + + +class DiscordMember(BaseModel): + """A discord guild member.""" + + user: _User + nick: t.Optional[str] + avatar: t.Optional[str] + roles: list[str] + joined_at: datetime.datetime + premium_since: t.Optional[datetime.datetime] + deaf: bool + mute: bool + pending: t.Optional[bool] + permissions: t.Optional[str] + communication_disabled_until: t.Optional[datetime.datetime] + + def dict(self, *args, **kwargs) -> dict[str, t.Any]: + """Convert the model to a python dict, and encode timestamps in a serializable format.""" + data = super().dict(*args, **kwargs) + for field, value in data.items(): + if isinstance(value, datetime.datetime): + data[field] = value.isoformat() + + return data diff --git a/backend/models/form.py b/backend/models/form.py index f19ed85..f888d6e 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -1,10 +1,10 @@ import typing as t import httpx -from pydantic import constr, BaseModel, Field, root_validator, validator +from pydantic import BaseModel, Field, constr, root_validator, validator from pydantic.error_wrappers import ErrorWrapper, ValidationError -from backend.constants import FormFeatures, WebHook +from backend.constants import DISCORD_GUILD, FormFeatures, WebHook from .question import Question PUBLIC_FIELDS = [ @@ -43,6 +43,8 @@ class Form(BaseModel): submitted_text: t.Optional[str] = None webhook: _WebHook = None discord_role: t.Optional[str] + response_readers: t.Optional[list[str]] + editors: t.Optional[list[str]] class Config: allow_population_by_field_name = True @@ -67,6 +69,13 @@ class Form(BaseModel): return value + @validator("response_readers", "editors") + def validate_role_scoping(cls, value: t.Optional[list[str]]) -> t.Optional[list[str]]: + """Ensure special role based permissions aren't granted to the @everyone role.""" + if value and str(DISCORD_GUILD) in value: + raise ValueError("You can not add the everyone role as an access scope.") + return value + @root_validator def validate_role(cls, values: dict[str, t.Any]) -> t.Optional[dict[str, t.Any]]: """Validates does Discord role provided when flag provided.""" 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: @@ -5,6 +5,6 @@ docstring-convention=all import-order-style=pycharm ignore= # Type annotations - ANN101,ANN102 + ANN002,ANN003,ANN101,ANN102 # Line breaks W503 |