From 7c01270f3e95c7eab12219714f7a27caaf33cacc Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 09:00:46 +0300 Subject: Adds Production Constant Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/constants.py | 2 ++ 1 file changed, 2 insertions(+) (limited to 'backend') diff --git a/backend/constants.py b/backend/constants.py index fedab64..af25d84 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -10,6 +10,8 @@ FRONTEND_URL = os.getenv("FRONTEND_URL", "https://forms.pythondiscord.com") DATABASE_URL = os.getenv("DATABASE_URL") MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms") +PRODUCTION = os.getenv("PRODUCTION", "True").lower() != "false" + OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") OAUTH2_REDIRECT_URI = os.getenv( -- cgit v1.2.3 From 7a16a6b129f754a5486c441f2602a8d593edb85f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 09:01:38 +0300 Subject: Adds Token Refresh Route Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/authentication/user.py | 17 +++++++++ backend/discord.py | 11 ++++-- backend/routes/auth/authorize.py | 81 +++++++++++++++++++++++++++++++++------- 3 files changed, 93 insertions(+), 16 deletions(-) (limited to 'backend') diff --git a/backend/authentication/user.py b/backend/authentication/user.py index f40c68c..a1d78e5 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -1,7 +1,11 @@ import typing as t +import jwt from starlette.authentication import BaseUser +from backend.constants import SECRET_KEY +from backend.discord import fetch_user_details + class User(BaseUser): """Starlette BaseUser implementation for JWT authentication.""" @@ -23,3 +27,16 @@ class User(BaseUser): @property def discord_mention(self) -> str: return f"<@{self.payload['id']}>" + + @property + def decoded_token(self) -> dict[str, any]: + return jwt.decode(self.token, SECRET_KEY, algorithms=["HS256"]) + + async def refresh_data(self) -> None: + """Fetches user data from discord, and updates the instance.""" + self.payload = await fetch_user_details(self.decoded_token.get("token")) + + updated_info = self.decoded_token + updated_info["user_details"] = self.payload + + self.token = jwt.encode(updated_info, SECRET_KEY, algorithm="HS256") diff --git a/backend/discord.py b/backend/discord.py index d6310b7..9cdd2c4 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -8,16 +8,21 @@ from backend.constants import ( API_BASE_URL = "https://discord.com/api/v8" -async def fetch_bearer_token(access_code: str) -> dict: +async def fetch_bearer_token(code: str, *, refresh: bool) -> dict: async with httpx.AsyncClient() as client: data = { "client_id": OAUTH2_CLIENT_ID, "client_secret": OAUTH2_CLIENT_SECRET, - "grant_type": "authorization_code", - "code": access_code, "redirect_uri": OAUTH2_REDIRECT_URI } + if refresh: + data["grant_type"] = "refresh_token" + data["refresh_token"] = code + else: + data["grant_type"] = "authorization_code" + data["code"] = code + r = await client.post(f"{API_BASE_URL}/oauth2/token", headers={ "Content-Type": "application/x-www-form-urlencoded" }, data=data) diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 975936a..2244152 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -2,17 +2,23 @@ Use a token received from the Discord OAuth2 system to fetch user information. """ +import datetime +from typing import Union + import httpx import jwt from pydantic.fields import Field from pydantic.main import BaseModel from spectree.response import Response +from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse +from backend import constants +from backend.authentication.user import User from backend.constants import SECRET_KEY -from backend.route import Route from backend.discord import fetch_bearer_token, fetch_user_details +from backend.route import Route from backend.validation import ErrorMessage, api @@ -21,7 +27,42 @@ class AuthorizeRequest(BaseModel): class AuthorizeResponse(BaseModel): - token: str = Field(description="A JWT token containing the user information") + username: str = Field("Discord display name.") + + +AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) + + +async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAILURE]: + """Post a bearer token to Discord, and return a JWT and username.""" + interaction_start = datetime.datetime.now() + + try: + user_details = await fetch_user_details(bearer_token["access_token"]) + except httpx.HTTPStatusError: + AUTH_FAILURE.delete_cookie("BackendToken") + return AUTH_FAILURE + + max_age = datetime.timedelta(seconds=int(bearer_token["expires_in"])) + token_expiry = interaction_start + max_age + + data = { + "token": bearer_token["access_token"], + "refresh": bearer_token["refresh_token"], + "user_details": user_details, + "expiry": token_expiry.isoformat() + } + + token = jwt.encode(data, SECRET_KEY, algorithm="HS256") + user = User(token, user_details) + + response = JSONResponse({"username": user.display_name}) + response.set_cookie( + "BackendToken", f"JWT {token}", + secure=constants.PRODUCTION, httponly=True, samesite="strict", + max_age=bearer_token["expires_in"] + ) + return response class AuthorizeRoute(Route): @@ -40,19 +81,33 @@ class AuthorizeRoute(Route): async def post(self, request: Request) -> JSONResponse: """Generate an authorization token.""" data = await request.json() - try: - bearer_token = await fetch_bearer_token(data["token"]) - user_details = await fetch_user_details(bearer_token["access_token"]) + bearer_token = await fetch_bearer_token(data["token"], refresh=False) except httpx.HTTPStatusError: - return JSONResponse({ - "error": "auth_failure" - }, status_code=400) + return AUTH_FAILURE + + return await process_token(bearer_token) - user_details["admin"] = await request.state.db.admins.find_one( - {"_id": user_details["id"]} - ) is not None - token = jwt.encode(user_details, SECRET_KEY, algorithm="HS256") +class TokenRefreshRoute(Route): + """ + Use the refresh code from a JWT to get a new token and generate a new JWT token. + """ + + name = "refresh" + path = "/refresh" + + @requires(["authenticated"]) + @api.validate( + resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), + tags=["auth"] + ) + async def post(self, request: Request) -> JSONResponse: + """Refresh an authorization token.""" + try: + token = request.user.decoded_token.get("refresh") + bearer_token = await fetch_bearer_token(token, refresh=True) + except httpx.HTTPStatusError: + return AUTH_FAILURE - return JSONResponse({"token": token}) + return await process_token(bearer_token) -- cgit v1.2.3 From 10a2afbf27b052ba3561709bcda1ae2924b90cd2 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 09:10:38 +0300 Subject: Refreshes User Data On Form Submit Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/authentication/backend.py | 42 +++++++++++++++++++++++++---------- backend/routes/forms/submit.py | 46 ++++++++++++++++++++++++++++++++------- 2 files changed, 69 insertions(+), 19 deletions(-) (limited to 'backend') diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index f1d2ece..abe7313 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -1,6 +1,6 @@ -import jwt import typing as t +import jwt from starlette import authentication from starlette.requests import Request @@ -13,18 +13,18 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): """Custom Starlette authentication backend for JWT.""" @staticmethod - def get_token_from_header(header: str) -> str: - """Parse JWT token from header value.""" + def get_token_from_cookie(cookie: str) -> str: + """Parse JWT token from cookie.""" try: - prefix, token = header.split() + prefix, token = cookie.split() except ValueError: raise authentication.AuthenticationError( - "Unable to split prefix and token from Authorization header." + "Unable to split prefix and token from authorization cookie." ) if prefix.upper() != "JWT": raise authentication.AuthenticationError( - f"Invalid Authorization header prefix '{prefix}'." + f"Invalid authorization cookie prefix '{prefix}'." ) return token @@ -33,11 +33,11 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): self, request: Request ) -> t.Optional[tuple[authentication.AuthCredentials, authentication.BaseUser]]: """Handles JWT authentication process.""" - if "Authorization" not in request.headers: + cookie = request.cookies.get("BackendToken") + if not cookie: return None - auth = request.headers["Authorization"] - token = self.get_token_from_header(auth) + token = self.get_token_from_cookie(cookie) try: payload = jwt.decode(token, constants.SECRET_KEY, algorithms=["HS256"]) @@ -46,7 +46,27 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): scopes = ["authenticated"] - if payload.get("admin") is True: + if not payload.get("token"): + raise authentication.AuthenticationError("Token is missing from JWT.") + if not payload.get("refresh"): + raise authentication.AuthenticationError( + "Refresh token is missing from JWT." + ) + + try: + user_details = payload.get("user_details") + if not user_details or not user_details.get("id"): + raise authentication.AuthenticationError("Improper user details.") + except Exception: + raise authentication.AuthenticationError("Could not parse user details.") + + admin = await request.state.db.admins.find_one( + {"_id": user_details["id"]} + ) is not None + + if admin: scopes.append("admin") - return authentication.AuthCredentials(scopes), User(token, payload) + user = User(token, user_details) + + return authentication.AuthCredentials(scopes), user diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index d8e6d35..ec9b24f 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -3,6 +3,7 @@ Submit a form. """ import binascii +import datetime import hashlib import uuid from typing import Any, Optional @@ -15,7 +16,8 @@ from starlette.background import BackgroundTask from starlette.requests import Request from starlette.responses import JSONResponse -from backend.constants import FRONTEND_URL, FormFeatures, HCAPTCHA_API_SECRET +from backend import constants +from backend.authentication.user import User from backend.models import Form, FormResponse from backend.route import Route from backend.validation import AuthorizationHeaders, ErrorMessage, api @@ -56,8 +58,36 @@ class SubmitForm(Route): ) async def post(self, request: Request) -> JSONResponse: """Submit a response to the form.""" - data = await request.json() + response = await self.submit(request) + + # Silently try to update user data + try: + if hasattr(request.user, User.refresh_data.__name__): + old = request.user.token + await request.user.refresh_data() + + if old != request.user.token: + try: + expiry = datetime.datetime.fromisoformat( + request.user.decoded_token.get("expiry") + ) + except ValueError: + expiry = None + + response.set_cookie( + "BackendToken", f"JWT {request.user.token}", + secure=constants.PRODUCTION, httponly=True, samesite="strict", + max_age=(expiry - datetime.datetime.now()).seconds + ) + except httpx.HTTPStatusError: + pass + + return response + + async def submit(self, request: Request) -> JSONResponse: + """Helper method for handling submission logic.""" + data = await request.json() data["timestamp"] = None if form := await request.state.db.forms.find_one( @@ -68,7 +98,7 @@ class SubmitForm(Route): response["id"] = str(uuid.uuid4()) response["form_id"] = form.id - if FormFeatures.DISABLE_ANTISPAM.value not in form.features: + if constants.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()) @@ -78,7 +108,7 @@ class SubmitForm(Route): async with httpx.AsyncClient() as client: query_params = { - "secret": HCAPTCHA_API_SECRET, + "secret": constants.HCAPTCHA_API_SECRET, "response": data.get("captcha") } r = await client.post( @@ -95,11 +125,11 @@ class SubmitForm(Route): "captcha_pass": captcha_data["success"] } - if FormFeatures.REQUIRES_LOGIN.value in form.features: + if constants.FormFeatures.REQUIRES_LOGIN.value in form.features: if request.user.is_authenticated: response["user"] = request.user.payload - if FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa + if constants.FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa return JSONResponse({ "error": "email_required" }, status_code=400) @@ -132,7 +162,7 @@ class SubmitForm(Route): ) send_webhook = None - if FormFeatures.WEBHOOK_ENABLED.value in form.features: + if constants.FormFeatures.WEBHOOK_ENABLED.value in form.features: send_webhook = BackgroundTask( self.send_submission_webhook, form=form, @@ -172,7 +202,7 @@ class SubmitForm(Route): 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 + "url": f"{constants.FRONTEND_URL}/path_to_view_form/{response.id}", # noqa # TODO: Enter Form View URL "timestamp": response.timestamp, "color": 7506394, } -- cgit v1.2.3 From 839525ef99ca353a107812cefb006e22a5f3f7f4 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 09:49:09 +0300 Subject: Remove AuthorizationHeaders Class Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/forms/submit.py | 3 +-- backend/validation.py | 11 ----------- 2 files changed, 1 insertion(+), 13 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index ec9b24f..55a4875 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -20,7 +20,7 @@ from backend import constants from backend.authentication.user import User from backend.models import Form, FormResponse from backend.route import Route -from backend.validation import AuthorizationHeaders, ErrorMessage, api +from backend.validation import ErrorMessage, api HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify" HCAPTCHA_HEADERS = { @@ -53,7 +53,6 @@ class SubmitForm(Route): HTTP_404=ErrorMessage, HTTP_400=ErrorMessage ), - headers=AuthorizationHeaders, tags=["forms", "responses"] ) async def post(self, request: Request) -> JSONResponse: diff --git a/backend/validation.py b/backend/validation.py index e696683..8771924 100644 --- a/backend/validation.py +++ b/backend/validation.py @@ -1,6 +1,5 @@ """Utilities for providing API payload validation.""" -from typing import Optional from pydantic.fields import Field from pydantic.main import BaseModel from spectree import SpecTree @@ -18,13 +17,3 @@ class ErrorMessage(BaseModel): class OkayResponse(BaseModel): status: str = "ok" - - -class AuthorizationHeaders(BaseModel): - authorization: Optional[str] = Field( - title="Authorization", - description=( - "The Authorization JWT token received from the " - "authorize route in the format `JWT {token}`" - ) - ) -- cgit v1.2.3 From 423a1bdf2e89b73ac2aca10e1a20891d5fc01715 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 10:12:46 +0300 Subject: Adds CORS Rules Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/__init__.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) (limited to 'backend') diff --git a/backend/__init__.py b/backend/__init__.py index a3704a0..d56edfb 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -7,10 +7,20 @@ from starlette.middleware.cors import CORSMiddleware from backend import constants from backend.authentication import JWTAuthenticationBackend -from backend.route_manager import create_route_map from backend.middleware import DatabaseMiddleware, ProtectedDocsMiddleware +from backend.route_manager import create_route_map from backend.validation import api +ORIGINS = [ + r"(https://[^.?#]*--pydis-forms\.netlify\.app)", # Netlify Previews + r"(https?://[^.?#]*.forms-frontend.pages.dev)", # Cloudflare Previews +] +if not constants.PRODUCTION: + # Add localhost to allowed origins on non-production deployments + ORIGINS.append(r"(https?://localhost:\d{0,4})") + +ALLOW_ORIGIN_REGEX = "|".join(ORIGINS) + sentry_sdk.init( dsn=constants.FORMS_BACKEND_DSN, send_default_pii=True, @@ -20,13 +30,13 @@ sentry_sdk.init( middleware = [ Middleware( CORSMiddleware, - # TODO: Convert this into a RegEx that works for prod, netlify & previews - allow_origins=["*"], + allow_origins=["https://forms.pythondiscord.com"], + allow_origin_regex=ALLOW_ORIGIN_REGEX, allow_headers=[ - "Authorization", "Content-Type" ], - allow_methods=["*"] + allow_methods=["*"], + allow_credentials=True ), Middleware(DatabaseMiddleware), Middleware(AuthenticationMiddleware, backend=JWTAuthenticationBackend()), -- cgit v1.2.3 From f6b09f5366a0921d12707c444a8bd86e05b7df19 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 19 Feb 2021 10:13:17 +0300 Subject: Adds Expiry To Authorization Routes Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 2244152..c6cd86c 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -28,6 +28,7 @@ class AuthorizeRequest(BaseModel): class AuthorizeResponse(BaseModel): username: str = Field("Discord display name.") + expiry: str = Field("ISO formatted timestamp of expiry.") AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) @@ -56,7 +57,11 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI token = jwt.encode(data, SECRET_KEY, algorithm="HS256") user = User(token, user_details) - response = JSONResponse({"username": user.display_name}) + response = JSONResponse({ + "username": user.display_name, + "expiry": token_expiry.isoformat() + }) + response.set_cookie( "BackendToken", f"JWT {token}", secure=constants.PRODUCTION, httponly=True, samesite="strict", -- cgit v1.2.3 From 3c4f7e71cb1ecdfd8d255b02cf44adcd90f32f01 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 20 Feb 2021 03:45:16 +0300 Subject: Centralizes Admin Authentication Sets admin authentication on authenticator to allow the addition and removal of admins without creating a new token. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/authentication/backend.py | 9 ++------- backend/authentication/user.py | 9 +++++++++ backend/routes/forms/form.py | 2 +- backend/routes/forms/submit.py | 1 + 4 files changed, 13 insertions(+), 8 deletions(-) (limited to 'backend') diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index abe7313..bdff796 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -60,13 +60,8 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): except Exception: raise authentication.AuthenticationError("Could not parse user details.") - admin = await request.state.db.admins.find_one( - {"_id": user_details["id"]} - ) is not None - - if admin: - scopes.append("admin") - user = User(token, user_details) + if user.fetch_admin_status(request): + scopes.append("admin") return authentication.AuthCredentials(scopes), user diff --git a/backend/authentication/user.py b/backend/authentication/user.py index a1d78e5..52baa61 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -2,6 +2,7 @@ import typing as t import jwt from starlette.authentication import BaseUser +from starlette.requests import Request from backend.constants import SECRET_KEY from backend.discord import fetch_user_details @@ -13,6 +14,7 @@ class User(BaseUser): def __init__(self, token: str, payload: dict[str, t.Any]) -> None: self.token = token self.payload = payload + self.admin = False @property def is_authenticated(self) -> bool: @@ -32,6 +34,13 @@ class User(BaseUser): def decoded_token(self) -> dict[str, any]: return jwt.decode(self.token, SECRET_KEY, algorithms=["HS256"]) + def fetch_admin_status(self, request: Request) -> bool: + self.admin = request.state.db.admins.find_one( + {"_id": self.payload["id"]} + ) is not None + + return self.admin + async def refresh_data(self) -> None: """Fetches user data from discord, and updates the instance.""" self.payload = await fetch_user_details(self.decoded_token.get("token")) diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index b6b722e..e3360b1 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -26,7 +26,7 @@ class SingleForm(Route): @api.validate(resp=Response(HTTP_200=Form, HTTP_404=ErrorMessage), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Returns single form information by ID.""" - admin = request.user.payload["admin"] if request.user.is_authenticated else False # noqa + admin = request.user.admin if request.user.is_authenticated else False filters = { "_id": request.path_params["form_id"] diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 55a4875..8627a29 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -127,6 +127,7 @@ class SubmitForm(Route): if constants.FormFeatures.REQUIRES_LOGIN.value in form.features: if request.user.is_authenticated: response["user"] = request.user.payload + response["user"]["admin"] = request.user.admin if constants.FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa return JSONResponse({ -- cgit v1.2.3 From f90d0c7fddb81215b907808b8365f63f42344652 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 21 Feb 2021 01:44:01 +0300 Subject: Dynamically Selects OAuth Redirect URI Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/discord.py | 6 +++--- backend/routes/auth/authorize.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) (limited to 'backend') diff --git a/backend/discord.py b/backend/discord.py index 9cdd2c4..8cb602c 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -2,18 +2,18 @@ import httpx from backend.constants import ( - OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_REDIRECT_URI + OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET ) API_BASE_URL = "https://discord.com/api/v8" -async def fetch_bearer_token(code: str, *, refresh: bool) -> dict: +async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict: async with httpx.AsyncClient() as client: data = { "client_id": OAUTH2_CLIENT_ID, "client_secret": OAUTH2_CLIENT_SECRET, - "redirect_uri": OAUTH2_REDIRECT_URI + "redirect_uri": f"{redirect}/callback" } if refresh: diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index c6cd86c..65709ab 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -87,7 +87,8 @@ class AuthorizeRoute(Route): """Generate an authorization token.""" data = await request.json() try: - bearer_token = await fetch_bearer_token(data["token"], refresh=False) + url = request.headers.get("origin") + bearer_token = await fetch_bearer_token(data["token"], url, refresh=False) except httpx.HTTPStatusError: return AUTH_FAILURE @@ -111,7 +112,8 @@ class TokenRefreshRoute(Route): """Refresh an authorization token.""" try: token = request.user.decoded_token.get("refresh") - bearer_token = await fetch_bearer_token(token, refresh=True) + url = request.headers.get("origin") + bearer_token = await fetch_bearer_token(token, url, refresh=True) except httpx.HTTPStatusError: return AUTH_FAILURE -- cgit v1.2.3 From c31bb72067d5192cbf8fb4ec523ee90ec32693d1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 12:05:46 +0100 Subject: Add snekbox to the environment --- backend/constants.py | 1 + docker-compose.yml | 8 ++++++++ 2 files changed, 9 insertions(+) (limited to 'backend') diff --git a/backend/constants.py b/backend/constants.py index fedab64..cccf437 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -9,6 +9,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") +SNEKBOX_URL = os.getenv("SNEKBOX_URL", "http://snekbox.default.svc.cluster.local/eval") OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") diff --git a/docker-compose.yml b/docker-compose.yml index d44b4e0..fd2eee4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,13 @@ services: MONGO_INITDB_ROOT_PASSWORD: forms-backend MONGO_INITDB_DATABASE: pydis_forms + snekbox: + image: ghcr.io/python-discord/snekbox:latest + ipc: none + ports: + - "127.0.0.1:8060:8060" + privileged: true + backend: build: context: . @@ -19,6 +26,7 @@ services: - "127.0.0.1:8000:8000" depends_on: - mongo + - snekbox tty: true volumes: - .:/app:ro -- cgit v1.2.3 From 6c38d1f153211e1731ed805da992fa5978ead91e Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 12:07:41 +0100 Subject: Support code unit testing through snekbox --- backend/routes/forms/unittesting.py | 91 +++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 backend/routes/forms/unittesting.py (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py new file mode 100644 index 0000000..3e1d280 --- /dev/null +++ b/backend/routes/forms/unittesting.py @@ -0,0 +1,91 @@ +import ast +from collections import namedtuple +from textwrap import indent +from typing import Optional + +import httpx + +from backend.constants import SNEKBOX_URL +from backend.models import FormResponse, Form + +with open("resources/unittest_template.py") as file: + TEST_TEMPLATE = file.read() + + +UnittestResult = namedtuple("UnittestResult", "question_id return_code passed result") + + +def _make_unit_code(units: dict[str, str]) -> str: + result = "" + + for unit_name, unit_code in units.items(): + result += f"\ndef test_{unit_name}(unit):\n{indent(unit_code, ' ')}" + + return indent(result, " ") + + +def _make_user_code(code: str) -> str: + # Make sure that we we escape triple quotes and backslashes in the user code + code = code.replace('"""', '\\"""').replace("\\", "\\\\") + return f'USER_CODE = """{code}"""' + + +async def _post_eval(code: str) -> Optional[dict[str, str]]: + data = {"input": code} + async with httpx.AsyncClient() as client: + response = await client.post(SNEKBOX_URL, json=data) + + if not response.status_code == 200: + return + + return response.json() + + +async def execute_unittest(form_response: FormResponse, form: Form) -> list[UnittestResult]: + unittest_results = [] + + for question in form.questions: + if question.type == "code" and "unittests" in question.data: + passed = False + + unit_code = _make_unit_code(question.data["unittests"]) + user_code = _make_user_code(form_response.response[question.id]) + + code = TEST_TEMPLATE.replace("### USER CODE", user_code).replace("### UNIT CODE", unit_code) + + # Make sure that the code is well formatted (we don't check for the user code) + try: + ast.parse(code) + except SyntaxError: + return_code = 99 + result = "Invalid generated unit code." + + else: + response = await _post_eval(code) + + if not response: + return_code = 99 + result = "Unable to contact code runner." + else: + return_code = int(response["returncode"]) + + if return_code not in (0, 5, 99): + return_code = 99 + result = "Internal error." + else: + stdout = response["stdout"] + passed = bool(int(stdout[0])) + + if not passed: + result = stdout[1:].strip() + else: + result = "" + + unittest_results.append(UnittestResult( + question_id=question.id, + return_code=return_code, + passed=passed, + result=result + )) + + return unittest_results -- cgit v1.2.3 From 96c659fce17a5aca17fb913cf587765bac90481f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 12:08:11 +0100 Subject: Hook up unittesting in the submit protocol --- backend/routes/forms/submit.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'backend') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index d8e6d35..85a4226 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -18,6 +18,7 @@ from starlette.responses import JSONResponse from backend.constants import FRONTEND_URL, FormFeatures, HCAPTCHA_API_SECRET from backend.models import Form, FormResponse from backend.route import Route +from backend.routes.forms.unittesting import execute_unittest from backend.validation import AuthorizationHeaders, ErrorMessage, api HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify" @@ -127,6 +128,19 @@ class SubmitForm(Route): except ValidationError as e: return JSONResponse(e.errors(), status_code=422) + has_unittests = any("unittests" in question.data for question in form.questions) + if has_unittests: + unittest_results = await execute_unittest(response_obj, form) + + was_successful = all(test.passed for test in unittest_results) + if not was_successful: + status_code = 500 if any(test.return_code == 99 for test in unittest_results) else 200 + + return JSONResponse({ + "error": "failed_tests", + "test_results": [test._asdict() for test in unittest_results if not test.passed] + }, status_code=status_code) + await request.state.db.responses.insert_one( response_obj.dict(by_alias=True) ) -- cgit v1.2.3 From da6b581185e8bbe37e561a05827c8517824c7d2c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 13:53:08 +0100 Subject: Switch to 100 chars line length and get rid of the noqas --- backend/constants.py | 8 ++++---- backend/models/form.py | 2 +- backend/routes/forms/form.py | 2 +- backend/routes/forms/submit.py | 15 +++++++++++---- backend/routes/forms/unittesting.py | 3 ++- tox.ini | 4 +++- 6 files changed, 22 insertions(+), 12 deletions(-) (limited to 'backend') diff --git a/backend/constants.py b/backend/constants.py index cccf437..59b56e0 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,9 +1,9 @@ from dotenv import load_dotenv -load_dotenv() +import os +import binascii +from enum import Enum -import os # noqa -import binascii # noqa -from enum import Enum # noqa +load_dotenv() FRONTEND_URL = os.getenv("FRONTEND_URL", "https://forms.pythondiscord.com") diff --git a/backend/models/form.py b/backend/models/form.py index 8e59905..eac0b63 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -47,7 +47,7 @@ class Form(BaseModel): if any(v not in allowed_values for v in value): raise ValueError("Form features list contains one or more invalid values.") - if FormFeatures.COLLECT_EMAIL in value and FormFeatures.REQUIRES_LOGIN not in value: # noqa + if FormFeatures.COLLECT_EMAIL in value and FormFeatures.REQUIRES_LOGIN not in value: raise ValueError("COLLECT_EMAIL feature require REQUIRES_LOGIN feature.") return value diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index b6b722e..e5f7ec6 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -26,7 +26,7 @@ class SingleForm(Route): @api.validate(resp=Response(HTTP_200=Form, HTTP_404=ErrorMessage), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Returns single form information by ID.""" - admin = request.user.payload["admin"] if request.user.is_authenticated else False # noqa + admin = request.user.payload["admin"] if request.user.is_authenticated else False filters = { "_id": request.path_params["form_id"] diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 85a4226..c19fc2d 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -100,7 +100,10 @@ class SubmitForm(Route): if request.user.is_authenticated: response["user"] = request.user.payload - if FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa + if ( + FormFeatures.COLLECT_EMAIL.value in form.features + and "email" not in response["user"] + ): return JSONResponse({ "error": "email_required" }, status_code=400) @@ -134,11 +137,15 @@ class SubmitForm(Route): was_successful = all(test.passed for test in unittest_results) if not was_successful: - status_code = 500 if any(test.return_code == 99 for test in unittest_results) else 200 + status_code = 500 if any( + test.return_code == 99 for test in unittest_results + ) else 200 return JSONResponse({ "error": "failed_tests", - "test_results": [test._asdict() for test in unittest_results if not test.passed] + "test_results": [ + test._asdict() for test in unittest_results if not test.passed + ] }, status_code=status_code) await request.state.db.responses.insert_one( @@ -186,7 +193,7 @@ class SubmitForm(Route): 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 + "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}", # TODO: Enter Form View URL "timestamp": response.timestamp, "color": 7506394, } diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 3e1d280..fe8320f 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -51,7 +51,8 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit unit_code = _make_unit_code(question.data["unittests"]) user_code = _make_user_code(form_response.response[question.id]) - code = TEST_TEMPLATE.replace("### USER CODE", user_code).replace("### UNIT CODE", unit_code) + code = TEST_TEMPLATE.replace("### USER CODE", user_code) + code = code.replace("### UNIT CODE", unit_code) # Make sure that the code is well formatted (we don't check for the user code) try: diff --git a/tox.ini b/tox.ini index 48a3da6..afb3b34 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [flake8] -max-line-length=88 +max-line-length=100 exclude=.cache,.venv,.git docstring-convention=all import-order-style=pycharm ignore= # Type annotations ANN101,ANN102 + # Line breaks + W503 -- cgit v1.2.3 From d69f80e083ed1b9d91716609c7c063968aef22fa Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 14:30:39 +0100 Subject: Return 403 on failed tests --- backend/routes/forms/submit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index c19fc2d..7618a33 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -139,7 +139,7 @@ class SubmitForm(Route): if not was_successful: status_code = 500 if any( test.return_code == 99 for test in unittest_results - ) else 200 + ) else 403 return JSONResponse({ "error": "failed_tests", -- cgit v1.2.3 From 3acf8d85447f1d58c8b3d0d6997828f166dfac5f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 14:46:44 +0100 Subject: Add support for hidden tests --- backend/routes/forms/unittesting.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index fe8320f..ddf0843 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -1,5 +1,6 @@ import ast from collections import namedtuple +from itertools import count from textwrap import indent from typing import Optional @@ -19,7 +20,7 @@ def _make_unit_code(units: dict[str, str]) -> str: result = "" for unit_name, unit_code in units.items(): - result += f"\ndef test_{unit_name}(unit):\n{indent(unit_code, ' ')}" + result += f"\ndef test_{unit_name.lstrip('#')}(unit):\n{indent(unit_code, ' ')}" return indent(result, " ") @@ -48,6 +49,13 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit if question.type == "code" and "unittests" in question.data: passed = False + hidden_test_counter = count(1) + hidden_tests = { + test.lstrip("#"): next(hidden_test_counter) + for test in question.data["unittests"].keys() + if test.startswith("#") + } + unit_code = _make_unit_code(question.data["unittests"]) user_code = _make_user_code(form_response.response[question.id]) @@ -78,7 +86,14 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit passed = bool(int(stdout[0])) if not passed: - result = stdout[1:].strip() + failed_tests = stdout[1:].strip().split(";") + + # Redact failed hidden tests + for i, failed_test in enumerate(failed_tests[:]): + if failed_test in hidden_tests: + failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" + + result = ";".join(failed_tests) else: result = "" -- cgit v1.2.3 From 6a1be658fd7fea03428f0ef1bbcce630ab290782 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 14:57:00 +0100 Subject: Censor unittests on GET /forms/$id --- backend/routes/forms/form.py | 3 +++ backend/routes/forms/unittesting.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) (limited to 'backend') diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index e5f7ec6..deb03ae 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -10,6 +10,7 @@ from starlette.responses import JSONResponse from backend.models import Form from backend.route import Route +from backend.routes.forms.unittesting import filter_unittests from backend.validation import ErrorMessage, OkayResponse, api @@ -37,6 +38,8 @@ class SingleForm(Route): if raw_form := await request.state.db.forms.find_one(filters): form = Form(**raw_form) + form = filter_unittests(form) + return JSONResponse(form.dict(admin=admin)) return JSONResponse({"error": "not_found"}, status_code=404) diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index ddf0843..0cb7d8d 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -16,6 +16,19 @@ with open("resources/unittest_template.py") as file: UnittestResult = namedtuple("UnittestResult", "question_id return_code passed result") +def filter_unittests(form: Form) -> Form: + """ + Replace the unittest data section of code questions with the number of test cases. + + This is used to redact the exact tests when sending the form back to the frontend. + """ + for question in form.questions: + if question.type == "code" and "unittests" in question.data: + question.data["unittests"] = len(question.data["unittests"]) + + return form + + def _make_unit_code(units: dict[str, str]) -> str: result = "" -- cgit v1.2.3 From 0a9026dcdd23eaf7c48256eb7da5af774892673b Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 15:16:13 +0100 Subject: Document unittest code --- backend/routes/forms/submit.py | 2 ++ backend/routes/forms/unittesting.py | 20 ++++++++++++++------ resources/unittest_template.py | 13 +++++++------ 3 files changed, 23 insertions(+), 12 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 7618a33..d6b549e 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -131,12 +131,14 @@ class SubmitForm(Route): except ValidationError as e: return JSONResponse(e.errors(), status_code=422) + # Run unittests if needed has_unittests = any("unittests" in question.data for question in form.questions) if has_unittests: unittest_results = await execute_unittest(response_obj, form) was_successful = all(test.passed for test in unittest_results) if not was_successful: + # Return 500 if we encountered an internal error (code 99). status_code = 500 if any( test.return_code == 99 for test in unittest_results ) else 403 diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 0cb7d8d..e038f3a 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -30,6 +30,7 @@ def filter_unittests(form: Form) -> Form: def _make_unit_code(units: dict[str, str]) -> str: + """Compose a dict mapping unit names to their code into an actual class body.""" result = "" for unit_name, unit_code in units.items(): @@ -39,14 +40,16 @@ def _make_unit_code(units: dict[str, str]) -> str: def _make_user_code(code: str) -> str: - # Make sure that we we escape triple quotes and backslashes in the user code - code = code.replace('"""', '\\"""').replace("\\", "\\\\") - return f'USER_CODE = """{code}"""' + """Compose the user code into an actual string variable.""" + # Make sure that we we escape triple quotes in the user code + code = code.replace('"""', '\\"""') + return f'USER_CODE = r"""{code}"""' async def _post_eval(code: str) -> Optional[dict[str, str]]: - data = {"input": code} + """Post the eval to snekbox and return the response.""" async with httpx.AsyncClient() as client: + data = {"input": code} response = await client.post(SNEKBOX_URL, json=data) if not response.status_code == 200: @@ -56,12 +59,14 @@ async def _post_eval(code: str) -> Optional[dict[str, str]]: async def execute_unittest(form_response: FormResponse, form: Form) -> list[UnittestResult]: + """Execute all the unittests in this form and return the results.""" unittest_results = [] for question in form.questions: if question.type == "code" and "unittests" in question.data: passed = False + # Tests starting with an hashtag should have censored names. hidden_test_counter = count(1) hidden_tests = { test.lstrip("#"): next(hidden_test_counter) @@ -69,19 +74,20 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit if test.startswith("#") } + # Compose runner code unit_code = _make_unit_code(question.data["unittests"]) user_code = _make_user_code(form_response.response[question.id]) code = TEST_TEMPLATE.replace("### USER CODE", user_code) code = code.replace("### UNIT CODE", unit_code) - # Make sure that the code is well formatted (we don't check for the user code) + # Make sure that the code is well formatted (we don't check for the user code). try: ast.parse(code) except SyntaxError: return_code = 99 result = "Invalid generated unit code." - + # The runner is correctly formatted, we can run it. else: response = await _post_eval(code) @@ -91,6 +97,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit else: return_code = int(response["returncode"]) + # Another code has been returned by CPython because of another failure. if return_code not in (0, 5, 99): return_code = 99 result = "Internal error." @@ -98,6 +105,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit stdout = response["stdout"] passed = bool(int(stdout[0])) + # If the test failed, we have to populate the result string. if not passed: failed_tests = stdout[1:].strip().split(";") diff --git a/resources/unittest_template.py b/resources/unittest_template.py index c792944..4c9b0bb 100644 --- a/resources/unittest_template.py +++ b/resources/unittest_template.py @@ -1,4 +1,5 @@ # flake8: noqa +"""This template is used inside snekbox to evaluate and test user code.""" import ast import io import os @@ -23,27 +24,26 @@ DEVNULL = SimpleNamespace(write=lambda *_: None, flush=lambda *_: None) RESULT = io.StringIO() ORIGINAL_STDOUT = sys.stdout +# stdout/err is patched in order to control what is outputted by the runner sys.stdout = DEVNULL sys.stderr = DEVNULL def _exit_sandbox(code: int) -> NoReturn: """ + Exit the sandbox by printing the result to the actual stdout and exit with the provided code. + Codes: - 0: Executed with success - 5: Syntax error while parsing user code - 99: Internal error """ - result_content = RESULT.getvalue() - - print( - f"{result_content}", - file=ORIGINAL_STDOUT - ) + print(RESULT.getvalue(), file=ORIGINAL_STDOUT, end="") sys.exit(code) def _load_user_module() -> ModuleType: + """Load the user code into a new module and return it.""" try: ast.parse(USER_CODE, "") except SyntaxError: @@ -74,6 +74,7 @@ def _main() -> None: try: + # Load the user code as a global module variable module = _load_user_module() _main() except Exception: -- cgit v1.2.3 From d466b8016c9fb5a5f23731d83254b0b94cf02990 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 15:44:37 +0100 Subject: Properly handle return codes 5 and 99 --- backend/routes/forms/unittesting.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index e038f3a..f7f6072 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -102,21 +102,25 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit return_code = 99 result = "Internal error." else: - stdout = response["stdout"] - passed = bool(int(stdout[0])) - - # If the test failed, we have to populate the result string. - if not passed: - failed_tests = stdout[1:].strip().split(";") - - # Redact failed hidden tests - for i, failed_test in enumerate(failed_tests[:]): - if failed_test in hidden_tests: - failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" - - result = ";".join(failed_tests) + # Parse the stdout if the tests ran successfully + if return_code == 0: + stdout = response["stdout"] + passed = bool(int(stdout[0])) + + # If the test failed, we have to populate the result string. + if not passed: + failed_tests = stdout[1:].strip().split(";") + + # Redact failed hidden tests + for i, failed_test in enumerate(failed_tests[:]): + if failed_test in hidden_tests: + failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" + + result = ";".join(failed_tests) + else: + result = "" else: - result = "" + result = response["stdout"] unittest_results.append(UnittestResult( question_id=question.id, -- cgit v1.2.3 From 8939c8e127d49f9f534679d5ff9bdef907730e13 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:15:15 +0100 Subject: Add return code 6 for exceptions when loading module --- backend/routes/forms/unittesting.py | 2 +- resources/unittest_template.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index f7f6072..c00fc4c 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -98,7 +98,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit return_code = int(response["returncode"]) # Another code has been returned by CPython because of another failure. - if return_code not in (0, 5, 99): + if return_code not in (0, 5, 6, 99): return_code = 99 result = "Internal error." else: diff --git a/resources/unittest_template.py b/resources/unittest_template.py index 755f7cc..02d3894 100644 --- a/resources/unittest_template.py +++ b/resources/unittest_template.py @@ -25,6 +25,7 @@ def _exit_sandbox(code: int) -> NoReturn: Codes: - 0: Executed with success - 5: Syntax error while parsing user code + - 6: Uncaught exception while loading user code - 99: Internal error """ print(RESULT.getvalue(), file=ORIGINAL_STDOUT, end="") @@ -74,8 +75,12 @@ try: sys.stderr = DEVNULL # Load the user code as a global module variable - module = _load_user_module() + try: + module = _load_user_module() + except Exception: + RESULT.write("Uncaught exception while loading user code.") + _exit_sandbox(6) _main() except Exception: - print("Uncaught exception inside runner.", file=RESULT) - _exit_sandbox(99) \ No newline at end of file + RESULT.write("Uncaught exception inside runner.") + _exit_sandbox(99) -- cgit v1.2.3 From 7d34cb8563d8c01e5f5d1b038e0fbd507063e853 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:28:51 +0100 Subject: Add return code 7 for processes killed by NsJail --- backend/routes/forms/unittesting.py | 47 ++++++++++++++++++++----------------- resources/unittest_template.py | 2 ++ 2 files changed, 27 insertions(+), 22 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index c00fc4c..57bf5db 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -50,7 +50,7 @@ async def _post_eval(code: str) -> Optional[dict[str, str]]: """Post the eval to snekbox and return the response.""" async with httpx.AsyncClient() as client: data = {"input": code} - response = await client.post(SNEKBOX_URL, json=data) + response = await client.post(SNEKBOX_URL, json=data, timeout=10) if not response.status_code == 200: return @@ -97,30 +97,33 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit else: return_code = int(response["returncode"]) + # Parse the stdout if the tests ran successfully + if return_code == 0: + stdout = response["stdout"] + passed = bool(int(stdout[0])) + + # If the test failed, we have to populate the result string. + if not passed: + failed_tests = stdout[1:].strip().split(";") + + # Redact failed hidden tests + for i, failed_test in enumerate(failed_tests[:]): + if failed_test in hidden_tests: + failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" + + result = ";".join(failed_tests) + else: + result = "" + elif return_code in (5, 6, 99): + result = response["stdout"] + # Killed by NsJail + elif return_code == 137: + return_code = 7 + result = "Timed out or ran out of memory." # Another code has been returned by CPython because of another failure. - if return_code not in (0, 5, 6, 99): + else: return_code = 99 result = "Internal error." - else: - # Parse the stdout if the tests ran successfully - if return_code == 0: - stdout = response["stdout"] - passed = bool(int(stdout[0])) - - # If the test failed, we have to populate the result string. - if not passed: - failed_tests = stdout[1:].strip().split(";") - - # Redact failed hidden tests - for i, failed_test in enumerate(failed_tests[:]): - if failed_test in hidden_tests: - failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" - - result = ";".join(failed_tests) - else: - result = "" - else: - result = response["stdout"] unittest_results.append(UnittestResult( question_id=question.id, diff --git a/resources/unittest_template.py b/resources/unittest_template.py index 02d3894..38e3be8 100644 --- a/resources/unittest_template.py +++ b/resources/unittest_template.py @@ -27,6 +27,8 @@ def _exit_sandbox(code: int) -> NoReturn: - 5: Syntax error while parsing user code - 6: Uncaught exception while loading user code - 99: Internal error + + 137 can also be generated by NsJail when killing the process. """ print(RESULT.getvalue(), file=ORIGINAL_STDOUT, end="") sys.exit(code) -- cgit v1.2.3 From 99f9a0a940a91f2b9894ebf10b0359bba41d1856 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:36:59 +0100 Subject: Make use of .raise_for_status() Co-authored-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/forms/unittesting.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 57bf5db..cc9f814 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -2,9 +2,9 @@ import ast from collections import namedtuple from itertools import count from textwrap import indent -from typing import Optional import httpx +from httpx import HTTPStatusError from backend.constants import SNEKBOX_URL from backend.models import FormResponse, Form @@ -46,15 +46,13 @@ def _make_user_code(code: str) -> str: return f'USER_CODE = r"""{code}"""' -async def _post_eval(code: str) -> Optional[dict[str, str]]: +async def _post_eval(code: str) -> dict[str, str]: """Post the eval to snekbox and return the response.""" async with httpx.AsyncClient() as client: data = {"input": code} response = await client.post(SNEKBOX_URL, json=data, timeout=10) - if not response.status_code == 200: - return - + response.raise_for_status() return response.json() @@ -89,9 +87,9 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit result = "Invalid generated unit code." # The runner is correctly formatted, we can run it. else: - response = await _post_eval(code) - - if not response: + try: + response = await _post_eval(code) + except HTTPStatusError: return_code = 99 result = "Unable to contact code runner." else: -- cgit v1.2.3 From 2bdcab13f2d25dee98ce4f6a04ef6baf69ce5898 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:41:40 +0100 Subject: Don't try to parse the composed code --- backend/routes/forms/unittesting.py | 72 +++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 40 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index cc9f814..198d950 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -78,50 +78,42 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit code = TEST_TEMPLATE.replace("### USER CODE", user_code) code = code.replace("### UNIT CODE", unit_code) - - # Make sure that the code is well formatted (we don't check for the user code). + try: - ast.parse(code) - except SyntaxError: + response = await _post_eval(code) + except HTTPStatusError: return_code = 99 - result = "Invalid generated unit code." - # The runner is correctly formatted, we can run it. + result = "Unable to contact code runner." else: - try: - response = await _post_eval(code) - except HTTPStatusError: - return_code = 99 - result = "Unable to contact code runner." - else: - return_code = int(response["returncode"]) - - # Parse the stdout if the tests ran successfully - if return_code == 0: - stdout = response["stdout"] - passed = bool(int(stdout[0])) - - # If the test failed, we have to populate the result string. - if not passed: - failed_tests = stdout[1:].strip().split(";") - - # Redact failed hidden tests - for i, failed_test in enumerate(failed_tests[:]): - if failed_test in hidden_tests: - failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" - - result = ";".join(failed_tests) - else: - result = "" - elif return_code in (5, 6, 99): - result = response["stdout"] - # Killed by NsJail - elif return_code == 137: - return_code = 7 - result = "Timed out or ran out of memory." - # Another code has been returned by CPython because of another failure. + return_code = int(response["returncode"]) + + # Parse the stdout if the tests ran successfully + if return_code == 0: + stdout = response["stdout"] + passed = bool(int(stdout[0])) + + # If the test failed, we have to populate the result string. + if not passed: + failed_tests = stdout[1:].strip().split(";") + + # Redact failed hidden tests + for i, failed_test in enumerate(failed_tests[:]): + if failed_test in hidden_tests: + failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" + + result = ";".join(failed_tests) else: - return_code = 99 - result = "Internal error." + result = "" + elif return_code in (5, 6, 99): + result = response["stdout"] + # Killed by NsJail + elif return_code == 137: + return_code = 7 + result = "Timed out or ran out of memory." + # Another code has been returned by CPython because of another failure. + else: + return_code = 99 + result = "Internal error." unittest_results.append(UnittestResult( question_id=question.id, -- cgit v1.2.3 From 52f12f4ab939b467c2ba88f5f83094fb1392baa2 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:42:43 +0100 Subject: Make use of list.copy() instead of [:] --- backend/routes/forms/unittesting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 198d950..c11e4ad 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -78,7 +78,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit code = TEST_TEMPLATE.replace("### USER CODE", user_code) code = code.replace("### UNIT CODE", unit_code) - + try: response = await _post_eval(code) except HTTPStatusError: @@ -97,7 +97,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit failed_tests = stdout[1:].strip().split(";") # Redact failed hidden tests - for i, failed_test in enumerate(failed_tests[:]): + for i, failed_test in enumerate(failed_tests.copy()): if failed_test in hidden_tests: failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}" -- cgit v1.2.3 From e46047da80d1141849e0ac755b83ef50f47bd53c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:43:35 +0100 Subject: Only filter units if we aren't using an admin token --- backend/routes/forms/form.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index deb03ae..dd1c83f 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -38,7 +38,8 @@ class SingleForm(Route): if raw_form := await request.state.db.forms.find_one(filters): form = Form(**raw_form) - form = filter_unittests(form) + if not admin: + form = filter_unittests(form) return JSONResponse(form.dict(admin=admin)) -- cgit v1.2.3 From 06c01e78abcb0ab8713a3ad375218e98aab2882f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:44:26 +0100 Subject: Remove unneeded temp variable --- backend/routes/forms/submit.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index d6b549e..b3a6afd 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -132,12 +132,10 @@ class SubmitForm(Route): return JSONResponse(e.errors(), status_code=422) # Run unittests if needed - has_unittests = any("unittests" in question.data for question in form.questions) - if has_unittests: + if any("unittests" in question.data for question in form.questions): unittest_results = await execute_unittest(response_obj, form) - was_successful = all(test.passed for test in unittest_results) - if not was_successful: + if not all(test.passed for test in unittest_results): # Return 500 if we encountered an internal error (code 99). status_code = 500 if any( test.return_code == 99 for test in unittest_results -- cgit v1.2.3 From bfc44e81cb0ea6b9997d8ca701b3f525ddcd50df Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:45:35 +0100 Subject: Make _make_unit_code more readable --- backend/routes/forms/unittesting.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index c11e4ad..ef86e7f 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -34,7 +34,10 @@ def _make_unit_code(units: dict[str, str]) -> str: result = "" for unit_name, unit_code in units.items(): - result += f"\ndef test_{unit_name.lstrip('#')}(unit):\n{indent(unit_code, ' ')}" + result += ( + f"\ndef test_{unit_name.lstrip('#')}(unit):" # Function definition + f"\n{indent(unit_code, ' ')}" # Unit code + ) return indent(result, " ") -- cgit v1.2.3 From e57b7ea1f5d93b8f9ebea825a742ed6ec5be1088 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 18:18:12 +0100 Subject: Remove unused import --- backend/routes/forms/unittesting.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index ef86e7f..4b362e1 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -1,4 +1,3 @@ -import ast from collections import namedtuple from itertools import count from textwrap import indent @@ -15,6 +14,9 @@ with open("resources/unittest_template.py") as file: UnittestResult = namedtuple("UnittestResult", "question_id return_code passed result") +# Mapping of questions to their generated +_unit_cache: dict[str, str] = {} + def filter_unittests(form: Form) -> Form: """ @@ -35,7 +37,7 @@ def _make_unit_code(units: dict[str, str]) -> str: for unit_name, unit_code in units.items(): result += ( - f"\ndef test_{unit_name.lstrip('#')}(unit):" # Function definition + f"\ndef test_{unit_name.lstrip('#')}(unit):" # Function definition f"\n{indent(unit_code, ' ')}" # Unit code ) -- cgit v1.2.3 From a1a14d8a82bb7d2a9021bca5a2b8fcb3fbc4406a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 18:20:46 +0100 Subject: Properly hadnle hidden tests starting with test_ --- backend/routes/forms/unittesting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 4b362e1..175701f 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -72,7 +72,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit # Tests starting with an hashtag should have censored names. hidden_test_counter = count(1) hidden_tests = { - test.lstrip("#"): next(hidden_test_counter) + test.lstrip("#").lstrip("test_"): next(hidden_test_counter) for test in question.data["unittests"].keys() if test.startswith("#") } -- cgit v1.2.3 From 9d2c3794a4c95f6c63a7de64172bc35a68403a4c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 26 Feb 2021 14:21:00 +0100 Subject: Use base64 encoded code snippets --- backend/routes/forms/unittesting.py | 8 ++++---- resources/unittest_template.py | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 175701f..b12cff2 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -1,3 +1,4 @@ +import base64 from collections import namedtuple from itertools import count from textwrap import indent @@ -45,10 +46,9 @@ def _make_unit_code(units: dict[str, str]) -> str: def _make_user_code(code: str) -> str: - """Compose the user code into an actual string variable.""" - # Make sure that we we escape triple quotes in the user code - code = code.replace('"""', '\\"""') - return f'USER_CODE = r"""{code}"""' + """Compose the user code into an actual base64-encoded string variable.""" + code = base64.b64encode(code.encode("utf8")).decode("utf8") + return f'USER_CODE = b"{code}"' async def _post_eval(code: str) -> dict[str, str]: diff --git a/resources/unittest_template.py b/resources/unittest_template.py index 38e3be8..2410278 100644 --- a/resources/unittest_template.py +++ b/resources/unittest_template.py @@ -1,6 +1,7 @@ # flake8: noqa """This template is used inside snekbox to evaluate and test user code.""" import ast +import base64 import io import os import sys @@ -36,14 +37,15 @@ def _exit_sandbox(code: int) -> NoReturn: def _load_user_module() -> ModuleType: """Load the user code into a new module and return it.""" + code = base64.b64decode(USER_CODE).decode("utf8") try: - ast.parse(USER_CODE, "") + ast.parse(code, "") except SyntaxError: RESULT.write("".join(traceback.format_exception(*sys.exc_info(), limit=0))) _exit_sandbox(5) _module = ModuleType("module") - exec(USER_CODE, _module.__dict__) + exec(code, _module.__dict__) return _module -- cgit v1.2.3 From 0e4a95b584f20f22e61314483147e81b0dcb5354 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 27 Feb 2021 17:00:53 +0100 Subject: Obliterate the _unit_cache variable --- backend/routes/forms/unittesting.py | 3 --- 1 file changed, 3 deletions(-) (limited to 'backend') diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index b12cff2..3854314 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -15,9 +15,6 @@ with open("resources/unittest_template.py") as file: UnittestResult = namedtuple("UnittestResult", "question_id return_code passed result") -# Mapping of questions to their generated -_unit_cache: dict[str, str] = {} - def filter_unittests(form: Form) -> Form: """ -- cgit v1.2.3 From c175279e4172160f0d119ddd93dce8a813fff69b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 1 Mar 2021 16:51:25 +0300 Subject: Allows All CORS Requests On Development Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/__init__.py | 4 ++-- docker-compose.yml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/__init__.py b/backend/__init__.py index d56edfb..5c91a65 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -16,8 +16,8 @@ ORIGINS = [ r"(https?://[^.?#]*.forms-frontend.pages.dev)", # Cloudflare Previews ] if not constants.PRODUCTION: - # Add localhost to allowed origins on non-production deployments - ORIGINS.append(r"(https?://localhost:\d{0,4})") + # Allow all hosts on non-production deployments + ORIGINS.append(r"(.*)") ALLOW_ORIGIN_REGEX = "|".join(ORIGINS) diff --git a/docker-compose.yml b/docker-compose.yml index 4e58ef7..8ee46be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,3 +37,4 @@ services: - OAUTH2_CLIENT_SECRET - ALLOWED_URL - DEBUG=true + - PRODUCTION=false -- cgit v1.2.3 From da41f255a06516c1b7b85a587b982535cd7fec54 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 1 Mar 2021 16:56:02 +0300 Subject: Make Admin Fetch Async Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/authentication/backend.py | 2 +- backend/authentication/user.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'backend') diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index bdff796..206d1eb 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -61,7 +61,7 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): raise authentication.AuthenticationError("Could not parse user details.") user = User(token, user_details) - if user.fetch_admin_status(request): + if await user.fetch_admin_status(request): scopes.append("admin") return authentication.AuthCredentials(scopes), user diff --git a/backend/authentication/user.py b/backend/authentication/user.py index 52baa61..857c2ed 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -34,8 +34,8 @@ class User(BaseUser): def decoded_token(self) -> dict[str, any]: return jwt.decode(self.token, SECRET_KEY, algorithms=["HS256"]) - def fetch_admin_status(self, request: Request) -> bool: - self.admin = request.state.db.admins.find_one( + async def fetch_admin_status(self, request: Request) -> bool: + self.admin = await request.state.db.admins.find_one( {"_id": self.payload["id"]} ) is not None -- cgit v1.2.3 From 02154294da8b25bf7dae1b79f170aab888f92797 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 6 Mar 2021 22:42:52 +0300 Subject: Renames Token To `token` Changes the name for the token used to authorize with the backend. Co-authored-by: Joe Banks --- backend/authentication/backend.py | 2 +- backend/routes/auth/authorize.py | 4 ++-- backend/routes/forms/submit.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) (limited to 'backend') diff --git a/backend/authentication/backend.py b/backend/authentication/backend.py index 206d1eb..c7590e9 100644 --- a/backend/authentication/backend.py +++ b/backend/authentication/backend.py @@ -33,7 +33,7 @@ class JWTAuthenticationBackend(authentication.AuthenticationBackend): self, request: Request ) -> t.Optional[tuple[authentication.AuthCredentials, authentication.BaseUser]]: """Handles JWT authentication process.""" - cookie = request.cookies.get("BackendToken") + cookie = request.cookies.get("token") if not cookie: return None diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 65709ab..98f9887 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -41,7 +41,7 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI try: user_details = await fetch_user_details(bearer_token["access_token"]) except httpx.HTTPStatusError: - AUTH_FAILURE.delete_cookie("BackendToken") + AUTH_FAILURE.delete_cookie("token") return AUTH_FAILURE max_age = datetime.timedelta(seconds=int(bearer_token["expires_in"])) @@ -63,7 +63,7 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI }) response.set_cookie( - "BackendToken", f"JWT {token}", + "token", f"JWT {token}", secure=constants.PRODUCTION, httponly=True, samesite="strict", max_age=bearer_token["expires_in"] ) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 4224586..8680b2d 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -75,7 +75,7 @@ class SubmitForm(Route): expiry = None response.set_cookie( - "BackendToken", f"JWT {request.user.token}", + "token", f"JWT {request.user.token}", secure=constants.PRODUCTION, httponly=True, samesite="strict", max_age=(expiry - datetime.datetime.now()).seconds ) -- cgit v1.2.3 From ca730082b523e62595687843a914adad8dbbaccf Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 6 Mar 2021 22:48:19 +0300 Subject: Formats Authorize File Cleans up the authorize file, and the __init__ to maintain the project's code style. Co-authored-by: Joe Banks Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/__init__.py | 1 + backend/routes/auth/authorize.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'backend') diff --git a/backend/__init__.py b/backend/__init__.py index 5c91a65..220b457 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -15,6 +15,7 @@ ORIGINS = [ r"(https://[^.?#]*--pydis-forms\.netlify\.app)", # Netlify Previews r"(https?://[^.?#]*.forms-frontend.pages.dev)", # Cloudflare Previews ] + if not constants.PRODUCTION: # Allow all hosts on non-production deployments ORIGINS.append(r"(.*)") diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 98f9887..26d8622 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -21,6 +21,8 @@ from backend.discord import fetch_bearer_token, fetch_user_details from backend.route import Route from backend.validation import ErrorMessage, api +AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) + class AuthorizeRequest(BaseModel): token: str = Field(description="The access token received from Discord.") @@ -31,9 +33,6 @@ class AuthorizeResponse(BaseModel): expiry: str = Field("ISO formatted timestamp of expiry.") -AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) - - async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAILURE]: """Post a bearer token to Discord, and return a JWT and username.""" interaction_start = datetime.datetime.now() -- cgit v1.2.3 From 013ea9006352ed714cbbd561880770062ea3a0e9 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 6 Mar 2021 23:19:08 +0300 Subject: Sets Token Cookie To Same Site To Lax Sets the authorization token cookie's security policy to lax, to allow it to work on the site. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 2 +- backend/routes/forms/submit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 26d8622..e00aef2 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -63,7 +63,7 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI response.set_cookie( "token", f"JWT {token}", - secure=constants.PRODUCTION, httponly=True, samesite="strict", + secure=constants.PRODUCTION, httponly=True, samesite="lax", max_age=bearer_token["expires_in"] ) return response diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 8680b2d..8803b7c 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -76,7 +76,7 @@ class SubmitForm(Route): response.set_cookie( "token", f"JWT {request.user.token}", - secure=constants.PRODUCTION, httponly=True, samesite="strict", + secure=constants.PRODUCTION, httponly=True, samesite="lax", max_age=(expiry - datetime.datetime.now()).seconds ) -- cgit v1.2.3 From b2ad14a87ab715eb403be68722914ed1c6b51d91 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 6 Mar 2021 23:32:21 +0300 Subject: Revert "Sets Token Cookie To Same Site To Lax" This reverts commit 013ea900 Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 2 +- backend/routes/forms/submit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index e00aef2..26d8622 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -63,7 +63,7 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI response.set_cookie( "token", f"JWT {token}", - secure=constants.PRODUCTION, httponly=True, samesite="lax", + secure=constants.PRODUCTION, httponly=True, samesite="strict", max_age=bearer_token["expires_in"] ) return response diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 8803b7c..8680b2d 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -76,7 +76,7 @@ class SubmitForm(Route): response.set_cookie( "token", f"JWT {request.user.token}", - secure=constants.PRODUCTION, httponly=True, samesite="lax", + secure=constants.PRODUCTION, httponly=True, samesite="strict", max_age=(expiry - datetime.datetime.now()).seconds ) -- cgit v1.2.3 From 5bab39126bb6b764595a4e21b454249c01628588 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 7 Mar 2021 00:07:19 +0300 Subject: Makes Helper To Handle Token SameSite Logic Adds a helper method to allow tokens to work on deploy previews. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/constants.py | 6 +++-- backend/routes/auth/authorize.py | 49 ++++++++++++++++++++++++++++++---------- backend/routes/forms/submit.py | 9 ++++---- 3 files changed, 45 insertions(+), 19 deletions(-) (limited to 'backend') diff --git a/backend/constants.py b/backend/constants.py index e1f4a5b..4bb7fd1 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,8 +1,9 @@ -from dotenv import load_dotenv -import os import binascii +import os from enum import Enum +from dotenv import load_dotenv + load_dotenv() @@ -12,6 +13,7 @@ MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms") SNEKBOX_URL = os.getenv("SNEKBOX_URL", "http://snekbox.default.svc.cluster.local/eval") PRODUCTION = os.getenv("PRODUCTION", "True").lower() != "false" +PRODUCTION_URL = "https://forms.pythondiscord.com/" OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 26d8622..1e773d6 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -10,9 +10,9 @@ import jwt from pydantic.fields import Field from pydantic.main import BaseModel from spectree.response import Response +from starlette import responses from starlette.authentication import requires from starlette.requests import Request -from starlette.responses import JSONResponse from backend import constants from backend.authentication.user import User @@ -21,7 +21,7 @@ from backend.discord import fetch_bearer_token, fetch_user_details from backend.route import Route from backend.validation import ErrorMessage, api -AUTH_FAILURE = JSONResponse({"error": "auth_failure"}, status_code=400) +AUTH_FAILURE = responses.JSONResponse({"error": "auth_failure"}, status_code=400) class AuthorizeRequest(BaseModel): @@ -33,7 +33,7 @@ class AuthorizeResponse(BaseModel): expiry: str = Field("ISO formatted timestamp of expiry.") -async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAILURE]: +async def process_token(bearer_token: dict, origin: str) -> Union[AuthorizeResponse, AUTH_FAILURE]: """Post a bearer token to Discord, and return a JWT and username.""" interaction_start = datetime.datetime.now() @@ -56,17 +56,42 @@ async def process_token(bearer_token: dict) -> Union[AuthorizeResponse, AUTH_FAI token = jwt.encode(data, SECRET_KEY, algorithm="HS256") user = User(token, user_details) - response = JSONResponse({ + response = responses.JSONResponse({ "username": user.display_name, "expiry": token_expiry.isoformat() }) + await set_response_token(response, origin, token, bearer_token["expires_in"]) + return response + + +async def set_response_token( + response: responses.Response, + origin_url: str, + new_token: str, + expiry: int +) -> None: + """Helper that handles logic for updating a token in a set-cookie response.""" + if origin_url == constants.PRODUCTION_URL: + domain = constants.PRODUCTION_URL + samesite = "strict" + + elif not constants.PRODUCTION: + domain = None + samesite = "strict" + + else: + domain = origin_url + samesite = "None" + response.set_cookie( - "token", f"JWT {token}", - secure=constants.PRODUCTION, httponly=True, samesite="strict", - max_age=bearer_token["expires_in"] + "token", f"JWT {new_token}", + secure=constants.PRODUCTION, + httponly=True, + samesite=samesite, + domain=domain, + max_age=expiry ) - return response class AuthorizeRoute(Route): @@ -82,7 +107,7 @@ class AuthorizeRoute(Route): resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), tags=["auth"] ) - async def post(self, request: Request) -> JSONResponse: + async def post(self, request: Request) -> responses.JSONResponse: """Generate an authorization token.""" data = await request.json() try: @@ -91,7 +116,7 @@ class AuthorizeRoute(Route): except httpx.HTTPStatusError: return AUTH_FAILURE - return await process_token(bearer_token) + return await process_token(bearer_token, url) class TokenRefreshRoute(Route): @@ -107,7 +132,7 @@ class TokenRefreshRoute(Route): resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), tags=["auth"] ) - async def post(self, request: Request) -> JSONResponse: + async def post(self, request: Request) -> responses.JSONResponse: """Refresh an authorization token.""" try: token = request.user.decoded_token.get("refresh") @@ -116,4 +141,4 @@ class TokenRefreshRoute(Route): except httpx.HTTPStatusError: return AUTH_FAILURE - return await process_token(bearer_token) + return await process_token(bearer_token, url) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 8680b2d..975307b 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -20,6 +20,7 @@ from backend import constants from backend.authentication.user import User from backend.models import Form, FormResponse from backend.route import Route +from backend.routes.auth.authorize import set_response_token from backend.routes.forms.unittesting import execute_unittest from backend.validation import ErrorMessage, api @@ -74,11 +75,9 @@ class SubmitForm(Route): except ValueError: expiry = None - response.set_cookie( - "token", f"JWT {request.user.token}", - secure=constants.PRODUCTION, httponly=True, samesite="strict", - max_age=(expiry - datetime.datetime.now()).seconds - ) + origin = request.headers.get("origin") + expiry_seconds = (expiry - datetime.datetime.now()).seconds + await set_response_token(response, origin, request.user.token, expiry_seconds) except httpx.HTTPStatusError: pass -- cgit v1.2.3 From 8811959c6f13cdccb56d4fc72c1d9027e66d63d5 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 7 Mar 2021 00:37:42 +0300 Subject: Fixes Domain URL On Token Cookie Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 25 +++++++++++++++---------- backend/routes/forms/submit.py | 4 +++- 2 files changed, 18 insertions(+), 11 deletions(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 1e773d6..5742b9b 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -33,7 +33,11 @@ class AuthorizeResponse(BaseModel): expiry: str = Field("ISO formatted timestamp of expiry.") -async def process_token(bearer_token: dict, origin: str) -> Union[AuthorizeResponse, AUTH_FAILURE]: +async def process_token( + bearer_token: dict, + origin_url: str, + request_url: Request.url +) -> Union[AuthorizeResponse, AUTH_FAILURE]: """Post a bearer token to Discord, and return a JWT and username.""" interaction_start = datetime.datetime.now() @@ -61,19 +65,20 @@ async def process_token(bearer_token: dict, origin: str) -> Union[AuthorizeRespo "expiry": token_expiry.isoformat() }) - await set_response_token(response, origin, token, bearer_token["expires_in"]) + await set_response_token(response, origin_url, request_url, token, bearer_token["expires_in"]) return response async def set_response_token( - response: responses.Response, - origin_url: str, - new_token: str, - expiry: int + response: responses.Response, + origin_url: str, + request_url: Request.url, + new_token: str, + expiry: int ) -> None: """Helper that handles logic for updating a token in a set-cookie response.""" if origin_url == constants.PRODUCTION_URL: - domain = constants.PRODUCTION_URL + domain = request_url samesite = "strict" elif not constants.PRODUCTION: @@ -81,7 +86,7 @@ async def set_response_token( samesite = "strict" else: - domain = origin_url + domain = request_url samesite = "None" response.set_cookie( @@ -116,7 +121,7 @@ class AuthorizeRoute(Route): except httpx.HTTPStatusError: return AUTH_FAILURE - return await process_token(bearer_token, url) + return await process_token(bearer_token, url, request.url) class TokenRefreshRoute(Route): @@ -141,4 +146,4 @@ class TokenRefreshRoute(Route): except httpx.HTTPStatusError: return AUTH_FAILURE - return await process_token(bearer_token, url) + return await process_token(bearer_token, url, request.url) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 975307b..ae98cfb 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -77,7 +77,9 @@ class SubmitForm(Route): origin = request.headers.get("origin") expiry_seconds = (expiry - datetime.datetime.now()).seconds - await set_response_token(response, origin, request.user.token, expiry_seconds) + await set_response_token( + response, origin, request.url, request.user.token, expiry_seconds + ) except httpx.HTTPStatusError: pass -- cgit v1.2.3 From 311a58b9a998385961a369dfbdc895c915ba28df Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 7 Mar 2021 00:41:53 +0300 Subject: Corrects Domain On Token Cookie Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 5742b9b..ce7b8bd 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -77,8 +77,9 @@ async def set_response_token( expiry: int ) -> None: """Helper that handles logic for updating a token in a set-cookie response.""" + stripped_domain = request_url.scheme + request_url.netloc if origin_url == constants.PRODUCTION_URL: - domain = request_url + domain = stripped_domain samesite = "strict" elif not constants.PRODUCTION: @@ -86,7 +87,7 @@ async def set_response_token( samesite = "strict" else: - domain = request_url + domain = stripped_domain samesite = "None" response.set_cookie( -- cgit v1.2.3 From 85396769cc8481d1484da369f9c1a2e0c59409f7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 7 Mar 2021 00:44:19 +0300 Subject: Corrects Domain On Token Cookie Correctly formats the domain set on the cookie used for tokens. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index ce7b8bd..6a27c65 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -77,7 +77,8 @@ async def set_response_token( expiry: int ) -> None: """Helper that handles logic for updating a token in a set-cookie response.""" - stripped_domain = request_url.scheme + request_url.netloc + stripped_domain = f"{request_url.scheme}://{request_url.netloc}/" + if origin_url == constants.PRODUCTION_URL: domain = stripped_domain samesite = "strict" -- cgit v1.2.3 From 8ef22e9bac402f12bb5f6e932ff67fd45b26433b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 7 Mar 2021 00:55:31 +0300 Subject: Switches Forwarded Protocol Header Traefik forwards https traffic to http, which causes issues with the protocol in a request's URL. This switch uses the protocol header to correctly set the protocol. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 16 ++++++++-------- backend/routes/forms/submit.py | 5 +---- 2 files changed, 9 insertions(+), 12 deletions(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 6a27c65..e782bcc 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -35,8 +35,7 @@ class AuthorizeResponse(BaseModel): async def process_token( bearer_token: dict, - origin_url: str, - request_url: Request.url + request: Request ) -> Union[AuthorizeResponse, AUTH_FAILURE]: """Post a bearer token to Discord, and return a JWT and username.""" interaction_start = datetime.datetime.now() @@ -65,19 +64,20 @@ async def process_token( "expiry": token_expiry.isoformat() }) - await set_response_token(response, origin_url, request_url, token, bearer_token["expires_in"]) + await set_response_token(response, request, token, bearer_token["expires_in"]) return response async def set_response_token( response: responses.Response, - origin_url: str, - request_url: Request.url, + request: Request, new_token: str, expiry: int ) -> None: """Helper that handles logic for updating a token in a set-cookie response.""" - stripped_domain = f"{request_url.scheme}://{request_url.netloc}/" + origin_url = request.headers.get("origin") + protocol = request.headers.get("X-Forwarded-Proto") or "https" + stripped_domain = f"{protocol}://{request.url.netloc}/" if origin_url == constants.PRODUCTION_URL: domain = stripped_domain @@ -123,7 +123,7 @@ class AuthorizeRoute(Route): except httpx.HTTPStatusError: return AUTH_FAILURE - return await process_token(bearer_token, url, request.url) + return await process_token(bearer_token, request) class TokenRefreshRoute(Route): @@ -148,4 +148,4 @@ class TokenRefreshRoute(Route): except httpx.HTTPStatusError: return AUTH_FAILURE - return await process_token(bearer_token, url, request.url) + return await process_token(bearer_token, request) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index ae98cfb..2624c98 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -75,11 +75,8 @@ class SubmitForm(Route): except ValueError: expiry = None - origin = request.headers.get("origin") expiry_seconds = (expiry - datetime.datetime.now()).seconds - await set_response_token( - response, origin, request.url, request.user.token, expiry_seconds - ) + await set_response_token(response, request, request.user.token, expiry_seconds) except httpx.HTTPStatusError: pass -- cgit v1.2.3 From 99e82b5ba80c45e0e0800db93f573929ee05feea Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 7 Mar 2021 03:05:08 +0300 Subject: Corrects Token Cookie Domain Removes schema from the token cookie's domain field. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/routes/auth/authorize.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'backend') diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index e782bcc..d4587f0 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -76,11 +76,9 @@ async def set_response_token( ) -> None: """Helper that handles logic for updating a token in a set-cookie response.""" origin_url = request.headers.get("origin") - protocol = request.headers.get("X-Forwarded-Proto") or "https" - stripped_domain = f"{protocol}://{request.url.netloc}/" if origin_url == constants.PRODUCTION_URL: - domain = stripped_domain + domain = request.url.netloc samesite = "strict" elif not constants.PRODUCTION: @@ -88,7 +86,7 @@ async def set_response_token( samesite = "strict" else: - domain = stripped_domain + domain = request.url.netloc samesite = "None" response.set_cookie( -- cgit v1.2.3 From 4fadbef8cd9aded59b02d376f78533947aa831df Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 8 Mar 2021 17:28:17 +0300 Subject: Fixes Production URL Constant Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- backend/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend') diff --git a/backend/constants.py b/backend/constants.py index 4bb7fd1..d90fd9a 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -13,7 +13,7 @@ MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms") SNEKBOX_URL = os.getenv("SNEKBOX_URL", "http://snekbox.default.svc.cluster.local/eval") PRODUCTION = os.getenv("PRODUCTION", "True").lower() != "false" -PRODUCTION_URL = "https://forms.pythondiscord.com/" +PRODUCTION_URL = "https://forms.pythondiscord.com" OAUTH2_CLIENT_ID = os.getenv("OAUTH2_CLIENT_ID") OAUTH2_CLIENT_SECRET = os.getenv("OAUTH2_CLIENT_SECRET") -- cgit v1.2.3