14. Referencing Assets#
Status#
Proposed.
Context#
13. Shared Static Assets introduces “Assets” (also called “shared assets”) to hold shared asset files within a learning package. This ADR decides how content refers to those files, and to the files attached directly to a component.
Today, course content references assets in one of two ways:
- Portable URLs, e.g.
/static/Time_medium_icon.png. These resolve against a single course-wide namespace of files, and must be rewritten before they can be served. They technically share a namespace with Studio’s built-in assets (e.g.
/static/studio/css/studio-main-v1.css). However, since user-authored content generally does not reference Studio’s built-in JS/CSS files, any usage of"/static/..."or'/static/...'in XBlock OLX is assumed to be referring to course Files, and will be rewritten to one of the URL forms defined in 5. Serving Course Team Authored Static Assets (“URLs”) before being served to the user.- Asset key URLs, e.g.
https://courses.example.com/asset-v1:HarvardX+StudioAdv1+2T2019+type@asset+block@Time_medium_icon.pngor sometimes just/asset-v1:HarvardX+StudioAdv1+2T2019+type@asset+block@Time_medium_icon.png These are unambiguous, but they encode the course run key and sometimes the hostname, so they can break when content is copied to a new run (every rerun), to another course, or to another instance, and the original version is deleted or modified. Technically, the
asset-v1:...opaque key part could be used as an identifier on its own, but this rarely occurs in practice.
For backwards compatibility, both of these formats must continue to be supported indefinitely.
Of the two, only the /static/ format is portable, so it is the one we build on. Its weakness is detection: /static/ can occur in many unrelated contexts, so the system currently limits auto-detection to quoted URLs like "/static/blah", which means that any reference that is not quoted as expected (e.g. something on its own line in the text or something like "/static/...") will not be detected.
Decisions#
1. Assets can be referenced using the existing /static/ URL format, improved#
When editing an XBlock Component (stored in openedx_content, not modulestore), whether using a visual editor or editing OLX directly, users who want to reference a static asset file (e.g. embed an image) can browse through all files attached to the current component (those under the static/ prefix, which are learner-accessible), as well as all shared assets in the course (or copied/linked into the course from a library). They can then use the existing /static/ path prefix to identify that asset:
/static/filename.ext
For example, for an HTML component that wants to use the Asset moon-orbit.svg:
<img src="/static/moon-orbit.svg">
However, the existing code for detecting /static/x references only detects them if they are quoted in either single quotes or double quotes. This does not detect other situations like "/static/triangle.png" which occurs in the Drag and Drop XBlock’s OLX nor JSON-escaped references like \/static\/triangle.png inside JSON stored in an OLX field.
To improve this, the /static/ detection function should be improved to handle the examples described above, but only for the purposes of dependency detection (see decision 3); rewriting such newly-detected URL forms would break existing XBlock behavior.
3. References using /static/ URLs are tracked as dependencies#
When editing an XBlock/Component such as a Text (HTML) component, course authors can freely copy and paste /static/ references into the HTML/OLX to reference any available shared assets in the course (including ones copied from content libraries). When the author saves their changes, the OLX will be scanned for /static/ references using the improved detection from decision 1, and each reference will be resolved as described in decision 2. As part of saving the new ComponentVersion, the system will create a PublishableEntityVersionDependency from the component version to any shared assets used. (References that resolve to the component’s own attached files need no dependency.)
This means the existing side-effect machinery works unchanged: editing a draft Asset marks every component that uses it as having unpublished changes, and the publish log records the change against those components as well. It also answers “where is this asset used?” with a query instead of by parsing content.
⚠️ However, note that on its own, this cannot detect relative references among Assets. For example, if an Asset example.html references <img src="image.png">, then there is a dependency between example.html and image.png that is not represented by a PublishableEntityVersionDependency. We can attempt to reduce the occurrence of this by scanning HTML files for references as well, but that is not going to be particularly reliable and could also generate circular dependencies, which the set_version_dependencies API says should be avoided.
To address this, shared Assets used by a Component are served relative to that Component, and in “strict mode” (used for content libraries), a shared Asset is only served for a Component if the Component declares it as a dependency (13. Shared Static Assets, decision 4). In the example above, image.png would then have to be explicitly added as a dependency of each Component that uses example.html. In “lenient mode” (used for courses, for now), undeclared relative references still work, and they’re logged so they can be found.
4. Unresolved references create placeholder Assets#
Authors will sometimes save a component that references /static/foo.png before foo.png has been uploaded, or after it has been deleted. Rendering is unaffected, since references are resolved by name at render time, but without a PublishableEntityVersionDependency the side-effect machinery from decision 3 would not apply once the file is uploaded: the component would not show as having unpublished changes, publishing it would not publish foo.png, and “where is this asset used?” would miss it.
To avoid this, when a /static/ reference does not match any attached asset or shared asset, the system will create a placeholder Asset: an Asset with the referenced filename, but with no versions. The dependency is then created as usual, because PublishableEntityVersionDependency references an entity, not a version.
When a file with that name is later uploaded, it becomes the first version of the placeholder rather than a new Asset, and the normal draft side effects mark every referencing component as having unpublished changes. This also means a never-uploaded file and a deleted Asset end up in the same state: an entity with no current draft version, which other component versions still depend on.
Additional rules:
No placeholder is created if the referencing component has an attached asset file with that name (decision 2).
A placeholder is identified by being an
Assetwith no versions at all. Placeholders are excluded from regular Asset listings, export, and search indexing.Placeholders that no longer have any dependents (e.g. typos that were later fixed) can be hard-deleted, since they never had any content. The
on_delete=RESTRICTonreferenced_entityensures a placeholder that is still referenced cannot be deleted by mistake.When a component referencing a placeholder is copied to another Learning Package (decision 5), a placeholder is created in the destination too.
Soft deleted assets can be used like placeholders, for the purpose of tracking unmet dependencies. The course files page should list both placeholder assets and soft deleted assets used as dependencies under a “broken references” / “missing files” heading.
6. Editors should de-reference full URLs on save#
In the course of editing courseware, authors may use their browser’s “Copy Image URL” to copy the URL of an image and paste it elsewhere, resulting in a full, rewritten URL like https://demo.openedx.org/assets/content_libraries/lib:Axim:200/xblock.v1:html@intro/draft/static/images/fig1.png or https://demo.openedx.org/assets/content_libraries/lib:Axim:200/assets/draft/images/fig1.png ending up in the OLX. If any such URLs are detected, they should be automatically rewritten to the /static/ format (subject to some sanity checks to ensure it will still reference the correct asset).
Note: Only rewrite full URLs that resolve to a shared asset in the same learning package (whether through another component’s file URL or the learning-package-level URL), or to the component’s own file, and don’t conflict with any of the component’s own asset files.
7. Versioning follows the library content model#
There are two independent layers of versioning, and they work the same way as for library components used in courses today.
Within a learning package, references are unpinned. A PublishableEntityVersionDependency names an Asset, not a version of it, just as a unit’s unpinned children are components rather than component versions. The version used is determined by where the content is rendered:
In Studio (authoring), the component’s draft is rendered, and
/static/references resolve to the draft version of each referenced Asset.In the LMS, the component’s published version is rendered, and
/static/references resolve to the published version of each referenced Asset.
Editing an Asset’s draft therefore immediately changes what authors see in every component that uses it, and marks those components as having unpublished changes (via the dependency from decision 3). Learners see nothing until the Asset is published. Publishing a component also publishes the draft versions of the Assets it references, the same way publishing a unit publishes its unpinned children, so a published component never references an unpublished Asset.
Between a library and a course, updates are pulled explicitly. A course copy of a library Asset is a downstream of the library Asset, and it tracks the upstream the same way downstream course blocks do:
version_synced: the library Asset’s published version number that the course copy was created from or last synced with.version_declined: the latest upstream version the course author chose not to sync, if any.
Only published library versions are ever copied into a course. Library drafts are never visible to courses.
Upstream tracking for Assets uses the same platform-level mechanism as components (currently the ComponentLink model and related sync APIs in openedx-platform). The upstream model may need adjustment, since an Asset is not a Component or an XBlock and has no XBlock fields to store upstream_version in. The details are left to the platform implementation.
8. Course block usages will be tracked#
In modulestore, courses are represented by an instance of the course XBlock, which forms the root of the course content tree. This XBlock has several references to shared asset files that can be specified, such as course_image, banner_image, video_thumbnail_image, certificate signatory images, textbook PDF URLs, HTML textbook URLs, and instructor images. Such usages should continue working as they do now, but ideally be tracked as dependencies, with PublishableEntityVersionDependency. The details of this are left to implementation, but may be achieved using 15. Shadow Components to shadow the root course block.
Consequences#
Asset usage (“which components use this file?”) becomes a simple query over
PublishableEntityVersionDependency, rather than a best-effort parse of all course content.The course “Files” page can show a list of “broken references” / “missing files” by querying for placeholder Assets (decision 4) that still have dependents, along with the components that reference each one. Deleted Assets that are still referenced can be included in the same list.
For content using
/static/...references: reruns, clipboard copy/paste, library import and library sync never need to rewrite references in component OLX.A course’s learning package always contains every (properly-referenced) asset it uses, so it can be exported, backed up and restored in isolation.
Every component version snapshots its dependencies, adding a small number of rows per component version for components that use shared assets.
The existing two URL formats (
/static/or[https://host]/asset-v1:) have to be supported by the rendering pipeline indefinitely.Library Assets used in many courses are copied into each course component. This costs rows, not bytes, because blobs are deduplicated per org.
Some relative references among Assets (e.g. an HTML file with a dynamic reference to a JS file, like
import(`./${filename}.js`)) will not be identified and tracked viaPublishableEntityVersionDependency.Publishing one component publishes the shared assets it uses, which changes what learners see in every other component that uses it. That’s consistent with how unit children behave, but may be surprising.
Deleting a shared asset needs to be handled carefully. Publishing a component publishes its dependencies’ unpublished changes, and a soft-delete counts as one. So if an author deletes a still-referenced Asset and then publishes any component that uses it, the deletion goes live and breaks every other published component that uses it. To avoid this, we should warn users when they delete an asset that is still referenced. (Or, don’t publish dependency deletions as a side effect.)
Rejected Alternatives#
Linking directly to Assets in libraries#
Letting a course component link to an Asset that lives in a library’s learning package would avoid copying. It was rejected because it breaks learning package isolation (deleting or un-publishing a library asset would break courses), can’t use PublishableEntityVersionDependency for side effects, would give courses two different versioning and permission models for assets depending on where they came from, and is inconsistent with how library components are used in courses.
A new oex-asset: URL scheme#
An earlier draft of this ADR introduced a new reference format, e.g. <img src="oex-asset:moon-orbit.svg">, along with an AssetComponentLink model holding a per-component alias for each referenced Asset. Unlike /static/, oex-asset: never occurs in content for any other reason, so it can be detected with no false positives (e.g. from platform URLs, or a course on HTML whose examples include /static/).
It was rejected because it adds cost without removing any: /static/ and asset-v1: must be supported indefinitely anyway, so the rendering pipeline would have to handle three formats instead of two. Every sanitizer and editor that touches authored content would have to allow the new scheme, and exports would have to rewrite oex-asset: references back to /static/ so that the tarball can be imported on older Open edX versions. The aliases, which were needed to avoid filename collisions when copying from a library, are instead handled by converting shared assets to component-local assets (decision 5).
The cost of staying with /static/ is the occasional false positive from the broader detection in decision 1. With placeholder Assets (decision 4), a false positive results in an unused placeholder and a spurious “missing file” entry, rather than broken content.
Template placeholders instead of URLs#
Placeholders such as {{ asset "moon-orbit.svg" }} are easy to detect, but they are not URLs. HTML parsers, WYSIWYG editors and sanitizers mangle them in src and href attributes, while a URL-shaped reference like /static/moon-orbit.svg survives those tools.
Pinning links to specific Asset versions#
A component version’s reference could name a specific version of the Asset, like a pinned container child. This would stop an Asset edit from affecting components that use it, but authors expect that replacing a shared image updates it everywhere in the course. Pinning also creates a different version of the same asset for every component, and requires a new version of every using component each time the Asset changes. Controlled updates are already provided one layer up, between a library and a course (decision 7).