diff options
Diffstat (limited to 'backend')
-rw-r--r-- | backend/constants.py | 2 | ||||
-rw-r--r-- | backend/models/__init__.py | 5 | ||||
-rw-r--r-- | backend/models/antispam.py | 10 | ||||
-rw-r--r-- | backend/models/discord_user.py | 24 | ||||
-rw-r--r-- | backend/models/form_response.py | 19 | ||||
-rw-r--r-- | backend/routes/forms/new.py | 5 | ||||
-rw-r--r-- | backend/routes/forms/submit.py | 95 |
7 files changed, 135 insertions, 25 deletions
diff --git a/backend/constants.py b/backend/constants.py index 3b8bec8..61519ed 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -18,6 +18,8 @@ OAUTH2_REDIRECT_URI = os.getenv( SECRET_KEY = os.getenv("SECRET_KEY", binascii.hexlify(os.urandom(30)).decode()) +HCAPTCHA_API_SECRET = os.getenv("HCAPTCHA_API_SECRET") + QUESTION_TYPES = [ "radio", "checkbox", diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 80abf6f..98fa619 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,4 +1,7 @@ +from .antispam import AntiSpam +from .discord_user import DiscordUser from .form import Form +from .form_response import FormResponse from .question import Question -__all__ = ["Form", "Question"] +__all__ = ["AntiSpam", "DiscordUser", "Form", "FormResponse", "Question"] diff --git a/backend/models/antispam.py b/backend/models/antispam.py new file mode 100644 index 0000000..b16f686 --- /dev/null +++ b/backend/models/antispam.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class AntiSpam(BaseModel): + """Schema model for form response antispam field.""" + + ip_hash: str + user_agent_hash: str + captcha_pass: bool + dns_blacklisted: bool diff --git a/backend/models/discord_user.py b/backend/models/discord_user.py new file mode 100644 index 0000000..e835176 --- /dev/null +++ b/backend/models/discord_user.py @@ -0,0 +1,24 @@ +import typing as t + +from pydantic import BaseModel + + +class DiscordUser(BaseModel): + """Schema model of Discord user for form response.""" + + # Discord default fields + id: int # This is actually snowflake, but we simplify it here + username: str + discriminator: str + avatar: t.Optional[str] + bot: t.Optional[bool] + system: t.Optional[bool] + locale: t.Optional[str] + verified: t.Optional[bool] + email: t.Optional[str] + flags: t.Optional[int] + premium_type: t.Optional[int] + public_flags: t.Optional[int] + + # Custom fields + admin: bool diff --git a/backend/models/form_response.py b/backend/models/form_response.py new file mode 100644 index 0000000..bea070f --- /dev/null +++ b/backend/models/form_response.py @@ -0,0 +1,19 @@ +import typing as t + +from pydantic import BaseModel, Field + +from .antispam import AntiSpam +from .discord_user import DiscordUser + + +class FormResponse(BaseModel): + """Schema model for form response.""" + + id: str = Field(alias="_id") + user: t.Optional[DiscordUser] + antispam: t.Optional[AntiSpam] + response: t.Dict[str, t.Any] + form_id: str + + class Config: + allow_population_by_field_name = True diff --git a/backend/routes/forms/new.py b/backend/routes/forms/new.py index ff39f12..6437a4a 100644 --- a/backend/routes/forms/new.py +++ b/backend/routes/forms/new.py @@ -26,5 +26,10 @@ class FormCreate(Route): except ValidationError as e: return JSONResponse(e.errors()) + if await request.state.db.forms.find_one({"_id": form.id}): + return JSONResponse({ + "error": "Form with same ID already exists." + }, status_code=400) + await request.state.db.forms.insert_one(form.dict(by_alias=True)) return JSONResponse(form.dict()) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index a94a1c9..3ecbda0 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -4,15 +4,24 @@ Submit a form. import binascii import hashlib +import uuid -import jwt +import httpx +import pydnsbl +from pydantic import ValidationError from starlette.requests import Request from starlette.responses import JSONResponse -from backend.constants import SECRET_KEY +from backend.constants import HCAPTCHA_API_SECRET, FormFeatures +from backend.models import Form, FormResponse from backend.route import Route +HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify" +HCAPTCHA_HEADERS = { + "Content-Type": "application/x-www-form-urlencoded" +} + class SubmitForm(Route): """ @@ -28,40 +37,78 @@ class SubmitForm(Route): if form := await request.state.db.forms.find_one( {"_id": request.path_params["form_id"], "features": "OPEN"} ): - response_obj = {} + form = Form(**form) + response = data.copy() + response["id"] = str(uuid.uuid4()) + response["form_id"] = form.id - if "DISABLE_ANTISPAM" not in form["features"]: + if FormFeatures.DISABLE_ANTISPAM.value not in form.features: ip_hash_ctx = hashlib.md5() ip_hash_ctx.update(request.client.host.encode()) ip_hash = binascii.hexlify(ip_hash_ctx.digest()) + user_agent_hash_ctx = hashlib.md5() + user_agent_hash_ctx.update(request.headers["User-Agent"].encode()) + user_agent_hash = binascii.hexlify(user_agent_hash_ctx.digest()) - response_obj["antispam"] = { - "ip": ip_hash.decode() - } + dsn_checker = pydnsbl.DNSBLIpChecker() + dsn_blacklist = await dsn_checker.check_async(request.client.host) - if "REQUIRES_LOGIN" in form["features"]: - if token := data.get("token"): - data = jwt.decode(token, SECRET_KEY, algorithms=['HS256']) - response_obj["user"] = { - "user": f"{data['username']}#{data['discriminator']}", - "id": data["id"] + async with httpx.AsyncClient() as client: + query_params = { + "secret": HCAPTCHA_API_SECRET, + "response": data.get("captcha") } + r = await client.post( + HCAPTCHA_VERIFY_URL, + params=query_params, + headers=HCAPTCHA_HEADERS + ) + r.raise_for_status() + captcha_data = r.json() + + response["antispam"] = { + "ip_hash": ip_hash.decode(), + "user_agent_hash": user_agent_hash.decode(), + "captcha_pass": captcha_data["success"], + "dns_blacklisted": dsn_blacklist.blacklisted, + } + + if FormFeatures.REQUIRES_LOGIN.value in form.features: + if request.user.is_authenticated: + response["user"] = request.user.payload - if "COLLECT_EMAIL" in form["features"]: - if data.get("email"): - response_obj["user"]["email"] = data["email"] - else: - return JSONResponse({ - "error": "User data did not include email information" - }) + if FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa + return JSONResponse({ + "error": "email_required" + }, status_code=400) else: return JSONResponse({ - "error": "Missing Discord user data" - }) + "error": "missing_discord_data" + }, status_code=400) + + missing_fields = [] + for question in form.questions: + if question.id not in response["response"]: + missing_fields.append(question.id) + + if missing_fields: + return JSONResponse({ + "error": "missing_fields", + "fields": missing_fields + }, status_code=400) + + try: + response_obj = FormResponse(**response) + except ValidationError as e: + return JSONResponse(e.errors()) + + await request.state.db.responses.insert_one( + response_obj.dict(by_alias=True) + ) return JSONResponse({ - "form": form, - "response": response_obj + "form": form.dict(), + "response": response_obj.dict() }) else: return JSONResponse({ |