From 97bf31862b59dc8e2d9d3620599692642656f616 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Tue, 22 Sep 2026 13:54:38 -0400 Subject: [PATCH 1/4] Mark the Django session cookie Secure by default (#3968) --- env/backend.env | 1 + main/settings.py | 1 + main/settings_test.py | 12 ++++++++++++ 3 files changed, 14 insertions(+) diff --git a/env/backend.env b/env/backend.env index 82f45d76b3..10ef493e41 100644 --- a/env/backend.env +++ b/env/backend.env @@ -11,6 +11,7 @@ CORS_ALLOWED_ORIGINS='["http://open.odl.local:8062"]' CSRF_TRUSTED_ORIGINS='["http://open.odl.local:8062", "http://api.open.odl.local:8063"]' CSRF_COOKIE_DOMAIN=open.odl.local CSRF_COOKIE_SECURE=False +SESSION_COOKIE_SECURE=False MITOL_COOKIE_DOMAIN=open.odl.local MITOL_COOKIE_NAME=mitlearn diff --git a/main/settings.py b/main/settings.py index 0cbd76c3b5..ccaf2b7632 100644 --- a/main/settings.py +++ b/main/settings.py @@ -216,6 +216,7 @@ SESSION_COOKIE_DOMAIN = get_string("SESSION_COOKIE_DOMAIN", None) SESSION_COOKIE_NAME = get_string("SESSION_COOKIE_NAME", "sessionid") +SESSION_COOKIE_SECURE = get_bool("SESSION_COOKIE_SECURE", True) # noqa: FBT003 if COOKIE_TOMBSTONES: tombstone_middleware = "main.middleware.cookie_tombstones.CookieTombstoneMiddleware" diff --git a/main/settings_test.py b/main/settings_test.py index a1ffc99e1b..ae48751e45 100644 --- a/main/settings_test.py +++ b/main/settings_test.py @@ -170,6 +170,18 @@ def test_secure_proxy_ssl_header(self): settings_vars = self.reload_settings() assert "SECURE_PROXY_SSL_HEADER" not in settings_vars + def test_session_cookie_secure(self): + """SESSION_COOKIE_SECURE is on by default and can be turned off for local dev""" + with mock.patch.dict("os.environ", REQUIRED_SETTINGS, clear=True): + assert self.reload_settings()["SESSION_COOKIE_SECURE"] is True + + with mock.patch.dict( + "os.environ", + {**REQUIRED_SETTINGS, "SESSION_COOKIE_SECURE": "False"}, + clear=True, + ): + assert self.reload_settings()["SESSION_COOKIE_SECURE"] is False + def test_x_forwarded_proto_makes_request_secure(self): """Only X-Forwarded-Proto: https marks a request as secure""" factory = RequestFactory() From 2ef6c7e1dbb79153ba5931928893e91c941fd999 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Tue, 22 Sep 2026 14:09:47 -0400 Subject: [PATCH 2/4] Skip unreferenced static files when ingesting edX course archives (#3942) --- learning_resources/etl/edx_shared.py | 85 +++-- learning_resources/etl/edx_shared_test.py | 119 +++++- learning_resources/etl/utils.py | 242 +++++++++++- learning_resources/etl/utils_test.py | 344 +++++++++++++++++- .../commands/audit_olx_references.py | 65 ++++ .../commands/unpublish_excluded_files.py | 112 ++++++ .../commands/unpublish_staff_only_files.py | 69 ---- learning_resources/tasks.py | 33 +- learning_resources/tasks_test.py | 35 +- main/celery.py | 4 +- 10 files changed, 962 insertions(+), 146 deletions(-) create mode 100644 learning_resources/management/commands/audit_olx_references.py create mode 100644 learning_resources/management/commands/unpublish_excluded_files.py delete mode 100644 learning_resources/management/commands/unpublish_staff_only_files.py diff --git a/learning_resources/etl/edx_shared.py b/learning_resources/etl/edx_shared.py index 71fff4dbf3..4f940ec255 100644 --- a/learning_resources/etl/edx_shared.py +++ b/learning_resources/etl/edx_shared.py @@ -11,14 +11,15 @@ from django.core.cache import caches from django.db.models import Prefetch, Q +from learning_resources.constants import VALID_TEXT_FILE_TYPES from learning_resources.etl.constants import ETLSource from learning_resources.etl.loaders import load_content_files from learning_resources.etl.utils import ( calc_checksum, + excluded_olx_paths, get_bucket_by_name, get_edx_module_id, get_s3_prefix_for_source, - staff_only_olx_paths, transform_content_files, ) from learning_resources.models import ContentFile, LearningResourceRun @@ -369,32 +370,42 @@ def sync_edx_course_files( ) -def unpublish_staff_only_content_files( - etl_source: str, ids: list[int], keys: list[str] -) -> int: +def unpublish_excluded_content_files( + etl_source: str, ids: list[int], keys: list[str], *, dry_run: bool = False +) -> list[dict]: """ - Unpublish (and deindex) content files under staff-only OLX subtrees for the - runs matching the given archive keys, without re-extracting anything. + Unpublish (and deindex) content files the course does not use — staff-only + subtrees, asset manifests and unreferenced static files — for the runs + matching the given archive keys, without re-extracting anything. Args: etl_source(str): The edx ETL source ids(list of int): list of course ids to process keys(list[str]): list of S3 archive keys to search through + dry_run(bool): count the rows but leave them published and deindex nothing Returns: - int: number of content files unpublished + list of dict: a row per run whose archive excludes content files it has, + counting the excluded rows, the ones this call unpublished (or would + have, under dry_run) and the run's content files in total. Counts, + not paths, so the payload stays small enough to cross the celery + result backend for every run at once. """ from learning_resources_search import tasks as search_tasks from vector_search import tasks as vector_tasks bucket = get_bucket_by_name(settings.COURSE_ARCHIVE_BUCKET_NAME) run_lookup = build_run_lookup(etl_source, ids) - total = 0 + rows = [] for key in keys: matching_runs = run_lookup.get(extract_run_id_from_key(etl_source, key)) if not matching_runs: continue run = matching_runs[0] + if not ContentFile.objects.filter(run=run).exists(): + # a run with no content files has none to unpublish, and its archive + # is a download and an extract to find that out + continue with TemporaryDirectory() as tempdir: tarpath = Path(tempdir, key.rsplit("/", maxsplit=1)[-1]) bucket.download_file(key, tarpath) @@ -408,23 +419,55 @@ def unpublish_staff_only_content_files( if olx_path is None: continue try: - hidden_paths = staff_only_olx_paths(olx_path) + excluded_paths = excluded_olx_paths(olx_path) except ElementTree.ParseError: log.exception("Malformed OLX in %s, skipping", key) continue - hidden_keys = {get_edx_module_id(str(path), run) for path in hidden_paths} - if not hidden_keys: + ingestable = [ + path + for path in olx_path.rglob("*") + if path.is_file() + and path.suffix.lower() in VALID_TEXT_FILE_TYPES + and not any( + "draft" in part for part in path.relative_to(olx_path).parts[:-1] + ) + ] + # get_edx_module_id writes a space as an underscore, so "foo bar.pdf" + # and "foo_bar.pdf" are one row; it stays if either path is ingested + excluded_keys = { + get_edx_module_id(str(path), run) + for path in ingestable + if path in excluded_paths + } - { + get_edx_module_id(str(path), run) + for path in ingestable + if path not in excluded_paths + } + if not excluded_keys: continue # scoped to this run: keys embed the run_id, but never rely on that alone - hidden_files = ContentFile.objects.filter(run=run, key__in=hidden_keys) - unpublished = hidden_files.filter(published=True).update(published=False) - total += unpublished - log.info( - "Unpublished %d staff-only content files for %s", unpublished, run.run_id - ) - # dispatched whenever hidden rows exist, not only when this call flipped - # them, so a re-run after a failed deindex task cleans up the indexes - if hidden_files.exists(): + excluded_files = ContentFile.objects.filter(run=run, key__in=excluded_keys) + excluded = excluded_files.count() + if not excluded: + continue + if dry_run: + unpublished = excluded_files.filter(published=True).count() + else: + unpublished = excluded_files.filter(published=True).update(published=False) + log.info( + "Unpublished %d excluded content files for %s", unpublished, run.run_id + ) + # dispatched whenever excluded rows exist, not only when this call + # flipped them, so a re-run after a failed deindex task cleans up + # the indexes search_tasks.deindex_run_content_files.delay(run.id, unpublished_only=True) vector_tasks.remove_unpublished_run_content_files.delay(run.id) - return total + rows.append( + { + "run_id": run.run_id, + "excluded": excluded, + "unpublished": unpublished, + "total": ContentFile.objects.filter(run=run).count(), + } + ) + return rows diff --git a/learning_resources/etl/edx_shared_test.py b/learning_resources/etl/edx_shared_test.py index 1d9cc34aa6..f60a5f378e 100644 --- a/learning_resources/etl/edx_shared_test.py +++ b/learning_resources/etl/edx_shared_test.py @@ -19,7 +19,7 @@ normalize_run_id, process_course_archive, sync_edx_course_files, - unpublish_staff_only_content_files, + unpublish_excluded_content_files, ) from learning_resources.etl.utils import get_edx_module_id, get_s3_prefix_for_source from learning_resources.factories import ( @@ -1660,7 +1660,9 @@ def _staff_only_archive(tmp_path) -> Path: "course/run.xml": '', "chapter/ok.xml": '', "sequential/seq_ok.xml": '', - "vertical/v_ok.xml": '', + "vertical/v_ok.xml": ( + '' + ), "html/h_ok.xml": '', "html/h_ok.html": "

ok

", "chapter/staff.xml": ( @@ -1670,6 +1672,13 @@ def _staff_only_archive(tmp_path) -> Path: "vertical/v_staff.xml": '', "html/h_staff.xml": '', "html/h_staff.html": "

staff

", + # nothing links this, so it is from an earlier offering + "static/stale_syllabus.pdf": "stale", + # the video id keeps subs_ABC123; its space-spelled twin is a stale copy + # that get_edx_module_id folds onto the same content file key + "video/vid.xml": '