Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 68 additions & 25 deletions apps/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
32 changes: 26 additions & 6 deletions apps/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/spider/crawlers/orc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<name>\w*)\s?(\((?P<term>\w*)\))?")
Expand Down
2 changes: 2 additions & 0 deletions apps/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
"""
Expand Down
15 changes: 13 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down Expand Up @@ -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:
6 changes: 6 additions & 0 deletions website/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"))}
Expand Down
Loading