From 3205fd83a8ae0c51f460a8b3d973fb4a0ea11ee5 Mon Sep 17 00:00:00 2001 From: tejas Date: Tue, 1 Sep 2026 14:22:34 +0100 Subject: [PATCH 1/3] Answer CORS preflight OPTIONS on the enquiry endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers send a preflight OPTIONS request before any cross-origin POST with a Content-Type of application/json. json_request_middleware tried to JSON-parse the empty OPTIONS body and returned a 400, so the preflight failed and the browser never sent the enquiry POST — the tutorcruncher.com contact form has been unable to submit because of this. Skip body parsing for OPTIONS and have the enquiry view answer it with 204 plus the Access-Control-Allow-* headers. Co-Authored-By: Claude Fable 5 --- tcsocket/app/middleware.py | 4 ++-- tcsocket/app/utils.py | 6 ++++++ tcsocket/app/views/enquiry.py | 9 +++++++-- tests/test_enquiries.py | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/tcsocket/app/middleware.py b/tcsocket/app/middleware.py index f9b06ee..429092e 100644 --- a/tcsocket/app/middleware.py +++ b/tcsocket/app/middleware.py @@ -4,7 +4,7 @@ from asyncio import CancelledError from time import time -from aiohttp.hdrs import METH_GET, METH_HEAD +from aiohttp.hdrs import METH_GET, METH_HEAD, METH_OPTIONS from aiohttp.web_exceptions import HTTPBadRequest, HTTPException, HTTPInternalServerError, HTTPMovedPermanently from aiohttp.web_middlewares import middleware from aiohttp.web_urldispatcher import SystemRoute @@ -174,7 +174,7 @@ async def company_middleware(request, handler): @middleware async def json_request_middleware(request, handler): - if request.method not in {METH_GET, METH_HEAD} and request.match_info.route.name: + if request.method not in {METH_GET, METH_HEAD, METH_OPTIONS} and request.match_info.route.name: error_details = None try: data = await request.json() diff --git a/tcsocket/app/utils.py b/tcsocket/app/utils.py index f6c3018..8efe7ba 100644 --- a/tcsocket/app/utils.py +++ b/tcsocket/app/utils.py @@ -46,6 +46,12 @@ def pretty_lenient_json(data): JSON_CONTENT_TYPE = 'application/json' ACCESS_CONTROL_HEADERS = {'Access-Control-Allow-Origin': '*'} +CORS_PREFLIGHT_HEADERS = { + **ACCESS_CONTROL_HEADERS, + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', +} class HTTPClientErrorJson(web.HTTPClientError): diff --git a/tcsocket/app/views/enquiry.py b/tcsocket/app/views/enquiry.py index a0212f3..828a726 100644 --- a/tcsocket/app/views/enquiry.py +++ b/tcsocket/app/views/enquiry.py @@ -5,11 +5,12 @@ from operator import itemgetter import pydantic -from aiohttp.hdrs import METH_POST +from aiohttp.hdrs import METH_OPTIONS, METH_POST +from aiohttp.web_response import Response from arq.utils import timestamp_ms from ..geo import get_ip -from ..utils import HTTPBadRequestJson, json_response +from ..utils import CORS_PREFLIGHT_HEADERS, HTTPBadRequestJson, json_response from ..worker import REDIS_ENQUIRY_CACHE_KEY, get_enquiry_options, store_enquiry_data logger = logging.getLogger('socket') @@ -23,6 +24,10 @@ async def clear_enquiry(request): async def enquiry(request): + if request.method == METH_OPTIONS: + # CORS preflight sent by browsers before a cross-origin JSON post + return Response(status=204, headers=CORS_PREFLIGHT_HEADERS) + company = dict(request['company']) redis = request.app['redis'] diff --git a/tests/test_enquiries.py b/tests/test_enquiries.py index 78af411..2a4024a 100644 --- a/tests/test_enquiries.py +++ b/tests/test_enquiries.py @@ -386,3 +386,18 @@ async def test_post_all_optional(cli, company, other_server): assert r.status == 201, await r.text() data = await r.json() assert data == {'status': 'enquiry submitted to TutorCruncher'} + + +async def test_options_enquiry_preflight(cli, company): + r = await cli.options( + cli.server.app.router['enquiry'].url_for(company=company.public_key), + headers={ + 'Origin': 'https://tutorcruncher.com', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type', + }, + ) + assert r.status == 204, await r.text() + assert r.headers['Access-Control-Allow-Origin'] == '*' + assert r.headers['Access-Control-Allow-Methods'] == 'POST' + assert r.headers['Access-Control-Allow-Headers'] == 'Content-Type' From 765c953cb7e81bebb7272c44340a99c3274e21f4 Mon Sep 17 00:00:00 2001 From: tejas Date: Tue, 1 Sep 2026 14:25:16 +0100 Subject: [PATCH 2/3] Use setup-python v5: the runner no longer provides Python 3.9.21 for v1 Co-Authored-By: Claude Fable 5 --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5e7cd3c..2cc96ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,9 +35,9 @@ jobs: - uses: actions/checkout@v2 - name: set up python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: - python-version: '3.9.21' + python-version: '3.9' - name: install dependencies run: | From d772d239265bc5b4f8e0cf2b2d0d29609b52e3fb Mon Sep 17 00:00:00 2001 From: tejas Date: Tue, 1 Sep 2026 14:30:26 +0100 Subject: [PATCH 3/3] Fix test_post_enquiry_long_grecaptcha_response failing on required attributes The test set extra_attributes='default', which makes the tell-us-about-yourself attribute required, but posted no attributes, so the enquiry was rejected with a 400 instead of the asserted 201. It merged unnoticed because CI's setup-python step was already broken and tests never ran. The test only cares that a long grecaptcha token passes validation, so drop the extra_attributes line. Co-Authored-By: Claude Fable 5 --- tests/test_enquiries.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_enquiries.py b/tests/test_enquiries.py index 2a4024a..91672a7 100644 --- a/tests/test_enquiries.py +++ b/tests/test_enquiries.py @@ -97,7 +97,6 @@ async def test_post_enquiry_success(cli, company, other_server, worker): async def test_post_enquiry_long_grecaptcha_response(cli, company, other_server, worker): - other_server.app['extra_attributes'] = 'default' data = { 'client_name': 'Cat Flap', 'grecaptcha_response': 'good' * 1000,