Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
34 changes: 27 additions & 7 deletions SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions backend/authentication/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ def is_authenticated(self) -> bool:
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']}>"
11 changes: 11 additions & 0 deletions backend/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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")

Expand Down Expand Up @@ -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
76 changes: 75 additions & 1 deletion backend/models/form.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand All @@ -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
Expand Down Expand Up @@ -56,3 +78,55 @@ def dict(self, admin: bool = True, **kwargs: t.Any) -> dict[str, t.Any]:

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)
18 changes: 17 additions & 1 deletion backend/routes/forms/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
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


Expand Down Expand Up @@ -46,6 +48,20 @@ async def post(self, request: Request) -> JSONResponse:
"""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}):
Expand Down
86 changes: 80 additions & 6 deletions backend/routes/forms/submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@

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
Expand Down Expand Up @@ -128,11 +128,85 @@ async def post(self, request: Request) -> JSONResponse:
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.")
Comment thread
jb3 marked this conversation as resolved.

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()