diff options
Diffstat (limited to '')
| -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() | 
