Skip to content

Commit 61748a7

Browse files
fix: contract course asset URLs to /static/ before saving xblock data (LP-704)
1 parent e98af83 commit 61748a7

4 files changed

Lines changed: 137 additions & 1 deletion

File tree

cms/djangoapps/contentstore/views/tests/test_block.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,32 @@ class TestEditItem(TestEditItemSetup):
19481948
Test xblock update.
19491949
"""
19501950

1951+
def test_data_saved_with_portable_static_urls(self):
1952+
"""
1953+
Absolute asset URLs referencing this course's assets must be contracted
1954+
to the portable /static/ form before persisting, so re-runs and
1955+
export/import don't break them when an editor fails to contract.
1956+
"""
1957+
asset_key = self.course.id.make_asset_key('asset', 'textlog.html')
1958+
self.client.ajax_post(
1959+
self.problem_update_url,
1960+
data={'data': f'<problem><jsinput html_file="/{asset_key}" gradefn="getGrade"/></problem>'},
1961+
)
1962+
problem = self.get_item_from_modulestore(self.problem_usage_key)
1963+
self.assertIn('html_file="/static/textlog.html"', problem.data)
1964+
1965+
def test_data_preserves_other_courses_asset_urls(self):
1966+
"""
1967+
Asset URLs referencing another course's assets are not ours to rewrite.
1968+
"""
1969+
foreign_url = '/asset-v1:OtherX+Other+1T2020+type@asset+block@textlog.html'
1970+
self.client.ajax_post(
1971+
self.problem_update_url,
1972+
data={'data': f'<problem><jsinput html_file="{foreign_url}" gradefn="getGrade"/></problem>'},
1973+
)
1974+
problem = self.get_item_from_modulestore(self.problem_usage_key)
1975+
self.assertIn(f'html_file="{foreign_url}"', problem.data)
1976+
19511977
def test_delete_field(self):
19521978
"""
19531979
Sending null in for a field 'deletes' it

cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
4343
from cms.lib.xblock.upstream_sync_block import sync_from_upstream_block
4444
from cms.lib.xblock.upstream_sync_container import sync_from_upstream_container
45-
from common.djangoapps.static_replace import replace_static_urls
45+
from common.djangoapps.static_replace import contract_static_urls, replace_static_urls
4646
from common.djangoapps.student.auth import (
4747
has_studio_read_access,
4848
has_studio_write_access,
@@ -353,6 +353,11 @@ def _save_xblock(
353353
old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
354354

355355
if data:
356+
if isinstance(data, str):
357+
# Editors receive `data` with /static/ URLs expanded (see get_block_info)
358+
# and don't all contract them back on save; contract here so URLs pinned
359+
# to this course are never persisted (they break on re-run/export/import).
360+
data = contract_static_urls(data, xblock.location.course_key)
356361
# TODO Allow any scope.content fields not just "data" (exactly like the get below this)
357362
xblock.data = data
358363
else:

common/djangoapps/static_replace/__init__.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import re
5+
import uuid
56

67
from django.conf import settings
78
from django.contrib.staticfiles import finders
@@ -240,3 +241,51 @@ def replace_static_url(original, prefix, quote, rest):
240241
return "".join([quote, url, quote])
241242

242243
return process_static_urls(text, replace_static_url, data_dir=static_asset_path or data_directory)
244+
245+
246+
def contract_static_urls(text, course_id):
247+
"""
248+
Reverse of `replace_static_urls` for a course's own assets: rewrite absolute
249+
asset URLs referencing ``course_id``'s assets back to the portable
250+
``/static/<name>`` form.
251+
252+
Handles every form `StaticContent.get_canonicalized_asset_path` can emit:
253+
relative (``/asset-v1:...@name`` or ``.../name``), versioned
254+
(``/assets/courseware/v1/<digest>/asset-v1:...``), host-qualified
255+
(``https://host/asset-v1:...``), and old-style ``/c4x/...`` paths.
256+
257+
URLs referencing other courses' assets, genuinely external URLs, and
258+
already-portable ``/static/`` paths are left untouched.
259+
260+
text: The source text to do the substitution in
261+
course_id: The course whose asset URLs should be made portable
262+
"""
263+
if not course_id or not isinstance(text, str):
264+
return text
265+
266+
# Serialize an asset key with a placeholder name, then split it into the
267+
# course-specific prefix and the separator preceding the asset name.
268+
placeholder = uuid.uuid4().hex
269+
serialized = str(course_id.make_asset_key('asset', placeholder))
270+
name_index = serialized.rfind(placeholder)
271+
key_prefix, separator = serialized[:name_index - 1], serialized[name_index - 1]
272+
# Modern keys serialize with '@' before the name, but canonicalized paths
273+
# may separate the name with '/' instead; accept either.
274+
separator_pattern = '[@/]' if separator == '@' else re.escape(separator)
275+
276+
asset_url_pattern = re.compile(
277+
r"""(?P<quote>\\?['"])""" # the opening quotes
278+
r"""(?:(?:https?:)?//[^'"/]+)?""" # optional scheme and host
279+
r"""(?:/assets/courseware/v\d+/[a-f0-9]{32})?""" # optional versioned-asset prefix
280+
r"""/?""" + re.escape(key_prefix) + separator_pattern +
281+
r"""(?P<name>[^'"?]*)""" # the asset name
282+
r"""(?P<query>\?[^'"]*)?""" # optional query string
283+
r"""(?P=quote)""" # the first matching closing quote
284+
)
285+
286+
def contract(match):
287+
quote = match.group('quote')
288+
query = match.group('query') or ''
289+
return f"{quote}/static/{match.group('name')}{query}{quote}"
290+
291+
return asset_url_pattern.sub(contract, text)

common/djangoapps/static_replace/test/test_static_replace.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from common.djangoapps.static_replace import (
1717
_url_replace_regex,
18+
contract_static_urls,
1819
make_static_urls_absolute,
1920
process_static_urls,
2021
replace_course_urls,
@@ -62,6 +63,61 @@ def test_multi_replace():
6263
replace_course_urls(replace_course_urls(course_source, COURSE_KEY), COURSE_KEY)
6364

6465

66+
SPLIT_COURSE_KEY = CourseKey.from_string('course-v1:HarvardX+BDSG+2T2022')
67+
68+
69+
def test_contract_static_urls_jsinput_html_file():
70+
text = '<jsinput html_file="/asset-v1:HarvardX+BDSG+2T2022+type@asset+block@textlog.html" gradefn="getGrade"/>'
71+
expected = '<jsinput html_file="/static/textlog.html" gradefn="getGrade"/>'
72+
assert contract_static_urls(text, SPLIT_COURSE_KEY) == expected
73+
74+
75+
def test_contract_static_urls_versioned_asset_path():
76+
text = (
77+
'<img src="/assets/courseware/v1/43761bc1cc13b218d267f790ed4313d1/'
78+
'asset-v1:HarvardX+BDSG+2T2022+type@asset+block/image.jpg">'
79+
)
80+
expected = '<img src="/static/image.jpg">'
81+
assert contract_static_urls(text, SPLIT_COURSE_KEY) == expected
82+
83+
84+
def test_contract_static_urls_host_qualified():
85+
text = '<a href="https://courses.edx.org/asset-v1:HarvardX+BDSG+2T2022+type@asset+block@syllabus.pdf">Syllabus</a>'
86+
expected = '<a href="/static/syllabus.pdf">Syllabus</a>'
87+
assert contract_static_urls(text, SPLIT_COURSE_KEY) == expected
88+
89+
90+
def test_contract_static_urls_preserves_query_string():
91+
text = '<img src="/asset-v1:HarvardX+BDSG+2T2022+type@asset+block@pic.png?width=100">'
92+
expected = '<img src="/static/pic.png?width=100">'
93+
assert contract_static_urls(text, SPLIT_COURSE_KEY) == expected
94+
95+
96+
def test_contract_static_urls_leaves_foreign_course_urls():
97+
"""URLs pointing at a different course's assets are not this course's to rewrite."""
98+
text = '<img src="/asset-v1:OtherX+Other+1T2020+type@asset+block@pic.png">'
99+
assert contract_static_urls(text, SPLIT_COURSE_KEY) == text
100+
101+
102+
def test_contract_static_urls_leaves_external_and_portable_urls():
103+
text = '<a href="https://example.com/asset.png">x</a> <img src="/static/already.png">'
104+
assert contract_static_urls(text, SPLIT_COURSE_KEY) == text
105+
106+
107+
def test_contract_static_urls_old_style_course_key():
108+
text = '<img src="/c4x/org/course/asset/foo.gif">'
109+
expected = '<img src="/static/foo.gif">'
110+
assert contract_static_urls(text, COURSE_KEY) == expected
111+
112+
113+
def test_contract_static_urls_non_string_passthrough():
114+
assert contract_static_urls(None, SPLIT_COURSE_KEY) is None
115+
assert contract_static_urls('', SPLIT_COURSE_KEY) == ''
116+
data = {'key': 'value'}
117+
assert contract_static_urls(data, SPLIT_COURSE_KEY) is data
118+
assert contract_static_urls('text', None) == 'text'
119+
120+
65121
def test_process_url():
66122
def processor(__, prefix, quote, rest): # pylint: disable=redefined-outer-name
67123
return quote + 'test' + prefix + rest + quote

0 commit comments

Comments
 (0)