Skip to content

Commit 35626c8

Browse files
authored
Merge pull request #17 from python-discord/ks123/response-model-endpoint
2 parents cbd1026 + 362c665 commit 35626c8

File tree

9 files changed

+342
-94
lines changed

9 files changed

+342
-94
lines changed

backend/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
SECRET_KEY = os.getenv("SECRET_KEY", binascii.hexlify(os.urandom(30)).decode())
2020

21+
HCAPTCHA_API_SECRET = os.getenv("HCAPTCHA_API_SECRET")
22+
2123
QUESTION_TYPES = [
2224
"radio",
2325
"checkbox",

backend/models/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from .antispam import AntiSpam
2+
from .discord_user import DiscordUser
13
from .form import Form
4+
from .form_response import FormResponse
25
from .question import Question
36

4-
__all__ = ["Form", "Question"]
7+
__all__ = ["AntiSpam", "DiscordUser", "Form", "FormResponse", "Question"]

backend/models/antispam.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from pydantic import BaseModel
2+
3+
4+
class AntiSpam(BaseModel):
5+
"""Schema model for form response antispam field."""
6+
7+
ip_hash: str
8+
user_agent_hash: str
9+
captcha_pass: bool
10+
dns_blacklisted: bool

backend/models/discord_user.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import typing as t
2+
3+
from pydantic import BaseModel
4+
5+
6+
class DiscordUser(BaseModel):
7+
"""Schema model of Discord user for form response."""
8+
9+
# Discord default fields
10+
id: int # This is actually snowflake, but we simplify it here
11+
username: str
12+
discriminator: str
13+
avatar: t.Optional[str]
14+
bot: t.Optional[bool]
15+
system: t.Optional[bool]
16+
locale: t.Optional[str]
17+
verified: t.Optional[bool]
18+
email: t.Optional[str]
19+
flags: t.Optional[int]
20+
premium_type: t.Optional[int]
21+
public_flags: t.Optional[int]
22+
23+
# Custom fields
24+
admin: bool

backend/models/form_response.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import typing as t
2+
3+
from pydantic import BaseModel, Field
4+
5+
from .antispam import AntiSpam
6+
from .discord_user import DiscordUser
7+
8+
9+
class FormResponse(BaseModel):
10+
"""Schema model for form response."""
11+
12+
id: str = Field(alias="_id")
13+
user: t.Optional[DiscordUser]
14+
antispam: t.Optional[AntiSpam]
15+
response: t.Dict[str, t.Any]
16+
form_id: str
17+
18+
class Config:
19+
allow_population_by_field_name = True

backend/routes/forms/new.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,10 @@ async def post(self, request: Request) -> JSONResponse:
2626
except ValidationError as e:
2727
return JSONResponse(e.errors())
2828

29+
if await request.state.db.forms.find_one({"_id": form.id}):
30+
return JSONResponse({
31+
"error": "Form with same ID already exists."
32+
}, status_code=400)
33+
2934
await request.state.db.forms.insert_one(form.dict(by_alias=True))
3035
return JSONResponse(form.dict())

backend/routes/forms/submit.py

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@
44

55
import binascii
66
import hashlib
7+
import uuid
78

8-
import jwt
9+
import httpx
10+
import pydnsbl
11+
from pydantic import ValidationError
912
from starlette.requests import Request
1013

1114
from starlette.responses import JSONResponse
1215

13-
from backend.constants import SECRET_KEY
16+
from backend.constants import HCAPTCHA_API_SECRET, FormFeatures
17+
from backend.models import Form, FormResponse
1418
from backend.route import Route
1519

20+
HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify"
21+
HCAPTCHA_HEADERS = {
22+
"Content-Type": "application/x-www-form-urlencoded"
23+
}
24+
1625

1726
class SubmitForm(Route):
1827
"""
@@ -28,40 +37,78 @@ async def post(self, request: Request) -> JSONResponse:
2837
if form := await request.state.db.forms.find_one(
2938
{"_id": request.path_params["form_id"], "features": "OPEN"}
3039
):
31-
response_obj = {}
40+
form = Form(**form)
41+
response = data.copy()
42+
response["id"] = str(uuid.uuid4())
43+
response["form_id"] = form.id
3244

33-
if "DISABLE_ANTISPAM" not in form["features"]:
45+
if FormFeatures.DISABLE_ANTISPAM.value not in form.features:
3446
ip_hash_ctx = hashlib.md5()
3547
ip_hash_ctx.update(request.client.host.encode())
3648
ip_hash = binascii.hexlify(ip_hash_ctx.digest())
49+
user_agent_hash_ctx = hashlib.md5()
50+
user_agent_hash_ctx.update(request.headers["User-Agent"].encode())
51+
user_agent_hash = binascii.hexlify(user_agent_hash_ctx.digest())
3752

38-
response_obj["antispam"] = {
39-
"ip": ip_hash.decode()
40-
}
53+
dsn_checker = pydnsbl.DNSBLIpChecker()
54+
dsn_blacklist = await dsn_checker.check_async(request.client.host)
4155

42-
if "REQUIRES_LOGIN" in form["features"]:
43-
if token := data.get("token"):
44-
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
45-
response_obj["user"] = {
46-
"user": f"{data['username']}#{data['discriminator']}",
47-
"id": data["id"]
56+
async with httpx.AsyncClient() as client:
57+
query_params = {
58+
"secret": HCAPTCHA_API_SECRET,
59+
"response": data.get("captcha")
4860
}
61+
r = await client.post(
62+
HCAPTCHA_VERIFY_URL,
63+
params=query_params,
64+
headers=HCAPTCHA_HEADERS
65+
)
66+
r.raise_for_status()
67+
captcha_data = r.json()
68+
69+
response["antispam"] = {
70+
"ip_hash": ip_hash.decode(),
71+
"user_agent_hash": user_agent_hash.decode(),
72+
"captcha_pass": captcha_data["success"],
73+
"dns_blacklisted": dsn_blacklist.blacklisted,
74+
}
75+
76+
if FormFeatures.REQUIRES_LOGIN.value in form.features:
77+
if request.user.is_authenticated:
78+
response["user"] = request.user.payload
4979

50-
if "COLLECT_EMAIL" in form["features"]:
51-
if data.get("email"):
52-
response_obj["user"]["email"] = data["email"]
53-
else:
54-
return JSONResponse({
55-
"error": "User data did not include email information"
56-
})
80+
if FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa
81+
return JSONResponse({
82+
"error": "email_required"
83+
}, status_code=400)
5784
else:
5885
return JSONResponse({
59-
"error": "Missing Discord user data"
60-
})
86+
"error": "missing_discord_data"
87+
}, status_code=400)
88+
89+
missing_fields = []
90+
for question in form.questions:
91+
if question.id not in response["response"]:
92+
missing_fields.append(question.id)
93+
94+
if missing_fields:
95+
return JSONResponse({
96+
"error": "missing_fields",
97+
"fields": missing_fields
98+
}, status_code=400)
99+
100+
try:
101+
response_obj = FormResponse(**response)
102+
except ValidationError as e:
103+
return JSONResponse(e.errors())
104+
105+
await request.state.db.responses.insert_one(
106+
response_obj.dict(by_alias=True)
107+
)
61108

62109
return JSONResponse({
63-
"form": form,
64-
"response": response_obj
110+
"form": form.dict(),
111+
"response": response_obj.dict()
65112
})
66113
else:
67114
return JSONResponse({

0 commit comments

Comments
 (0)