From feb45bc51b9d04f3a151895f18abad48a7a4bce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E6=9D=B0=20525370910098?= Date: Thu, 13 Aug 2026 23:14:35 +0800 Subject: [PATCH 1/4] fix(deploy): use ghcr base image, add tunnel service with http2, pg18 volume path - gcr.io distroless base is unreachable behind GFW; reuse ghcr uv image - add cloudflared tunnel service reading TUNNEL_TOKEN from .env - force http2 protocol (QUIC connections get dropped on this network) - mount config.yaml and .env into container for live config updates - rename service to CourseReview - fix PostgreSQL 18 volume mount path --- Containerfile | 4 +++- compose.yaml | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Containerfile b/Containerfile index cb6ee1c..ea69b1c 100644 --- a/Containerfile +++ b/Containerfile @@ -7,7 +7,9 @@ COPY . /app RUN UV_PROJECT_ENVIRONMENT=/usr/local \ uv sync --project=/app --frozen --compile-bytecode --no-dev --no-editable --no-managed-python -FROM gcr.io/distroless/base-debian13:nonroot +FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim + +RUN useradd --create-home --shell /bin/bash nonroot COPY --from=builder /usr/local /usr/local diff --git a/compose.yaml b/compose.yaml index a20016d..efc22e2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ services: db: image: postgres:18-alpine volumes: - - postgres18_data:/var/lib/postgresql/data + - postgres18_data:/var/lib/postgresql environment: POSTGRES_DB: ${POSTGRES_DB:-coursereview} POSTGRES_USER: ${POSTGRES_USER:-admin} @@ -23,8 +23,11 @@ services: retries: 5 restart: unless-stopped - backend: + CourseReview: image: coursereview-backend + volumes: + - ./config.yaml:/app/config.yaml:ro + - ./.env:/app/.env:ro depends_on: db: condition: service_healthy @@ -57,5 +60,13 @@ services: command: ["python", "django_manage.py", "migrate"] restart: "no" + tunnel: + image: cloudflare/cloudflared:latest + command: tunnel run --protocol http2 --token ${TUNNEL_TOKEN} + restart: unless-stopped + depends_on: + CourseReview: + condition: service_healthy + volumes: postgres18_data: From 6ba3f2eb11ed744bcdf7f43171c2c39a3f169865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E6=9D=B0=20525370910098?= Date: Thu, 13 Aug 2026 23:14:46 +0800 Subject: [PATCH 2/4] fix(security): csrf trusted origins, proxy ssl header, csrftoken cookie - add CSRF_TRUSTED_ORIGINS config for coursesel.gcers.org / api.gcers.org - add SECURE_PROXY_SSL_HEADER so Django detects HTTPS behind tunnel - set csrftoken cookie on /api/user/status/ so browser-side POSTs pass CSRF --- apps/web/views.py | 2 ++ website/settings.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/apps/web/views.py b/apps/web/views.py index 8e55d9e..ed0f95c 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -2,6 +2,7 @@ from django.conf import settings from django.db.models import Count, Prefetch, Q +from django.views.decorators.csrf import ensure_csrf_cookie from rest_framework import generics, mixins, pagination, status from rest_framework.decorators import ( api_view, @@ -36,6 +37,7 @@ class CoursesPagination(pagination.PageNumberPagination): page_size = settings.WEB["COURSE"]["PAGE_SIZE"] +@ensure_csrf_cookie @api_view(["GET"]) def user_status(request): """ diff --git a/website/settings.py b/website/settings.py index d281cf3..b2bc7f1 100644 --- a/website/settings.py +++ b/website/settings.py @@ -14,6 +14,7 @@ "SECRET_KEY": None, "ALLOWED_HOSTS": ["127.0.0.1", "localhost"], "CORS_ALLOWED_ORIGINS": ["http://localhost:5173", "http://127.0.0.1:5173"], + "CSRF_TRUSTED_ORIGINS": [], "SESSION": { "COOKIE_AGE": 2592000, # 30 days "SAVE_EVERY_REQUEST": True, @@ -68,6 +69,11 @@ DEBUG = config.get("DEBUG", cast=bool) ALLOWED_HOSTS = config.get("ALLOWED_HOSTS", cast=list) CORS_ALLOWED_ORIGINS = config.get("CORS_ALLOWED_ORIGINS", cast=list) +CSRF_TRUSTED_ORIGINS = config.get("CSRF_TRUSTED_ORIGINS", cast=list) + +# Requests arrive via Cloudflare Tunnel over HTTPS; make Django trust the +# forwarded proto so Secure cookies and is_secure() work correctly. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # --- Infrastructure --- DATABASES = {"default": dj_database_url.parse(config.get("DATABASE.URL"))} From da34a586e592fb1452afa6ff4fb15bcec4f5a303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E6=9D=B0=20525370910098?= Date: Thu, 13 Aug 2026 23:14:53 +0800 Subject: [PATCH 3/4] fix(spider): update ORC crawler base URL to gc.sjtu.edu.cn The course catalog moved from www.ji.sjtu.edu.cn to gc.sjtu.edu.cn and the old domain's TLS certificate has expired. --- apps/spider/crawlers/orc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/spider/crawlers/orc.py b/apps/spider/crawlers/orc.py index aba3fdd..538a35f 100644 --- a/apps/spider/crawlers/orc.py +++ b/apps/spider/crawlers/orc.py @@ -5,12 +5,12 @@ from apps.web.models import Course, CourseOffering, Instructor from lib.constants import CURRENT_TERM -BASE_URL = "https://www.ji.sjtu.edu.cn/" +BASE_URL = "https://gc.sjtu.edu.cn/" ORC_BASE_URL = urljoin(BASE_URL, "/academics/courses/courses-by-number/") # ORC_UNDERGRAD_SUFFIX = "Departments-Programs-Undergraduate" # ORC_GRADUATE_SUFFIX = "Departments-Programs-Graduate" COURSE_DETAIL_URL_PREFIX = ( - "https://www.ji.sjtu.edu.cn/academics/courses/courses-by-number/course-info/?id=" + "https://gc.sjtu.edu.cn/academics/courses/courses-by-number/course-info/?id=" ) UNDERGRAD_URL = ORC_BASE_URL INSTRUCTOR_TERM_REGEX = re.compile(r"^(?P\w*)\s?(\((?P\w*)\))?") From 4ae2b604f3da57eb30f58e5be7ba5d032172b1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E6=9D=B0=20525370910098?= Date: Thu, 13 Aug 2026 23:15:06 +0800 Subject: [PATCH 4/4] fix(auth): type casts, turnstile retry, wj clock tolerance - cast OTP_TIMEOUT to int (env override injects a string, breaking float comparisons) - retry Turnstile siteverify on transient network failures with short timeout - handle non-JSON siteverify responses (rate limit 429 HTML) - tolerate 60s negative timestamp offset (WJ server clock measured ~39s slow) - fix Python 2 style except clauses that never caught TypeError - add diagnostic logging for submission timestamp failures --- apps/auth/utils.py | 93 +++++++++++++++++++++++++++++++++------------- apps/auth/views.py | 32 +++++++++++++--- 2 files changed, 94 insertions(+), 31 deletions(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 3e63695..4411e5c 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -19,7 +19,7 @@ AUTH_SETTINGS = settings.AUTH PASSWORD_LENGTH_MIN = AUTH_SETTINGS["PASSWORD_LENGTH_MIN"] PASSWORD_LENGTH_MAX = AUTH_SETTINGS["PASSWORD_LENGTH_MAX"] -OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] +OTP_TIMEOUT = int(AUTH_SETTINGS["OTP_TIMEOUT"]) EMAIL_DOMAIN_NAME = AUTH_SETTINGS["EMAIL_DOMAIN_NAME"] QUEST_SETTINGS = settings.QUEST @@ -46,7 +46,7 @@ def get_survey_details(action: str) -> dict[str, Any] | None: try: question_id = int(action_details.get("QUESTIONID")) - except ValueError, TypeError: + except (ValueError, TypeError): # fmt: skip logger.error( "Could not parse 'QUESTIONID' for action '%s'. Check your settings.", action ) @@ -59,35 +59,75 @@ def get_survey_details(action: str) -> dict[str, Any] | None: } +# Dedicated network timeout for the siteverify HTTP call. Kept short and +# separate from OTP_TIMEOUT: the OTP window is a business rule, not a +# network deadline. +TURNSTILE_VERIFY_TIMEOUT = 15 +TURNSTILE_VERIFY_RETRIES = 3 + + async def verify_turnstile_token( turnstile_token, client_ip ) -> tuple[bool, Response | None]: """Helper function to verify Turnstile token with Cloudflare's API""" - try: - async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: - response = await client.post( - "https://challenges.cloudflare.com/turnstile/v0/siteverify", - data={ - "secret": settings.TURNSTILE_SECRET_KEY, - "response": turnstile_token, - "remoteip": client_ip, - }, + last_error: Exception | None = None + for attempt in range(TURNSTILE_VERIFY_RETRIES): + try: + async with httpx.AsyncClient(timeout=TURNSTILE_VERIFY_TIMEOUT) as client: + response = await client.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={ + "secret": settings.TURNSTILE_SECRET_KEY, + "response": turnstile_token, + "remoteip": client_ip, + }, + ) + try: + data = response.json() + except ValueError: + logger.error( + "Turnstile siteverify returned non-JSON: status=%s body=%s", + response.status_code, + response.text[:200], + ) + return False, Response( + {"error": "Turnstile verification error"}, status=502 + ) + if not data.get("success"): + logger.warning("Turnstile verification failed: %s", data) + return False, Response( + {"error": "Turnstile verification failed"}, status=403 + ) + return True, None + except httpx.TimeoutException as e: + last_error = e + logger.warning( + "Turnstile verification timed out (attempt %d/%d)", + attempt + 1, + TURNSTILE_VERIFY_RETRIES, ) - if not response.json().get("success"): - logger.warning("Turnstile verification failed: %s", response.json()) + except httpx.HTTPError as e: + # ConnectError / ReadError etc.: transient network failures. + last_error = e + logger.warning( + "Turnstile verification network error (attempt %d/%d): %s", + attempt + 1, + TURNSTILE_VERIFY_RETRIES, + e, + ) + except Exception: + logger.exception("Turnstile verification error") return False, Response( - {"error": "Turnstile verification failed"}, status=403 + {"error": "Turnstile verification error"}, status=500 ) - return True, None - except httpx.TimeoutException: - logger.error("Turnstile verification timed out") - return False, Response( - {"error": "Turnstile verification timed out"}, status=504 - ) - except Exception: - logger.error("Turnstile verification error") - return False, Response({"error": "Turnstile verification error"}, status=500) + + logger.error( + "Turnstile verification failed after %d attempts: %s", + TURNSTILE_VERIFY_RETRIES, + last_error, + ) + return False, Response({"error": "Turnstile verification timed out"}, status=504) async def get_latest_answer( @@ -133,7 +173,7 @@ async def get_latest_answer( full_url_path = f"{QUEST_BASE_URL}/{quest_api}/json" try: - async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: + async with httpx.AsyncClient(timeout=TURNSTILE_VERIFY_TIMEOUT) as client: response = await client.get( full_url_path, params=final_query_params, @@ -153,7 +193,10 @@ async def get_latest_answer( status=500, ) except Exception: - logger.error("An unexpected error occurred") + logger.exception( + "Questionnaire API returned unexpected response: %s", + response.text[:200] if "response" in locals() else "(no response)", + ) return None, Response({"error": "An unexpected error occurred"}, status=500) # Filter and return only the required fields from the first row diff --git a/apps/auth/views.py b/apps/auth/views.py index 01aafb0..316397f 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -25,7 +25,7 @@ AUTH_SETTINGS = settings.AUTH -OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] +OTP_TIMEOUT = int(AUTH_SETTINGS["OTP_TIMEOUT"]) TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"] ACTION_LIST = AUTH_SETTINGS["ACTION_LIST"] TOKEN_RATE_LIMIT = AUTH_SETTINGS["TOKEN_RATE_LIMIT"] @@ -240,7 +240,7 @@ def verify_callback_api(request): otp_data = json.loads(otp_data_raw.decode("utf-8")) expected_temp_token = otp_data.get("temp_token") initiated_at = otp_data.get("initiated_at") - except json.JSONDecodeError, AttributeError: + except (json.JSONDecodeError, AttributeError): # fmt: skip logger.error("Invalid OTP data format in verify_callback_api") return Response({"error": "Invalid OTP data format"}, status=401) @@ -261,15 +261,35 @@ def verify_callback_api(request): submitted_at = dateutil.parser.parse(submitted_at_str).timestamp() - # Additional validation: check submission is after initiation and within window - if submitted_at < initiated_at or (submitted_at - initiated_at) > OTP_TIMEOUT: + # Additional validation: check submission is after initiation and within window. + # The WJ platform's server clock is measurably slow (~39s, verified via + # its HTTP Date header), so tolerate a small negative offset instead of + # rejecting valid submissions. + timestamp_tolerance = 60 + if ( + submitted_at + timestamp_tolerance < initiated_at + or (submitted_at - initiated_at) > OTP_TIMEOUT + ): + logger.warning( + "Submission timestamp outside validity window: " + "submitted_at=%s initiated_at=%s diff=%.1fs", + submitted_at, + initiated_at, + submitted_at - initiated_at, + ) return Response( {"error": "Submission timestamp outside validity window"}, status=401, ) - except ValueError, TypeError: - logger.error("Error parsing submission timestamp") + except (ValueError, TypeError) as e: + logger.error( + "Error parsing submission timestamp: submitted_at_str=%r " + "initiated_at=%r exception=%s", + locals().get("submitted_at_str"), + locals().get("initiated_at"), + e, + ) return Response({"error": "Invalid submission timestamp"}, status=401) # Step 7: Update state to verified and add user details