aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/review-policy.yml2
-rw-r--r--README.md1
-rw-r--r--SCHEMA.md34
-rw-r--r--backend/authentication/user.py4
-rw-r--r--backend/constants.py11
-rw-r--r--backend/models/form.py76
-rw-r--r--backend/routes/forms/index.py18
-rw-r--r--backend/routes/forms/submit.py86
8 files changed, 216 insertions, 16 deletions
diff --git a/.github/review-policy.yml b/.github/review-policy.yml
index 421b30f..c7df181 100644
--- a/.github/review-policy.yml
+++ b/.github/review-policy.yml
@@ -1,3 +1,3 @@
remote: python-discord/.github
-path: review-policies/core-developers.yml
+path: review-policies/forms.yml
ref: main
diff --git a/README.md b/README.md
index 671e6d5..3e38ef5 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/SCHEMA.md b/SCHEMA.md
index 59b6c33..fa5f247 100644
--- a/SCHEMA.md
+++ b/SCHEMA.md
@@ -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()