diff options
-rw-r--r-- | README.md | 1 | ||||
-rw-r--r-- | SCHEMA.md | 34 | ||||
-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 |
7 files changed, 215 insertions, 15 deletions
@@ -26,6 +26,7 @@ You can also run forms-backend manually on the host. #### Environment variables Create a `.env` file with the same contents as the Docker section above and the following new variables: +- `FRONTEND_URL`: Forms frontend URL. - `DATABASE_URL`: MongoDB instance URI, in format `mongodb://(username):(password)@(database IP or domain):(port)`. - `MONGO_DB`: MongoDB database name, defaults to `pydis_forms`. @@ -12,13 +12,14 @@ 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."` | +| 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"]` | +| `meta` | Mapping of [meta options](#meta-options) | Meta properties for the form. | See meta-options section | +| `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."` | ### Form features @@ -29,6 +30,25 @@ In this document: | `OPEN` | The form is currently accepting responses. | | `COLLECT_EMAIL` | The form should collect the email from submissions. Requires `REQUIRES_LOGIN` | | `DISABLE_ANTISPAM` | Disable the anti-spam checks from running on a form submission. | +| `WEBHOOK_ENABLED` | The form should notify the webhook. Has no effect if no webhook is set. | + +### Meta options +| Field | Description | Example | +| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `webhook` | Mapping of webhook url and message. Message can use certain [context variables](#webhook-variables). | `"webhook": {"url": "https://discord.com/api/webhooks/id/key", "message": "{user} submitted a form."}` | + + +#### Webhook Variables +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. | +| `form_id` | ID of the submitted form. | +| `time` | ISO submission timestamp. | + ### Form question 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() |