Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
feb45bc
fix(deploy): use ghcr base image, add tunnel service with http2, pg18…
JackyTJie Aug 13, 2026
6ba3f2e
fix(security): csrf trusted origins, proxy ssl header, csrftoken cookie
JackyTJie Aug 13, 2026
da34a58
fix(spider): update ORC crawler base URL to gc.sjtu.edu.cn
JackyTJie Aug 13, 2026
4ae2b60
fix(auth): type casts, turnstile retry, wj clock tolerance
JackyTJie Aug 13, 2026
7ebfda6
Merge remote-tracking branch 'origin/main' into fix/production-deploy
JackyTJie Aug 29, 2026
6ab1305
Merge remote-tracking branch 'origin/main' into fix/production-deploy
JackyTJie Aug 29, 2026
bb7ec73
Merge remote-tracking branch 'origin/main' into fix/production-deploy
JackyTJie Aug 29, 2026
9a41064
Merge remote-tracking branch 'origin/main' into fix/production-deploy
JackyTJie Sep 2, 2026
b557252
fix(auth): raise WJ clock tolerance to 220s to absorb server drift
JackyTJie Sep 2, 2026
a839815
feat(static): serve admin static files via whitenoise
JackyTJie Sep 2, 2026
ad7bcfd
Merge remote-tracking branch 'origin/main' into fix/production-deploy
JackyTJie Sep 3, 2026
920c2ea
feat(web): add Syllabus/SyllabusFile models and upload config
JackyTJie Sep 5, 2026
10bf7a3
feat(web): syllabus extraction, Ollama analysis, and Celery task
JackyTJie Sep 5, 2026
f1a1ec7
feat(web): syllabus upload/list/download API and admin
JackyTJie Sep 5, 2026
94b93dd
chore: ruff-format deviant files, dev worker service, media volume
JackyTJie Sep 5, 2026
27b0042
fix(web): syllabus API routing, eager refresh, task failure handling
JackyTJie Sep 5, 2026
71525d1
test(web): syllabus upload/analysis/download suite (17 tests)
JackyTJie Sep 5, 2026
be53b4d
fix(web): bind celery current app and fix Ollama JSON responses
JackyTJie Sep 5, 2026
e7df6b9
Merge branch 'feat/syllabus-upload' into fix/production-deploy
JackyTJie Sep 5, 2026
743bd5b
test(web): match Ollama mock to nested message.content shape
JackyTJie Sep 5, 2026
1f2ec63
deploy: add celery worker service and persistent media volume
JackyTJie Sep 5, 2026
73ebce6
deploy: persist media on /mnt/data/GCAtlas bind mount
JackyTJie Sep 5, 2026
2239c40
test(web): match Ollama mock to nested message.content shape
JackyTJie Sep 5, 2026
9fc3213
fix(web): sanitize download log id and document empty JSON excepts
JackyTJie Sep 5, 2026
7754df5
fix(spider): import gc offerings into CourseOffering with instructors
JackyTJie Sep 5, 2026
5baf3fd
fix(web): schedule gc offerings crawl, drop dead beat entries
JackyTJie Sep 5, 2026
48c048a
Merge branch 'feat/syllabus-upload' into fix/production-deploy
JackyTJie Sep 5, 2026
63dcc9d
fix(deploy): run celery beat alongside the worker
JackyTJie Sep 5, 2026
6ef7449
feat(web): reject low-match syllabi, recycle files, add delete API
JackyTJie Sep 5, 2026
77a2e43
Merge branch 'feat/syllabus-upload' into fix/production-deploy
JackyTJie Sep 5, 2026
f964ca1
fix(spider): canonicalize GC instructor names at import to stop dupli…
JackyTJie Sep 5, 2026
ff5e997
fix(spider): split merged instructor cells at import, not only at parse
JackyTJie Sep 5, 2026
a8274bd
fix(spider): canonicalize ORC catalog instructor names; fix beat sche…
JackyTJie Sep 5, 2026
fe75762
feat(web): canonicalize new review professor names against course ins…
JackyTJie Sep 5, 2026
da05c41
fix(web): parenthesize except clause; canonicalize professor names in…
JackyTJie Sep 5, 2026
b87edaa
fix(web): parenthesize multi-exception clause in course filter
JackyTJie Sep 5, 2026
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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Local dev/media artifacts must not end up in the image (uploads persist
# on /mnt/data/GCAtlas via bind mount).
media/
.venv/
.git/
data/backups/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
staticfiles
media/

# Flask stuff:
instance/
Expand Down
9 changes: 8 additions & 1 deletion Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ 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
RUN python django_manage.py collectstatic --noinput

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

COPY --from=builder /app /app

# Media uploads land here; make the named volume writable by nonroot.
RUN mkdir -p /app/media && chown -R nonroot:nonroot /app/media

WORKDIR /app

USER nonroot
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
33 changes: 27 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,36 @@ 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 and drifts over time
# (measured ~39s slow on 2026-08-13, ~156s slow on 2026-08-29, drifting
# ~7s/day). Tolerance raised to 220s to absorb current drift; revisit if
# drift continues to grow (see dynamic calibration as the durable fix).
timestamp_tolerance = 220
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
Loading
Loading