From b80b3293a5cc50d4482ead0903d516652f83b27e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 17 Dec 2020 04:31:37 +0300 Subject: Adds Webhook Option Adds webhook option on form creation, and adds validation. Updates `SCHEMA.md`. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/models/form.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) (limited to 'backend/models') diff --git a/backend/models/form.py b/backend/models/form.py index cb58065..e6c56a4 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -1,5 +1,6 @@ import typing as t +import httpx from pydantic import BaseModel, Field, validator from backend.constants import FormFeatures @@ -8,6 +9,55 @@ from .question import Question PUBLIC_FIELDS = ["id", "features", "questions", "name", "description"] +class _WebHook(BaseModel): + """Schema model of discord webhooks.""" + url: str + message: str + + @validator("url") + def validate_url(cls, url: str) -> str: + """Checks if discord webhook urls are valid.""" + if not isinstance(url, str): + raise ValueError("Webhook URL must be a string.") + + if "discord.com/api/webhooks/" not in url: + raise ValueError("URL must be a discord webhook.") + + # Attempt to connect to URL + try: + httpx.get(url).raise_for_status() + + except httpx.RequestError as e: + # Catch exceptions in request format + raise ValueError( + f"Encountered error while trying to connect to url: `{e}`" + ) + + except httpx.HTTPStatusError as e: + # Catch exceptions in response + status = e.response.status_code + + if status == 401: + raise ValueError( + "Could not authenticate with target. Please check the webhook url." + ) + elif status == 404: + raise ValueError( + "Target could not find webhook url. Please check the webhook url." + ) + else: + raise ValueError( + f"Unknown error ({status}) while connecting to target: {e}" + ) + + return url + + +class _FormMeta(BaseModel): + """Schema model for form meta data.""" + webhook: _WebHook + + class Form(BaseModel): """Schema model for form.""" @@ -16,6 +66,7 @@ class Form(BaseModel): questions: list[Question] name: str description: str + meta: _FormMeta class Config: allow_population_by_field_name = True -- cgit v1.2.3 From 20c72c4d7fe41632d04c619c4510364ba2674599 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 17 Dec 2020 12:20:38 +0300 Subject: Makes Webhooks Optional Fixes a bug that would force you to do a dummy webhook object when creating a form. Fixes a bug that would force you to add an empty message for webhooks. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/models/form.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'backend/models') diff --git a/backend/models/form.py b/backend/models/form.py index e6c56a4..ef81616 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -12,7 +12,7 @@ PUBLIC_FIELDS = ["id", "features", "questions", "name", "description"] class _WebHook(BaseModel): """Schema model of discord webhooks.""" url: str - message: str + message: t.Optional[str] @validator("url") def validate_url(cls, url: str) -> str: @@ -55,7 +55,7 @@ class _WebHook(BaseModel): class _FormMeta(BaseModel): """Schema model for form meta data.""" - webhook: _WebHook + webhook: _WebHook = None class Form(BaseModel): -- cgit v1.2.3 From cbd38cebcc32940de14eaa4af3927430026e9cea Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 00:22:54 +0300 Subject: Sends Embed Asynchronously Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/models/form.py | 2 +- backend/routes/forms/submit.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) (limited to 'backend/models') diff --git a/backend/models/form.py b/backend/models/form.py index 6c435e7..9a3a8a2 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -66,7 +66,7 @@ class Form(BaseModel): questions: list[Question] name: str description: str - meta: _FormMeta + meta: _FormMeta = _FormMeta() class Config: allow_population_by_field_name = True diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index c476468..538a5b9 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -152,7 +152,7 @@ class SubmitForm(Route): }) @staticmethod - def send_submission_webhook(form: Form, response: FormResponse) -> None: + async def send_submission_webhook(form: Form, response: FormResponse) -> None: """Helper to send a submission message to a discord webhook.""" # Stop if webhook is not available if form.meta.webhook is None: @@ -189,7 +189,9 @@ class SubmitForm(Route): # Set hook message message = form.meta.webhook.message if message: - hook["content"] = message.replace("_USER_MENTION_", f"<@{user.id}>") + hook["content"] = message.replace("_USER_MENTION_", user_mention) # Post hook - httpx.post(form.meta.webhook.url, json=hook).raise_for_status() + async with httpx.AsyncClient() as client: + r = await client.post(form.meta.webhook.url, json=hook) + r.raise_for_status() -- cgit v1.2.3 From d1cb4200229a7811f21ce44dc427674e4f0b4ff3 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 23:12:33 +0300 Subject: Runs Initial Validation Asynchronously Moves the validation code of webhook urls to an async function that is called by the route, to avoid blocking code. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/constants.py | 9 +++++ backend/models/form.py | 87 +++++++++++++++++++++++++++---------------- backend/routes/forms/index.py | 18 ++++++++- 3 files changed, 81 insertions(+), 33 deletions(-) (limited to 'backend/models') diff --git a/backend/constants.py b/backend/constants.py index f5e4304..bfcf261 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -62,3 +62,12 @@ class FormFeatures(Enum): COLLECT_EMAIL = "COLLECT_EMAIL" DISABLE_ANTISPAM = "DISABLE_ANTISPAM" WEBHOOK_ENABLED = "WEBHOOK_ENABLED" + + +class WebHook(Enum): + URL = "url" + MESSAGE = "message" + + +class Meta(Enum): + WEB_HOOK = WebHook diff --git a/backend/models/form.py b/backend/models/form.py index 9a3a8a2..96362d4 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -2,8 +2,9 @@ import typing as t import httpx from pydantic import BaseModel, Field, validator +from pydantic.error_wrappers import ErrorWrapper, ValidationError -from backend.constants import FormFeatures +from backend.constants import FormFeatures, Meta, WebHook from .question import Question PUBLIC_FIELDS = ["id", "features", "questions", "name", "description"] @@ -16,40 +17,10 @@ class _WebHook(BaseModel): @validator("url") def validate_url(cls, url: str) -> str: - """Checks if discord webhook urls are valid.""" - if not isinstance(url, str): - raise ValueError("Webhook URL must be a string.") - + """Validates URL parameter.""" if "discord.com/api/webhooks/" not in url: raise ValueError("URL must be a discord webhook.") - # Attempt to connect to URL - try: - httpx.get(url).raise_for_status() - - except httpx.RequestError as e: - # Catch exceptions in request format - raise ValueError( - f"Encountered error while trying to connect to url: `{e}`" - ) - - except httpx.HTTPStatusError as e: - # Catch exceptions in response - status = e.response.status_code - - if status == 401: - raise ValueError( - "Could not authenticate with target. Please check the webhook url." - ) - elif status == 404: - raise ValueError( - "Target could not find webhook url. Please check the webhook url." - ) - else: - raise ValueError( - f"Unknown error ({status}) while connecting to target: {e}" - ) - return url @@ -107,3 +78,55 @@ class Form(BaseModel): class FormList(BaseModel): __root__: t.List[Form] + + +async def validate_hook_url(url: str) -> t.Optional[ValidationError]: + """Validator for discord webhook urls.""" + async def validate(): + if not isinstance(url, str): + raise ValueError("Webhook URL must be a string.") + + if "discord.com/api/webhooks/" not in url: + raise ValueError("URL must be a discord webhook.") + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + + except httpx.RequestError as error: + # Catch exceptions in request format + raise ValueError( + f"Encountered error while trying to connect to url: `{error}`" + ) + + except httpx.HTTPStatusError as error: + # Catch exceptions in response + status = error.response.status_code + + if status == 401: + raise ValueError( + "Could not authenticate with target. Please check the webhook url." + ) + elif status == 404: + raise ValueError( + "Target could not find webhook url. Please check the webhook url." + ) + else: + raise ValueError( + f"Unknown error ({status}) while connecting to target: {error}" + ) + + return url + + # Validate, and return errors, if any + try: + await validate() + except Exception as e: + loc = ( + Meta.__name__.lower(), + WebHook.__name__.lower(), + WebHook.URL.value + ) + + return ValidationError([ErrorWrapper(e, loc=loc)], _WebHook) diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py index d1373e4..0e1dee8 100644 --- a/backend/routes/forms/index.py +++ b/backend/routes/forms/index.py @@ -6,8 +6,10 @@ from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse -from backend.route import Route +from backend.constants import Meta, WebHook from backend.models import Form, FormList +from backend.models.form import validate_hook_url +from backend.route import Route from backend.validation import ErrorMessage, OkayResponse, api @@ -46,6 +48,20 @@ class FormsList(Route): """Create a new form.""" form_data = await request.json() + # Verify Webhook + try: + # Get url from request + path = (Meta.__name__.lower(), WebHook.__name__.lower(), WebHook.URL.value) + url = form_data[path[0]][path[1]][path[2]] + + # Validate URL + validation = await validate_hook_url(url) + if validation: + return JSONResponse(validation.errors(), status_code=422) + + except KeyError: + pass + form = Form(**form_data) if await request.state.db.forms.find_one({"_id": form.id}): -- cgit v1.2.3 From 5250e5407e5c229de3b31cf06f784d847aedc9ac Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 18 Dec 2020 23:15:31 +0300 Subject: Adds Missing Annotation Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/models/form.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend/models') diff --git a/backend/models/form.py b/backend/models/form.py index 96362d4..d5e2ff5 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -82,7 +82,7 @@ class FormList(BaseModel): async def validate_hook_url(url: str) -> t.Optional[ValidationError]: """Validator for discord webhook urls.""" - async def validate(): + async def validate() -> t.Optional[str]: if not isinstance(url, str): raise ValueError("Webhook URL must be a string.") -- cgit v1.2.3