diff options
author | 2020-12-21 19:39:56 +0000 | |
---|---|---|
committer | 2020-12-21 19:39:56 +0000 | |
commit | 59a605290795c0563311993fb16d992972104025 (patch) | |
tree | d1b392efe12ccaa053e3f6253f12a471d94da253 /backend | |
parent | Remove validator import (diff) | |
parent | Merge pull request #39 from python-discord/discord-webhook (diff) |
Merge branch 'main' into big-int-fix
Diffstat (limited to 'backend')
-rw-r--r-- | backend/authentication/user.py | 4 | ||||
-rw-r--r-- | backend/constants.py | 11 | ||||
-rw-r--r-- | backend/models/form.py | 76 | ||||
-rw-r--r-- | backend/routes/forms/index.py | 18 | ||||
-rw-r--r-- | backend/routes/forms/submit.py | 86 |
5 files changed, 187 insertions, 8 deletions
diff --git a/backend/authentication/user.py b/backend/authentication/user.py index 722c348..f40c68c 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -19,3 +19,7 @@ class User(BaseUser): def display_name(self) -> str: """Return username and discriminator as display name.""" return f"{self.payload['username']}#{self.payload['discriminator']}" + + @property + def discord_mention(self) -> str: + return f"<@{self.payload['id']}>" diff --git a/backend/constants.py b/backend/constants.py index 4218bff..bfcf261 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -6,6 +6,7 @@ import binascii # noqa from enum import Enum # noqa +FRONTEND_URL = os.getenv("FRONTEND_URL", "https://forms.pythondiscord.com") DATABASE_URL = os.getenv("DATABASE_URL") MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms") @@ -60,3 +61,13 @@ class FormFeatures(Enum): OPEN = "OPEN" 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 9d8ffaa..d5e2ff5 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -1,13 +1,34 @@ 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"] +class _WebHook(BaseModel): + """Schema model of discord webhooks.""" + url: str + message: t.Optional[str] + + @validator("url") + def validate_url(cls, url: str) -> str: + """Validates URL parameter.""" + if "discord.com/api/webhooks/" not in url: + raise ValueError("URL must be a discord webhook.") + + return url + + +class _FormMeta(BaseModel): + """Schema model for form meta data.""" + webhook: _WebHook = None + + class Form(BaseModel): """Schema model for form.""" @@ -16,6 +37,7 @@ class Form(BaseModel): questions: list[Question] name: str description: str + meta: _FormMeta = _FormMeta() class Config: allow_population_by_field_name = True @@ -56,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() -> t.Optional[str]: + 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}): diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 5bcdeff..82caa81 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -4,18 +4,18 @@ Submit a form. import binascii import hashlib -from typing import Any, Optional import uuid +from typing import Any, Optional import httpx -from pydantic.main import BaseModel from pydantic import ValidationError +from pydantic.main import BaseModel from spectree import Response +from starlette.background import BackgroundTask from starlette.requests import Request - from starlette.responses import JSONResponse -from backend.constants import HCAPTCHA_API_SECRET, FormFeatures +from backend.constants import FRONTEND_URL, FormFeatures, HCAPTCHA_API_SECRET from backend.models import Form, FormResponse from backend.route import Route from backend.validation import AuthorizationHeaders, ErrorMessage, api @@ -128,11 +128,85 @@ class SubmitForm(Route): response_obj.dict(by_alias=True) ) + send_webhook = None + if FormFeatures.WEBHOOK_ENABLED.value in form.features: + send_webhook = BackgroundTask( + self.send_submission_webhook, + form=form, + response=response_obj, + request_user=request.user + ) + return JSONResponse({ - "form": form.dict(), + "form": form.dict(admin=False), "response": response_obj.dict() - }) + }, background=send_webhook) + else: return JSONResponse({ "error": "Open form not found" }, status_code=404) + + @staticmethod + async def send_submission_webhook( + form: Form, + response: FormResponse, + request_user: Request.user + ) -> None: + """Helper to send a submission message to a discord webhook.""" + # Stop if webhook is not available + if form.meta.webhook is None: + raise ValueError("Got empty webhook.") + + try: + mention = request_user.discord_mention + except AttributeError: + mention = "User" + + user = response.user + + # Build Embed + embed = { + "title": "New Form Response", + "description": f"{mention} submitted a response to `{form.name}`.", + "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}", # noqa # TODO: Enter Form View URL + "timestamp": response.timestamp, + "color": 7506394, + } + + # Add author to embed + if request_user.is_authenticated: + embed["author"] = {"name": request_user.display_name} + + if user and user.avatar: + url = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}.png" + embed["author"]["icon_url"] = url + + # Build Hook + hook = { + "embeds": [embed], + "allowed_mentions": {"parse": ["users", "roles"]}, + "username": form.name or "Python Discord Forms" + } + + # Set hook message + message = form.meta.webhook.message + if message: + # Available variables, see SCHEMA.md + ctx = { + "user": mention, + "response_id": response.id, + "form": form.name, + "form_id": form.id, + "time": response.timestamp, + } + + for key in ctx: + message = message.replace(f"{{{key}}}", str(ctx[key])) + + hook["content"] = message.replace("_USER_MENTION_", mention) + + # Post hook + async with httpx.AsyncClient() as client: + r = await client.post(form.meta.webhook.url, json=hook) + r.raise_for_status() |