diff options
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | SCHEMA.md | 22 | ||||
| -rw-r--r-- | backend/constants.py | 2 | ||||
| -rw-r--r-- | backend/models/form.py | 51 | ||||
| -rw-r--r-- | backend/routes/forms/submit.py | 66 | 
5 files changed, 129 insertions, 13 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,13 @@ 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 `_USER_MENTION_` to mention the submitting user. | `"webhook": {"url": "https://discord.com/api/webhooks/id/key", "message": "Hello World! _USER_MENTION_"}` | +  ### Form question diff --git a/backend/constants.py b/backend/constants.py index 4218bff..f5e4304 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,4 @@ class FormFeatures(Enum):      OPEN = "OPEN"      COLLECT_EMAIL = "COLLECT_EMAIL"      DISABLE_ANTISPAM = "DISABLE_ANTISPAM" +    WEBHOOK_ENABLED = "WEBHOOK_ENABLED" diff --git a/backend/models/form.py b/backend/models/form.py index 9d8ffaa..9a3a8a2 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: t.Optional[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 = None + +  class Form(BaseModel):      """Schema model for form.""" @@ -16,6 +66,7 @@ class Form(BaseModel):      questions: list[Question]      name: str      description: str +    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 42fcf79..d8b178f 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -4,19 +4,19 @@ 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  import pydnsbl  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 @@ -133,11 +133,65 @@ 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 +                ) +              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) -> 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.") + +        user = response.user +        username = f"{user.username}#{user.discriminator}" if user else None +        user_mention = f"<@{user.id}>" if user else f"{username or 'User'}" + +        # Build Embed +        embed = { +            "title": "New Form Response", +            "description": f"{user_mention} submitted a response.", +            "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 user is not None: +            embed["author"] = {"name": username} + +            if user.avatar is not None: +                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: +            hook["content"] = message.replace("_USER_MENTION_", user_mention) + +        # Post hook +        async with httpx.AsyncClient() as client: +            r = await client.post(form.meta.webhook.url, json=hook) +            r.raise_for_status() | 
