From f495106f800353b9041cfc539c834b65452aa054 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Thu, 7 Nov 2024 19:35:54 -0800 Subject: [PATCH 1/8] Update plugin_data to insert template with JSON inline. --- kolibri/core/webpack/hooks.py | 18 +++++++----------- packages/kolibri-plugin-data/src/index.js | 6 +++++- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/kolibri/core/webpack/hooks.py b/kolibri/core/webpack/hooks.py index 7c23c4d0c65..27f1a960ea9 100644 --- a/kolibri/core/webpack/hooks.py +++ b/kolibri/core/webpack/hooks.py @@ -215,20 +215,16 @@ def plugin_data_tag(self): if self.plugin_data: return [ """ - + """.format( - name="kolibriPluginDataGlobal", bundle=self.unique_id, plugin_data=json.dumps( - json.dumps( - self.plugin_data, - separators=(",", ":"), - ensure_ascii=False, - cls=DjangoJSONEncoder, - ) + self.plugin_data, + separators=(",", ":"), + ensure_ascii=False, + cls=DjangoJSONEncoder, ), ) ] diff --git a/packages/kolibri-plugin-data/src/index.js b/packages/kolibri-plugin-data/src/index.js index cb8faf3e8d0..503c963752c 100644 --- a/packages/kolibri-plugin-data/src/index.js +++ b/packages/kolibri-plugin-data/src/index.js @@ -1 +1,5 @@ -export default (global['kolibriPluginDataGlobal'] || {})[__kolibriModuleName] || {}; +const template = document.querySelector(`template[data-plugin="${__kolibriModuleName}"]`); + +const data = template ? JSON.parse(template.innerHTML.trim()) : {}; + +export default data; From 384daf454c58379b03821abfe47576316a8a3952 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 22 Nov 2024 07:42:13 -0800 Subject: [PATCH 2/8] Update URL reversal injection logic to leverage plugin_data. --- kolibri/core/assets/src/index.js | 5 - .../assets/src/state/modules/core/actions.js | 2 +- kolibri/core/kolibri_plugin.py | 85 +++----- .../assets/src/composables/usePlugins.js | 4 +- .../manageContent/actions/taskActions.js | 2 +- .../assets/src/views/FacilitiesPage/api.js | 2 +- .../assets/src/views/ManageContentPage/api.js | 2 +- .../facility/assets/src/apiResources.js | 4 +- .../src/modules/facilityConfig/actions.js | 6 +- .../src/composables/useProgressTracking.js | 4 +- .../assets/src/views/TopicsContentPage.vue | 4 +- .../apiResources/NetworkLocationResource.js | 2 +- .../apiResources/PortalResource.js | 4 +- .../mixins/commonSyncElements.js | 2 +- packages/kolibri/__tests__/urls.spec.js | 188 ++++++++++++++++++ .../components/__tests__/auth-message.spec.js | 2 +- packages/kolibri/heartbeat.js | 2 +- packages/kolibri/urls.js | 187 +++++++++++++++-- packages/kolibri/utils/appCapabilities.js | 4 +- 19 files changed, 413 insertions(+), 98 deletions(-) create mode 100644 packages/kolibri/__tests__/urls.spec.js diff --git a/kolibri/core/assets/src/index.js b/kolibri/core/assets/src/index.js index b4819ae111c..45bed6a8b2e 100644 --- a/kolibri/core/assets/src/index.js +++ b/kolibri/core/assets/src/index.js @@ -4,17 +4,12 @@ */ import 'core-js'; import coreApp from 'kolibri'; -import urls from 'kolibri/urls'; import logging from 'kolibri-logging'; import store from 'kolibri/store'; import heartbeat from 'kolibri/heartbeat'; import { i18nSetup } from 'kolibri/utils/i18n'; import coreModule from './state/modules/core'; -// Do this before any async imports to ensure that public paths -// are set correctly -urls.setUp(); - // set up logging logging.setDefaultLevel(process.env.NODE_ENV === 'production' ? 2 : 0); diff --git a/kolibri/core/assets/src/state/modules/core/actions.js b/kolibri/core/assets/src/state/modules/core/actions.js index 94b06e7b39f..24f000ddae9 100644 --- a/kolibri/core/assets/src/state/modules/core/actions.js +++ b/kolibri/core/assets/src/state/modules/core/actions.js @@ -112,7 +112,7 @@ export function kolibriLogin(store, sessionPayload) { browser, os, }, - url: urls['kolibri:core:session-list'](), + url: urls['kolibri:core:session_list'](), method: 'post', }) .then(() => { diff --git a/kolibri/core/kolibri_plugin.py b/kolibri/core/kolibri_plugin.py index 9904558bc38..f126b80111f 100644 --- a/kolibri/core/kolibri_plugin.py +++ b/kolibri/core/kolibri_plugin.py @@ -1,5 +1,4 @@ from django.conf import settings -from django.template.loader import render_to_string from django.templatetags.static import static from django.urls import get_resolver from django.urls import reverse @@ -7,9 +6,7 @@ from django.utils.translation import get_language from django.utils.translation import get_language_bidi from django.utils.translation import get_language_info -from django_js_reverse.core import _safe_json from django_js_reverse.core import generate_json -from django_js_reverse.rjsmin import jsmin import kolibri from kolibri.core.content.utils.paths import get_content_storage_url @@ -31,57 +28,6 @@ class FrontEndCoreAppAssetHook(WebpackBundleHook): bundle_id = "default_frontend" - def url_tag(self): - # Modified from: - # https://github.com/ierror/django-js-reverse/blob/master/django_js_reverse/core.py#L101 - js_name = "window.kolibriPluginDataGlobal['{bundle}'].urls".format( - bundle=self.unique_id - ) - default_urlresolver = get_resolver(None) - - data = generate_json(default_urlresolver) - - # Generate the JS that exposes functions to reverse all Django URLs - # in the frontend. - js = render_to_string( - "django_js_reverse/urls_js.tpl", - {"data": _safe_json(data), "js_name": "__placeholder__"}, - # For some reason the js_name gets escaped going into the template - # so this was the easiest way to inject it. - ).replace("__placeholder__", js_name) - zip_content_origin, zip_content_port = get_zip_content_config() - return [ - mark_safe( - """ - """.format( - js_name=js_name, - static_url=settings.STATIC_URL, - media_url=settings.MEDIA_URL, - content_url=get_content_storage_url( - baseurl=OPTIONS["Deployment"]["URL_PATH_PREFIX"] - ), - zip_content_url=get_zip_content_base_path(), - hashi_url=get_hashi_path(), - zip_content_origin=zip_content_origin, - zip_content_port=zip_content_port, - ) - ) - ] - def navigation_tags(self): return [ hook.render_to_page_load_sync_html() @@ -96,7 +42,6 @@ def render_to_page_load_sync_html(self): """ tags = ( self.plugin_data_tag() - + self.url_tag() + list(self.js_and_css_tags()) + self.navigation_tags() ) @@ -108,6 +53,35 @@ def plugin_data(self): language_code = get_language() static_root = static("assets/fonts/noto-full") full_file = "{}.{}.{}.css?v={}" + + default_urlresolver = get_resolver(None) + + url_data = generate_json(default_urlresolver) + + # Convert the urls key, value pairs to a dictionary + # Turn all dashes in keys into underscores + # This should maintain consistency with our naming, as all namespaces + # are either 'kolibri:core' or 'kolibri:plugin_module_path' + # neither of which can contain dashes. + url_data["urls"] = { + key.replace("-", "_"): value for key, value in url_data["urls"] + } + + zip_content_origin, zip_content_port = get_zip_content_config() + + url_data.update( + { + "__staticUrl": settings.STATIC_URL, + "__mediaUrl": settings.MEDIA_URL, + "__contentUrl": get_content_storage_url( + baseurl=OPTIONS["Deployment"]["URL_PATH_PREFIX"] + ), + "__zipContentUrl": get_zip_content_base_path(), + "__hashiUrl": get_hashi_path(), + "__zipContentOrigin": zip_content_origin, + "__zipContentPort": zip_content_port, + } + ) return { "fullCSSFileModern": full_file.format( static_root, language_code, "modern", kolibri.__version__ @@ -121,6 +95,7 @@ def plugin_data(self): "languageGlobals": self.language_globals(), "oidcProviderEnabled": OIDCProviderHook.is_enabled(), "kolibriTheme": ThemeHook.get_theme(), + "urls": url_data, } def language_globals(self): diff --git a/kolibri/plugins/device/assets/src/composables/usePlugins.js b/kolibri/plugins/device/assets/src/composables/usePlugins.js index ab9279f89e3..08b1ecda8f7 100644 --- a/kolibri/plugins/device/assets/src/composables/usePlugins.js +++ b/kolibri/plugins/device/assets/src/composables/usePlugins.js @@ -10,7 +10,7 @@ export default function usePlugins() { const plugins = ref(null); const fetchPlugins = Promise.resolve( client({ - url: urls['kolibri:core:plugins-list'](), + url: urls['kolibri:core:plugins_list'](), }).then(response => { plugins.value = response.data; }), @@ -23,7 +23,7 @@ export default function usePlugins() { if (plugin.enabled !== value) { return client({ method: 'PATCH', - url: urls['kolibri:core:plugins-detail'](pluginId), + url: urls['kolibri:core:plugins_detail'](pluginId), data: { enabled: value, }, diff --git a/kolibri/plugins/device/assets/src/modules/manageContent/actions/taskActions.js b/kolibri/plugins/device/assets/src/modules/manageContent/actions/taskActions.js index baadd37b362..17072a2cdde 100644 --- a/kolibri/plugins/device/assets/src/modules/manageContent/actions/taskActions.js +++ b/kolibri/plugins/device/assets/src/modules/manageContent/actions/taskActions.js @@ -49,7 +49,7 @@ export function refreshTaskList(store) { } export function refreshDriveList(store) { - return client({ url: urls['kolibri:core:driveinfo-list']() }).then(({ data }) => { + return client({ url: urls['kolibri:core:driveinfo_list']() }).then(({ data }) => { store.commit('wizard/SET_DRIVE_LIST', data); return data; }); diff --git a/kolibri/plugins/device/assets/src/views/FacilitiesPage/api.js b/kolibri/plugins/device/assets/src/views/FacilitiesPage/api.js index 9c857b4216d..b0d9ecbee69 100644 --- a/kolibri/plugins/device/assets/src/views/FacilitiesPage/api.js +++ b/kolibri/plugins/device/assets/src/views/FacilitiesPage/api.js @@ -1,7 +1,7 @@ import client from 'kolibri/client'; import urls from 'kolibri/urls'; -const url = urls['kolibri:core:facility-create-facility'](); +const url = urls['kolibri:core:facility_create_facility'](); export function createFacility(payload) { return client({ diff --git a/kolibri/plugins/device/assets/src/views/ManageContentPage/api.js b/kolibri/plugins/device/assets/src/views/ManageContentPage/api.js index 3cef5e06290..6fcd6c966a2 100644 --- a/kolibri/plugins/device/assets/src/views/ManageContentPage/api.js +++ b/kolibri/plugins/device/assets/src/views/ManageContentPage/api.js @@ -8,7 +8,7 @@ import { TaskTypes } from 'kolibri-common/utils/syncTaskUtils'; import ChannelResource from '../../apiResources/deviceChannel'; function getChannelOnDrive(driveId, channelId) { - return client({ url: urls['kolibri:core:driveinfo-detail'](driveId) }) + return client({ url: urls['kolibri:core:driveinfo_detail'](driveId) }) .then(({ data }) => { const channelMatch = find(data.metadata.channels, { id: channelId }); if (!channelMatch) { diff --git a/kolibri/plugins/facility/assets/src/apiResources.js b/kolibri/plugins/facility/assets/src/apiResources.js index 4168e2b3f03..071560a6e5a 100644 --- a/kolibri/plugins/facility/assets/src/apiResources.js +++ b/kolibri/plugins/facility/assets/src/apiResources.js @@ -4,7 +4,7 @@ import urls from 'kolibri/urls'; export const PortalResource = new Resource({ name: 'portal', validateToken(token) { - const url = urls['kolibri:core:portal-validate-token'](); + const url = urls['kolibri:core:portal_validate_token'](); return this.client({ url, method: 'get', @@ -12,7 +12,7 @@ export const PortalResource = new Resource({ }); }, registerFacility({ facility_id, token }) { - const url = urls['kolibri:core:portal-register'](); + const url = urls['kolibri:core:portal_register'](); return this.client({ url, method: 'post', diff --git a/kolibri/plugins/facility/assets/src/modules/facilityConfig/actions.js b/kolibri/plugins/facility/assets/src/modules/facilityConfig/actions.js index 839284aa6a8..726b3dce72f 100644 --- a/kolibri/plugins/facility/assets/src/modules/facilityConfig/actions.js +++ b/kolibri/plugins/facility/assets/src/modules/facilityConfig/actions.js @@ -41,7 +41,7 @@ export function saveFacilityConfig(store) { export function resetFacilityConfig(store) { const { facilityDatasetId } = store.state; return client({ - url: urls['kolibri:core:facilitydataset-resetsettings'](facilityDatasetId), + url: urls['kolibri:core:facilitydataset_resetsettings'](facilityDatasetId), method: 'POST', }).then(({ data }) => { store.commit('CONFIG_PAGE_MODIFY_ALL_SETTINGS', { @@ -59,7 +59,7 @@ export function resetFacilityConfig(store) { export function setPin(store, payload) { const { facilityDatasetId } = store.state; return client({ - url: urls['kolibri:core:facilitydataset-update-pin'](facilityDatasetId), + url: urls['kolibri:core:facilitydataset_update_pin'](facilityDatasetId), method: 'POST', data: payload, }).then(({ data }) => { @@ -71,7 +71,7 @@ export function setPin(store, payload) { export function unsetPin(store) { const { facilityDatasetId } = store.state; return client({ - url: urls['kolibri:core:facilitydataset-update-pin'](facilityDatasetId), + url: urls['kolibri:core:facilitydataset_update_pin'](facilityDatasetId), method: 'PATCH', }).then(({ data }) => { store.commit('UPDATE_FACILITY_EXTRA_SETTINGS', { extra_fields: data.extra_fields }); diff --git a/kolibri/plugins/learn/assets/src/composables/useProgressTracking.js b/kolibri/plugins/learn/assets/src/composables/useProgressTracking.js index 492d5124433..37b9432e1ef 100644 --- a/kolibri/plugins/learn/assets/src/composables/useProgressTracking.js +++ b/kolibri/plugins/learn/assets/src/composables/useProgressTracking.js @@ -148,7 +148,7 @@ export default function useProgressTracking(store) { function _makeInitContentSessionRequest(data) { return client({ method: 'post', - url: urls['kolibri:core:trackprogress-list'](), + url: urls['kolibri:core:trackprogress_list'](), data: data, }).then(response => { const data = response.data; @@ -278,7 +278,7 @@ export default function useProgressTracking(store) { const wasComplete = get(complete); return client({ method: 'put', - url: urls['kolibri:core:trackprogress-detail'](get(session_id)), + url: urls['kolibri:core:trackprogress_detail'](get(session_id)), data, }).then(response => { if (response.data.attempts) { diff --git a/kolibri/plugins/learn/assets/src/views/TopicsContentPage.vue b/kolibri/plugins/learn/assets/src/views/TopicsContentPage.vue index 54efdcef921..82a28129e6f 100644 --- a/kolibri/plugins/learn/assets/src/views/TopicsContentPage.vue +++ b/kolibri/plugins/learn/assets/src/views/TopicsContentPage.vue @@ -529,7 +529,7 @@ } client({ method: 'get', - url: urls['kolibri:core:bookmarks-list'](), + url: urls['kolibri:core:bookmarks_list'](), params: { contentnode_id: this.content.id }, }).then(response => { // As the component never gets fully torn down @@ -652,7 +652,7 @@ const id = this.content.id; client({ method: 'post', - url: urls['kolibri:core:bookmarks-list'](), + url: urls['kolibri:core:bookmarks_list'](), data: { contentnode_id: id, user: this.currentUserId, diff --git a/packages/kolibri-common/apiResources/NetworkLocationResource.js b/packages/kolibri-common/apiResources/NetworkLocationResource.js index ef6a914408e..93ab612bfbf 100644 --- a/packages/kolibri-common/apiResources/NetworkLocationResource.js +++ b/packages/kolibri-common/apiResources/NetworkLocationResource.js @@ -37,7 +37,7 @@ function updateConnectionStatus(id) { */ function fetchFacilities(id) { return this.client({ - url: urls['kolibri:core:networklocation_facilities-detail'](id), + url: urls['kolibri:core:networklocation_facilities_detail'](id), }).then(response => { return response.data; }); diff --git a/packages/kolibri-common/apiResources/PortalResource.js b/packages/kolibri-common/apiResources/PortalResource.js index 5b702a28d52..3c608715c23 100644 --- a/packages/kolibri-common/apiResources/PortalResource.js +++ b/packages/kolibri-common/apiResources/PortalResource.js @@ -4,7 +4,7 @@ import urls from 'kolibri/urls'; export default new Resource({ name: 'portal', validateToken(token) { - const url = urls['kolibri:core:portal-validate-token'](); + const url = urls['kolibri:core:portal_validate_token'](); return this.client({ url, method: 'get', @@ -12,7 +12,7 @@ export default new Resource({ }); }, registerFacility({ facility_id, token }) { - const url = urls['kolibri:core:portal-register'](); + const url = urls['kolibri:core:portal_register'](); return this.client({ url, method: 'post', diff --git a/packages/kolibri-common/mixins/commonSyncElements.js b/packages/kolibri-common/mixins/commonSyncElements.js index 6076e11d757..0568433607d 100644 --- a/packages/kolibri-common/mixins/commonSyncElements.js +++ b/packages/kolibri-common/mixins/commonSyncElements.js @@ -90,7 +90,7 @@ export default { }, fetchNetworkLocationFacilities(locationId) { return client({ - url: urls['kolibri:core:networklocation_facilities-detail'](locationId), + url: urls['kolibri:core:networklocation_facilities_detail'](locationId), }) .then(response => { return response.data; diff --git a/packages/kolibri/__tests__/urls.spec.js b/packages/kolibri/__tests__/urls.spec.js new file mode 100644 index 00000000000..2bd21fa0033 --- /dev/null +++ b/packages/kolibri/__tests__/urls.spec.js @@ -0,0 +1,188 @@ +import plugin_data from 'kolibri-plugin-data'; +import { createUrlResolver } from '../urls'; + +// Mock plugin data import +jest.mock('kolibri-plugin-data'); + +describe('UrlResolver', () => { + let Urls; + + beforeEach(() => { + // Setup mock plugin data + plugin_data.urls = { + prefix: '/test/', + __staticUrl: '/static/', + __mediaUrl: '/media/', + __contentUrl: '/content/', + __zipContentUrl: '/zipcontent/', + __hashiUrl: '/hashi/', + __zipContentOrigin: 'http://localhost', + __zipContentPort: '8000', + urls: { + user_profile_detail: [['api/users/%(pk)s/', ['pk']]], + membership_detail: [ + ['api/auth/membership/%(pk)s.%(format)s', ['pk', 'format']], + ['api/auth/membership/%(pk)s/', ['pk']], + ], + simple_list: [['api/simple/', []]], + download_file: [['api/download/%(type)s/%(id)s/', ['type', 'id']]], + }, + }; + Urls = createUrlResolver(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('URL pattern handling', () => { + test('handles simple patterns with no parameters', () => { + expect(Urls.simple_list()).toBe('/test/api/simple/'); + }); + + test('handles patterns with single variations', () => { + expect(Urls.user_profile_detail(123)).toBe('/test/api/users/123/'); + }); + + test('handles patterns with multiple variations', () => { + // Full format + expect(Urls['membership_detail']({ pk: '123', format: 'json' })).toBe( + '/test/api/auth/membership/123.json', + ); + + // Short format + expect(Urls['membership_detail']({ pk: '123' })).toBe('/test/api/auth/membership/123/'); + }); + }); + + describe('Parameter handling', () => { + test('handles named parameters', () => { + expect(Urls['download_file']({ type: 'document', id: '123' })).toBe( + '/test/api/download/document/123/', + ); + }); + + test('handles positional parameters', () => { + expect(Urls['download_file']('document', '123')).toBe('/test/api/download/document/123/'); + }); + + test('handles URL encoding in parameters', () => { + expect(Urls['download_file']('file type', 'test/id')).toBe( + '/test/api/download/file%20type/test%2Fid/', + ); + }); + + test('throws error for missing required parameters', () => { + expect(() => Urls['download_file']({ type: 'document' })).toThrow( + 'Could not find matching URL pattern', + ); + }); + + test('throws error for wrong number of positional parameters', () => { + expect(() => Urls['download_file']('single')).toThrow('Could not find matching URL pattern'); + }); + }); + + describe('Special URL methods', () => { + const originalLocation = window.location; + + beforeEach(() => { + delete window.location; + window.location = new URL('http://kolibri.time'); + }); + + afterEach(() => { + window.location = originalLocation; + }); + + test('generates static URLs', () => { + expect(Urls.static('js/bundle.js')).toBe('http://kolibri.time/static/js/bundle.js'); + }); + + test('generates media URLs', () => { + expect(Urls.media('images/logo.png')).toBe('http://kolibri.time/media/images/logo.png'); + }); + + test('generates hashi URLs', () => { + expect(Urls.hashi()).toBe('http://localhost:8000/hashi/'); + }); + + test('generates zip content URLs', () => { + expect(Urls.zipContentUrl('abc123', 'mp4')).toBe( + 'http://localhost:8000/zipcontent/abc123.mp4/', + ); + + expect(Urls.zipContentUrl('abc123', 'mp4', 'embedded/file.mp4')).toBe( + 'http://localhost:8000/zipcontent/abc123.mp4/embedded/file.mp4', + ); + + expect(Urls.zipContentUrl('abc123', 'mp4', '', 'baseurl')).toBe( + 'http://localhost:8000/zipcontent/baseurl/abc123.mp4/', + ); + }); + + test('generates storage URLs', () => { + expect(Urls.storageUrl('abc123', 'mp4')).toBe('http://kolibri.time/content/a/b/abc123.mp4'); + }); + + test('throws error when special URLs are not defined', () => { + plugin_data.urls.__staticUrl = undefined; + jest.resetModules(); + const UrlsNoStatic = createUrlResolver(); + + expect(() => UrlsNoStatic.static('test.js')).toThrow('Static Url is not defined'); + }); + }); + + describe('Error handling', () => { + test('throws error for non-existent patterns', () => { + expect(() => Urls.nonExistentPattern()).toThrow('URL pattern "nonExistentPattern" not found'); + }); + + test('handles initialization with no plugin data', () => { + jest.resetModules(); + plugin_data.urls = undefined; + const UrlsNoData = createUrlResolver(); + + expect(() => UrlsNoData['user_profile_detail'](123)).toThrow( + 'URL pattern "user_profile_detail" not found', + ); + }); + test('throws error if URL pattern contains a dash', () => { + jest.resetModules(); + plugin_data.urls = { + urls: { + 'user-profile-detail': [['api/users/%(pk)s/', ['pk']]], + membership_detail: [ + ['api/auth/membership/%(pk)s.%(format)s', ['pk', 'format']], + ['api/auth/membership/%(pk)s/', ['pk']], + ], + 'simple-list': [['api/simple/', []]], + download_file: [['api/download/%(type)s/%(id)s/', ['type', 'id']]], + }, + }; + expect(createUrlResolver).toThrow( + 'URL pattern names should use underscores instead of dashes. Found "user-profile-detail"', + ); + }); + }); + + describe('Proxy and fallback behavior', () => { + test('returns same function for repeated calls', () => { + const func1 = Urls._getUrlFunction('user_profile_detail'); + const func2 = Urls._getUrlFunction('user_profile_detail'); + expect(func1).toBe(func2); + }); + + test('proxy fallback works when Proxy is not available', () => { + const originalProxy = global.Proxy; + global.Proxy = undefined; + + jest.resetModules(); + const NoProxyUrls = createUrlResolver(); + expect(NoProxyUrls['user_profile_detail'](123)).toBe('/test/api/users/123/'); + + global.Proxy = originalProxy; + }); + }); +}); diff --git a/packages/kolibri/components/__tests__/auth-message.spec.js b/packages/kolibri/components/__tests__/auth-message.spec.js index 3f91f11817b..2256abff5df 100644 --- a/packages/kolibri/components/__tests__/auth-message.spec.js +++ b/packages/kolibri/components/__tests__/auth-message.spec.js @@ -6,7 +6,7 @@ import { stubWindowLocation } from 'testUtils'; // eslint-disable-line import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line import AuthMessage from '../AuthMessage'; -jest.mock('urls', () => ({})); +jest.mock('kolibri/urls', () => ({})); jest.mock('kolibri/composables/useUser'); const localVue = createLocalVue(); diff --git a/packages/kolibri/heartbeat.js b/packages/kolibri/heartbeat.js index c5f31afc915..5806a32a3fe 100644 --- a/packages/kolibri/heartbeat.js +++ b/packages/kolibri/heartbeat.js @@ -273,7 +273,7 @@ export class HeartBeat { redirectBrowser(null, true); } _sessionUrl(id) { - return urls['kolibri:core:session-detail'](id); + return urls['kolibri:core:session_detail'](id); } /* * Method to reset activity listeners clear timeouts waiting to diff --git a/packages/kolibri/urls.js b/packages/kolibri/urls.js index e78d5a79978..e471e32f1e8 100644 --- a/packages/kolibri/urls.js +++ b/packages/kolibri/urls.js @@ -16,15 +16,154 @@ function generateUrl(baseUrl, { url, origin, port } = {}) { return urlObject.href; } -const urls = { - setUp() { - // Set urls onto this object for export - // This will add functions for every reversible URL - // and strings for __staticURL, __mediaURL, and __contentURL - // this behaviour is defined in kolibri/core/kolibri_plugin.py - // in the url_tag method of the FrontEndCoreAppAssetHook. - Object.assign(this, plugin_data.urls); - }, +class UrlResolver { + constructor() { + this._urlCache = new Map(); + this._functionCache = new Map(); + + this._validatePatternNames(); + + // For browsers without Proxy support, create all functions upfront + // Also for developer's convenience, create all functions upfront in development + // so that they can be accessed directly from the object and inspected. + if (typeof Proxy === 'undefined' || process.env.NODE_ENV !== 'production') { + this._createFallbackInterface(); + } + } + + get _patterns() { + return plugin_data?.urls?.urls || {}; + } + + get _prefix() { + return plugin_data?.urls?.prefix || '/'; + } + + _validatePatternNames() { + for (const patternName of Object.keys(this._patterns)) { + if (patternName.includes('-')) { + throw new Error( + `URL pattern names should use underscores instead of dashes. Found "${patternName}"`, + ); + } + } + } + + _createFallbackInterface() { + // Pre-generate all URL functions + for (const patternName of Object.keys(this._patterns)) { + this[patternName] = this._getUrlFunction(patternName); + } + } + + _getUrlFunction(name) { + // Check function cache first + let urlFunc = this._functionCache.get(name); + if (urlFunc) { + return urlFunc; + } + + const patterns = this._patterns[name]; + if (!patterns) { + urlFunc = () => { + if (process.env.NODE_ENV !== 'production') { + if (name.includes('-')) { + throw new Error( + `URL pattern names should use underscores instead of dashes. Try "${name.replace('-', '_')}"`, + ); + } + } + throw new Error(`URL pattern "${name}" not found`); + }; + } else { + urlFunc = this._createUrlFunction(name, patterns); + } + + // Cache the function + this._functionCache.set(name, urlFunc); + + return urlFunc; + } + + _createUrlFunction(name, patterns) { + return (...args) => { + let url; + + // Handle both named and positional arguments + if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { + // Named parameters + const kwargs = args[0]; + + // Try each pattern variation until we find one we can use + for (const [pattern, paramNames] of patterns) { + // Check if we have all required parameters + const hasAllParams = !paramNames.some(param => !(param in kwargs)); + if (hasAllParams) { + url = pattern; + // Replace all named parameters + for (const param of paramNames) { + const value = kwargs[param]; + url = url.replace(`%(${param})s`, encodeURIComponent(value)); + } + break; + } + } + } else { + // Positional parameters + // Use the first pattern that matches the number of arguments + for (const [pattern, paramNames] of patterns) { + if (paramNames.length === args.length) { + url = pattern; + for (const [index, param] of paramNames.entries()) { + url = url.replace(`%(${param})s`, encodeURIComponent(args[index])); + } + break; + } + } + } + + if (!url) { + throw new Error( + `Could not find matching URL pattern for "${name}" with the provided arguments`, + ); + } + + return this._prefix + url; + }; + } + + _getPluginData(dataKey) { + return plugin_data?.urls?.[dataKey]; + } + + get __hashiUrl() { + return this._getPluginData('__hashiUrl'); + } + + get __staticUrl() { + return this._getPluginData('__staticUrl'); + } + + get __mediaUrl() { + return this._getPluginData('__mediaUrl'); + } + + get __zipContentUrl() { + return this._getPluginData('__zipContentUrl'); + } + + get __zipContentOrigin() { + return this._getPluginData('__zipContentOrigin'); + } + + get __zipContentPort() { + return this._getPluginData('__zipContentPort'); + } + + get __contentUrl() { + return this._getPluginData('__contentUrl'); + } + hashi() { if (!this.__hashiUrl) { throw new ReferenceError('Hashi Url is not defined'); @@ -33,19 +172,19 @@ const urls = { origin: this.__zipContentOrigin, port: this.__zipContentPort, }); - }, + } static(url) { if (!this.__staticUrl) { throw new ReferenceError('Static Url is not defined'); } return generateUrl(this.__staticUrl, { url }); - }, + } media(url) { if (!this.__mediaUrl) { throw new ReferenceError('Media Url is not defined'); } return generateUrl(this.__mediaUrl, { url }); - }, + } zipContentUrl(fileId, extension, embeddedFilePath = '', baseurl) { const filename = `${fileId}.${extension}`; if (!this.__zipContentUrl) { @@ -56,14 +195,32 @@ const urls = { origin: this.__zipContentOrigin, port: this.__zipContentPort, }); - }, + } storageUrl(fileId, extension) { const filename = `${fileId}.${extension}`; if (!this.__contentUrl) { throw new ReferenceError('Zipcontent Url is not defined'); } return generateUrl(this.__contentUrl, { url: `${filename[0]}/${filename[1]}/${filename}` }); - }, + } +} + +// Create the proxy-wrapped instance +export const createUrlResolver = () => { + const resolver = new UrlResolver(); + + if (typeof Proxy !== 'undefined') { + return new Proxy(resolver, { + get(target, prop) { + if (prop in target) { + return target[prop]; + } + return target._getUrlFunction(prop); + }, + }); + } + + return resolver; }; -export default urls; +export default createUrlResolver(); diff --git a/packages/kolibri/utils/appCapabilities.js b/packages/kolibri/utils/appCapabilities.js index a3a26461883..4757bccbdbb 100644 --- a/packages/kolibri/utils/appCapabilities.js +++ b/packages/kolibri/utils/appCapabilities.js @@ -32,7 +32,7 @@ export default { return Promise.resolve(null); } - const urlFunction = urls['kolibri:kolibri.plugins.app:appcommands-check-is-metered']; + const urlFunction = urls['kolibri:kolibri.plugins.app:appcommands_check_is_metered']; if (!urlFunction || !checkCapability('check_is_metered')) { logging.warn('Checking if the device is metered is not supported on this platform'); return Promise.resolve(null); @@ -49,7 +49,7 @@ export default { // It would be more elegant to use a proxy for this, but that would require // adding a polyfill for this specific usage, so this works just as well. return ({ filename, message }) => { - const urlFunction = urls['kolibri:kolibri.plugins.app:appcommands-share-file']; + const urlFunction = urls['kolibri:kolibri.plugins.app:appcommands_share_file']; if (!urlFunction) { logging.warn('Sharing a file is not supported on this platform'); return Promise.reject(); From 1e663376c1c701a80ae2307b9d617c5cbfb74623 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 8 Nov 2024 15:09:38 -0800 Subject: [PATCH 3/8] Update frontend message registration to no longer rely on inline JS. --- kolibri/core/webpack/hooks.py | 16 +++--- .../lib/webpack.config.plugin.js | 9 ++++ .../lib/webpackMessageRegistrationPlugin.js | 52 +++++++++++++++++++ .../internal/__tests__/pluginMediator.spec.js | 12 +++-- packages/kolibri/internal/pluginMediator.js | 34 ++++++++---- 5 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 packages/kolibri-tools/lib/webpackMessageRegistrationPlugin.js diff --git a/kolibri/core/webpack/hooks.py b/kolibri/core/webpack/hooks.py index 27f1a960ea9..ca7962e22c8 100644 --- a/kolibri/core/webpack/hooks.py +++ b/kolibri/core/webpack/hooks.py @@ -194,18 +194,14 @@ def frontend_message_tag(self): if self.frontend_messages(): return [ """ - """.format( - kolibri_name="kolibriCoreAppGlobal", + """.format( bundle=self.unique_id, - lang_code=get_language(), messages=json.dumps( - json.dumps( - self.frontend_messages(), - separators=(",", ":"), - ensure_ascii=False, - ) + self.frontend_messages(), + separators=(",", ":"), + ensure_ascii=False, ), ) ] diff --git a/packages/kolibri-tools/lib/webpack.config.plugin.js b/packages/kolibri-tools/lib/webpack.config.plugin.js index eedfca72f6a..e83de71d982 100644 --- a/packages/kolibri-tools/lib/webpack.config.plugin.js +++ b/packages/kolibri-tools/lib/webpack.config.plugin.js @@ -18,6 +18,7 @@ const { coreExternals } = require('./apiSpecExportTools'); const WebpackRTLPlugin = require('./webpackRtlPlugin'); const { kolibriName } = require('./kolibriName'); const WebpackMessages = require('./webpackMessages'); +const MessageRegistrationPlugin = require('./webpackMessageRegistrationPlugin'); /** * Turn an object containing the vital information for a frontend plugin and return a bundle @@ -160,6 +161,14 @@ module.exports = ( __version: JSON.stringify(data.version), __copyrightYear: new Date().getFullYear(), }), + // Inject code to register frontend messages + new MessageRegistrationPlugin({ + // For the core plugin, because it sets up the i18n + // machinery, we need to inject the registration code + // afterwards to avoid a kerfuffle. + injectAfterBundle: isCoreBundle, + moduleName: data.name, + }), // Add custom messages per bundle. new WebpackMessages({ name: data.name, diff --git a/packages/kolibri-tools/lib/webpackMessageRegistrationPlugin.js b/packages/kolibri-tools/lib/webpackMessageRegistrationPlugin.js new file mode 100644 index 00000000000..aa099a6e912 --- /dev/null +++ b/packages/kolibri-tools/lib/webpackMessageRegistrationPlugin.js @@ -0,0 +1,52 @@ +const { kolibriName } = require('kolibri-tools/lib/kolibriName'); +const { + sources: { ConcatSource }, +} = require('webpack'); + +class MessageRegistrationPlugin { + constructor({ injectAfterBundle = false, moduleName } = {}) { + this.injectAfterBundle = injectAfterBundle; + this.moduleName = moduleName; + } + + apply(compiler) { + compiler.hooks.compilation.tap('MessageRegistrationPlugin', compilation => { + compilation.hooks.processAssets.tap( + { + name: 'MessageRegistrationPlugin', + stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + () => { + // Get the entry points + const entrypoints = compilation.entrypoints; + + entrypoints.forEach(entrypoint => { + // Get the first JS file from the entrypoint + const entryFiles = entrypoint.getFiles(); + const mainFile = entryFiles.find(file => file.endsWith('.js')); + + if (mainFile && compilation.assets[mainFile]) { + const asset = compilation.assets[mainFile]; + + // Create the injection code using the DefinePlugin value + const injectionCode = ` + (function() { + window.${kolibriName}.registerLanguageAssets('${this.moduleName}'); + })();\n + `; + + // Create a new concatenated source + const newSource = new ConcatSource( + ...(this.injectAfterBundle ? [asset, injectionCode] : [injectionCode, asset]), + ); + // Update the asset with the new source + compilation.updateAsset(mainFile, newSource); + } + }); + }, + ); + }); + } +} + +module.exports = MessageRegistrationPlugin; diff --git a/packages/kolibri/internal/__tests__/pluginMediator.spec.js b/packages/kolibri/internal/__tests__/pluginMediator.spec.js index 0e5ad8f4ecf..34e4ea1870a 100644 --- a/packages/kolibri/internal/__tests__/pluginMediator.spec.js +++ b/packages/kolibri/internal/__tests__/pluginMediator.spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { currentLanguage } from '../../utils/i18n'; import mediatorFactory from '../pluginMediator'; if (!Object.prototype.hasOwnProperty.call(global, 'Intl')) { @@ -541,7 +542,6 @@ describe('Mediator', function () { }); describe('registerLanguageAssets method', function () { const moduleName = 'test'; - const language = 'test_lang'; const messageMap = { test: 'test message', }; @@ -549,17 +549,19 @@ describe('Mediator', function () { beforeEach(function () { Vue.registerMessages = jest.fn(); spy = Vue.registerMessages; + document.body.innerHTML = + ''; }); afterEach(function () { spy.mockRestore(); }); it('should call Vue.registerMessages once', function () { - mediator.registerLanguageAssets(moduleName, language, messageMap); + mediator.registerLanguageAssets(moduleName); expect(spy).toHaveBeenCalledTimes(1); }); - it('should call Vue.registerMessages with arguments language and messageMap', function () { - mediator.registerLanguageAssets(moduleName, language, messageMap); - expect(spy).toHaveBeenCalledWith(language, messageMap); + it('should call Vue.registerMessages with arguments currentLanguage and messageMap', function () { + mediator.registerLanguageAssets(moduleName); + expect(spy).toHaveBeenCalledWith(currentLanguage, messageMap); }); }); }); diff --git a/packages/kolibri/internal/pluginMediator.js b/packages/kolibri/internal/pluginMediator.js index eda1f28c206..c0a0d6f28bf 100644 --- a/packages/kolibri/internal/pluginMediator.js +++ b/packages/kolibri/internal/pluginMediator.js @@ -1,11 +1,14 @@ import Vue from 'vue'; +import logging from 'kolibri-logging'; import scriptLoader from 'kolibri-common/utils/scriptLoader'; import { RENDERER_SUFFIX } from 'kolibri/constants'; -import { languageDirection, languageDirections } from 'kolibri/utils/i18n'; +import { languageDirection, languageDirections, currentLanguage } from 'kolibri/utils/i18n'; import contentRendererMixin from '../components/internal/ContentRenderer/mixin'; import ContentRendererLoading from '../components/internal/ContentRenderer/ContentRendererLoading'; import ContentRendererError from '../components/internal/ContentRenderer/ContentRendererError'; +const logger = logging.getLogger(__filename); + /** * Array containing the names of all methods of the Mediator that * should be exposed publicly through the Facade. @@ -382,26 +385,37 @@ export default function pluginMediatorFactory(facade) { * @param {String} language language code whose messages we are registering. * @param {Object} messageMap an object with message id to message mappings. */ - registerLanguageAssets(moduleName, language, messageMap) { + registerLanguageAssets(moduleName) { + const messageElement = document.querySelector(`template[data-i18n="${moduleName}"]`); + if (!messageElement) { + return; + } + let messageMap; + try { + messageMap = JSON.parse(messageElement.innerHTML.trim()); + } catch (e) { + logger.error(`Error parsing language assets for ${moduleName}`); + } + if (!messageMap || typeof messageMap !== 'object') { + logger.error(`Error parsing language assets for ${moduleName}`); + return; + } if (!Vue.registerMessages) { // Set this messageMap so that we can register it later when VueIntl // has finished loading. // Create empty entry in the language asset registry for this module if needed - this._languageAssetRegistry[moduleName] = this._languageAssetRegistry[moduleName] || {}; - this._languageAssetRegistry[moduleName][language] = messageMap; + this._languageAssetRegistry[moduleName] = messageMap; } else { - Vue.registerMessages(language, messageMap); + Vue.registerMessages(currentLanguage, messageMap); } }, /** * A method for taking all registered language assets and registering them against Vue Intl. */ registerMessages() { - Object.keys(this._languageAssetRegistry).forEach(moduleName => { - Object.keys(this._languageAssetRegistry[moduleName]).forEach(language => { - Vue.registerMessages(language, this._languageAssetRegistry[moduleName][language]); - }); - }); + for (const moduleName in this._languageAssetRegistry) { + Vue.registerMessages(currentLanguage, this._languageAssetRegistry[moduleName]); + } delete this._languageAssetRegistry; }, /** From 304e4c80bbabd780390d3facf225a1eaa3e57ffd Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 8 Nov 2024 15:26:50 -0800 Subject: [PATCH 4/8] Get rid of the separate browser version checking bundle. Defer to the core bundle. --- kolibri/core/assets/src/index.js | 2 ++ .../assets/src/minimumBrowserRequirements.js | 5 +--- kolibri/core/buildConfig.js | 6 ----- kolibri/core/kolibri_plugin.py | 26 +++++-------------- kolibri/core/templates/kolibri/base.html | 1 - 5 files changed, 9 insertions(+), 31 deletions(-) diff --git a/kolibri/core/assets/src/index.js b/kolibri/core/assets/src/index.js index 45bed6a8b2e..3e057c951ae 100644 --- a/kolibri/core/assets/src/index.js +++ b/kolibri/core/assets/src/index.js @@ -2,6 +2,8 @@ * Provides the public API for the Kolibri FrontEnd core app. * @module Facade */ +// Import this first to ensure that we do a browser compatibility check before anything else +import './minimumBrowserRequirements'; import 'core-js'; import coreApp from 'kolibri'; import logging from 'kolibri-logging'; diff --git a/kolibri/core/assets/src/minimumBrowserRequirements.js b/kolibri/core/assets/src/minimumBrowserRequirements.js index d143d1aab7d..c14ef267d6f 100644 --- a/kolibri/core/assets/src/minimumBrowserRequirements.js +++ b/kolibri/core/assets/src/minimumBrowserRequirements.js @@ -1,10 +1,7 @@ import isUndefined from 'lodash/isUndefined'; import browsers from 'browserslist-config-kolibri'; import plugin_data from 'kolibri-plugin-data'; -// Do this to ensure that we sidestep the 'externals' configuration, and this code is -// directly bundled, and doesn't defer to the default bundle externals, which are -// loaded after this code is run. -import { browser, passesRequirements } from '../../../../packages/kolibri/utils/browserInfo'; +import { browser, passesRequirements } from 'kolibri/utils/browserInfo'; const minimumBrowserRequirements = {}; diff --git a/kolibri/core/buildConfig.js b/kolibri/core/buildConfig.js index 5ac0e6ba655..06e1510ad0b 100644 --- a/kolibri/core/buildConfig.js +++ b/kolibri/core/buildConfig.js @@ -13,10 +13,4 @@ module.exports = [ }, }, }, - { - bundle_id: 'frontend_head_assets', - webpack_config: { - entry: './assets/src/minimumBrowserRequirements.js', - }, - }, ]; diff --git a/kolibri/core/kolibri_plugin.py b/kolibri/core/kolibri_plugin.py index f126b80111f..5d2739df422 100644 --- a/kolibri/core/kolibri_plugin.py +++ b/kolibri/core/kolibri_plugin.py @@ -14,6 +14,7 @@ from kolibri.core.content.utils.paths import get_zip_content_base_path from kolibri.core.content.utils.paths import get_zip_content_config from kolibri.core.device.utils import allow_other_browsers_to_connect +from kolibri.core.hooks import FrontEndBaseHeadHook from kolibri.core.hooks import NavigationHook from kolibri.core.oidc_provider_hook import OIDCProviderHook from kolibri.core.theme_hook import ThemeHook @@ -96,6 +97,7 @@ def plugin_data(self): "oidcProviderEnabled": OIDCProviderHook.is_enabled(), "kolibriTheme": ThemeHook.get_theme(), "urls": url_data, + "unsupportedUrl": reverse("kolibri:core:unsupported"), } def language_globals(self): @@ -123,26 +125,14 @@ def language_globals(self): @register_hook -class FrontendHeadAssetsHook(WebpackBundleHook): +class FrontendHeadAssetsHook(FrontEndBaseHeadHook): """ Render these assets in the tag of base.html, before other JS and assets. """ - bundle_id = "frontend_head_assets" - - def render_to_page_load_sync_html(self): - """ - Add in the extra language font file tags needed - for preloading our custom font files. - """ - tags = ( - self.plugin_data_tag() - + self.language_font_file_tags() - + self.frontend_message_tag() - + list(self.js_and_css_tags()) - ) - - return mark_safe("\n".join(tags)) + @property + def head_html(self): + return mark_safe("\n".join(self.language_font_file_tags())) def language_font_file_tags(self): language_code = get_language() @@ -162,7 +152,3 @@ def language_font_file_tags(self): subset_css_file=subset_file, version=kolibri.__version__ ), ] - - @property - def plugin_data(self): - return {"unsupportedUrl": reverse("kolibri:core:unsupported")} diff --git a/kolibri/core/templates/kolibri/base.html b/kolibri/core/templates/kolibri/base.html index fdd5191b693..dad865648df 100644 --- a/kolibri/core/templates/kolibri/base.html +++ b/kolibri/core/templates/kolibri/base.html @@ -18,7 +18,6 @@ {% endif %} - {% webpack_asset 'kolibri.core.frontend_head_assets' %} {% frontend_base_head_markup %} From 79194b5dbc1197220a200013bf559cc6e6328f0f Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 8 Nov 2024 16:18:40 -0800 Subject: [PATCH 5/8] Update content renderer registration to rely on injected templates rather than inline JS. --- kolibri/core/content/hooks.py | 20 +++++++++++-------- kolibri/core/templates/kolibri/base.html | 2 +- packages/kolibri/internal/pluginMediator.js | 22 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/kolibri/core/content/hooks.py b/kolibri/core/content/hooks.py index bc31aa9139d..0c5e9a6e655 100644 --- a/kolibri/core/content/hooks.py +++ b/kolibri/core/content/hooks.py @@ -8,6 +8,7 @@ from abc import abstractmethod from abc import abstractproperty +from django.core.serializers.json import DjangoJSONEncoder from django.utils.safestring import mark_safe from kolibri.core.webpack.hooks import WebpackBundleHook @@ -32,14 +33,14 @@ def presets(self): def html(cls): tags = [] for hook in cls.registered_hooks: - tags.append(hook.render_to_page_load_async_html()) + tags.append(hook.template_html()) return mark_safe("\n".join(tags)) - def render_to_page_load_async_html(self): + def template_html(self): """ - Generates script tag containing Javascript to register a content renderer. + Generates template tags containing data to register a content renderer. - :returns: HTML of a script tag to insert into a page. + :returns: HTML of a template tags to insert into a page. """ # Note, while most plugins use sorted chunks to filter by text direction # content renderers do not, as they may need to have styling for a different @@ -49,11 +50,14 @@ def render_to_page_load_async_html(self): self.frontend_message_tag() + self.plugin_data_tag() + [ - ''.format( - kolibri_name="kolibriCoreAppGlobal", + ''.format( bundle=self.unique_id, - urls='","'.join(urls), - presets=json.dumps(self.presets), + data=json.dumps( + {"urls": urls, "presets": self.presets}, + separators=(",", ":"), + ensure_ascii=False, + cls=DjangoJSONEncoder, + ), ) ] ) diff --git a/kolibri/core/templates/kolibri/base.html b/kolibri/core/templates/kolibri/base.html index dad865648df..b491a5b5358 100644 --- a/kolibri/core/templates/kolibri/base.html +++ b/kolibri/core/templates/kolibri/base.html @@ -51,10 +51,10 @@ {% block frontend_assets %} +{% content_renderer_assets %} {% webpack_asset 'kolibri.core.default_frontend' %} {% frontend_base_assets %} {% frontend_base_async_assets %} -{% content_renderer_assets %} {% endblock %} {% block content %} diff --git a/packages/kolibri/internal/pluginMediator.js b/packages/kolibri/internal/pluginMediator.js index c0a0d6f28bf..1d498e69504 100644 --- a/packages/kolibri/internal/pluginMediator.js +++ b/packages/kolibri/internal/pluginMediator.js @@ -93,6 +93,7 @@ export default function pluginMediatorFactory(facade) { */ ready() { this.registerMessages(); + this.registerAllContentRenderers(); this.setReady(); }, @@ -452,6 +453,27 @@ export default function pluginMediatorFactory(facade) { }); }, + /** + * A method for reading all templates that contain metadata about content renderers + * and registering them. + */ + registerAllContentRenderers() { + const contentRendererElements = Array.from( + document.querySelectorAll('template[data-viewer]') || [], + ); + for (const element of contentRendererElements) { + const moduleName = element.getAttribute('data-viewer'); + try { + const data = JSON.parse(element.innerHTML.trim()); + const presets = data.presets; + const urls = data.urls; + this.registerContentRenderer(moduleName, urls, presets); + } catch (e) { + logger.error(`Error parsing content renderer for ${moduleName}`); + } + } + }, + /** * A method to retrieve a content renderer component. * @param {String} preset content preset From 45613ec8a259a278521a187afaeba225cad79765 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 8 Nov 2024 16:47:30 -0800 Subject: [PATCH 6/8] Remove unused async module registration and loading. --- kolibri/core/hooks.py | 9 -- kolibri/core/templates/kolibri/base.html | 1 - kolibri/core/templatetags/core_tags.py | 13 -- kolibri/core/webpack/hooks.py | 37 ----- .../core/webpack/templatetags/webpack_tags.py | 20 --- .../internal/__tests__/pluginMediator.spec.js | 131 +----------------- packages/kolibri/internal/pluginMediator.js | 126 +---------------- 7 files changed, 4 insertions(+), 333 deletions(-) diff --git a/kolibri/core/hooks.py b/kolibri/core/hooks.py index 215d8f8826f..6156ec986b6 100644 --- a/kolibri/core/hooks.py +++ b/kolibri/core/hooks.py @@ -15,7 +15,6 @@ from django.utils.safestring import mark_safe from kolibri.core.webpack.hooks import WebpackBundleHook -from kolibri.core.webpack.hooks import WebpackInclusionASyncMixin from kolibri.core.webpack.hooks import WebpackInclusionSyncMixin from kolibri.plugins.hooks import define_hook from kolibri.plugins.hooks import KolibriHook @@ -59,14 +58,6 @@ class FrontEndBaseSyncHook(WebpackInclusionSyncMixin): """ -@define_hook -class FrontEndBaseASyncHook(WebpackInclusionASyncMixin): - """ - Inherit a hook defining assets to be loaded in kolibri/base.html, that means - ALL pages. Use with care. - """ - - @define_hook class FrontEndBaseHeadHook(KolibriHook): """ diff --git a/kolibri/core/templates/kolibri/base.html b/kolibri/core/templates/kolibri/base.html index b491a5b5358..f7761933244 100644 --- a/kolibri/core/templates/kolibri/base.html +++ b/kolibri/core/templates/kolibri/base.html @@ -54,7 +54,6 @@ {% content_renderer_assets %} {% webpack_asset 'kolibri.core.default_frontend' %} {% frontend_base_assets %} -{% frontend_base_async_assets %} {% endblock %} {% block content %} diff --git a/kolibri/core/templatetags/core_tags.py b/kolibri/core/templatetags/core_tags.py index 3ba6f000203..c86105033f0 100644 --- a/kolibri/core/templatetags/core_tags.py +++ b/kolibri/core/templatetags/core_tags.py @@ -6,7 +6,6 @@ from django.templatetags.static import static from django.utils.html import format_html -from kolibri.core.hooks import FrontEndBaseASyncHook from kolibri.core.hooks import FrontEndBaseHeadHook from kolibri.core.hooks import FrontEndBaseSyncHook from kolibri.core.theme_hook import ThemeHook @@ -27,18 +26,6 @@ def frontend_base_assets(): return FrontEndBaseSyncHook.html() -@register.simple_tag() -def frontend_base_async_assets(): - """ - This is a script tag for all ``FrontEndAssetHook`` hooks that implement a - render_to_html() method - this is used in ``/base.html`` template to - populate any Javascript and CSS that should be loaded at page load. - - :return: HTML of script tags to insert into base.html - """ - return FrontEndBaseASyncHook.html() - - @register.simple_tag() def frontend_base_head_markup(): """ diff --git a/kolibri/core/webpack/hooks.py b/kolibri/core/webpack/hooks.py index ca7962e22c8..a16efb5da18 100644 --- a/kolibri/core/webpack/hooks.py +++ b/kolibri/core/webpack/hooks.py @@ -302,35 +302,6 @@ def render_to_page_load_sync_html(self): return mark_safe("\n".join(tags)) - def render_to_page_load_async_html(self): - """ - Generates script tag containing Javascript to register an - asynchronously loading Javascript FrontEnd plugin against the core - front-end Kolibri app. It passes in the events that would trigger - loading the plugin, both multi-time firing events (events) and one time - firing events (once). - - It also passes in information about the methods that the events should - be delegated to once the plugin has loaded. - - TODO: What do we do with the extension parameter here? - - :returns: HTML of a script tag to insert into a page. - """ - urls = [chunk["url"] for chunk in self.sorted_chunks()] - tags = ( - self.plugin_data_tag() - + self.frontend_message_tag() - + [ - ''.format( - kolibri_name="kolibriCoreAppGlobal", - bundle=self.unique_id, - urls='","'.join(urls), - ) - ] - ) - return mark_safe("\n".join(tags)) - class WebpackInclusionMixin(object): @abstractproperty @@ -355,11 +326,3 @@ def bundle_html(self): bundle = self.bundle_class() html = bundle.render_to_page_load_sync_html() return mark_safe(html) - - -class WebpackInclusionASyncMixin(hooks.KolibriHook, WebpackInclusionMixin): - @property - def bundle_html(self): - bundle = self.bundle_class() - html = bundle.render_to_page_load_async_html() - return mark_safe(html) diff --git a/kolibri/core/webpack/templatetags/webpack_tags.py b/kolibri/core/webpack/templatetags/webpack_tags.py index b3bedcfb2a5..8c90b9e5376 100644 --- a/kolibri/core/webpack/templatetags/webpack_tags.py +++ b/kolibri/core/webpack/templatetags/webpack_tags.py @@ -36,23 +36,3 @@ def webpack_asset(unique_id): """ hook = hooks.WebpackBundleHook.get_by_unique_id(unique_id) return hook.render_to_page_load_sync_html() - - -@register.simple_tag() -def webpack_async_asset(unique_id): - """ - This template tag returns inline Javascript (wrapped in a script tag) that - registers the events that a KolibriModule listens to, and a list of JS and - CSS assets that need to be loaded to instantiate the KolibriModule Django - template. KolibriModules loaded in this way will not be executed, - initialized or registered until one of the defined events is triggered. - - You need to define the asset by means of inheriting from WebpackBundleHook. - - :param unique_id: The unique_id defined as the module_path of the plugin - concatenated with the bundle_id of the WebpackBundleHook - - :return: Inline Javascript as HTML for insertion into the DOM. - """ - hook = hooks.WebpackBundleHook.get_by_unique_id(unique_id) - return hook.render_to_page_load_async_html() diff --git a/packages/kolibri/internal/__tests__/pluginMediator.spec.js b/packages/kolibri/internal/__tests__/pluginMediator.spec.js index 34e4ea1870a..9369ff04fd0 100644 --- a/packages/kolibri/internal/__tests__/pluginMediator.spec.js +++ b/packages/kolibri/internal/__tests__/pluginMediator.spec.js @@ -9,7 +9,6 @@ if (!Object.prototype.hasOwnProperty.call(global, 'Intl')) { describe('Mediator', function () { let mediator, kolibriModule, facade; - const kolibriModuleName = 'test'; beforeEach(function () { facade = {}; mediator = mediatorFactory({ Vue, facade }); @@ -23,21 +22,11 @@ describe('Mediator', function () { expect(mediator._kolibriModuleRegistry).toEqual({}); }); }); - describe('callback buffer', function () { - it('should be empty', function () { - expect(mediator._callbackBuffer).toEqual({}); - }); - }); describe('callback registry', function () { it('should be empty', function () { expect(mediator._callbackRegistry).toEqual({}); }); }); - describe('async callback registry', function () { - it('should be empty', function () { - expect(mediator._asyncCallbackRegistry).toEqual({}); - }); - }); describe('event dispatcher', function () { it('should be a Vue object', function () { expect(mediator._eventDispatcher.$on).toBeInstanceOf(Function); @@ -52,19 +41,17 @@ describe('Mediator', function () { }); }); describe('registerKolibriModuleSync method', function () { - let _registerMultipleEvents, _registerOneTimeEvents, emit, _executeCallbackBuffer; + let _registerMultipleEvents, _registerOneTimeEvents, emit; beforeEach(function () { _registerMultipleEvents = jest.spyOn(mediator, '_registerMultipleEvents'); _registerOneTimeEvents = jest.spyOn(mediator, '_registerOneTimeEvents'); emit = jest.spyOn(mediator, 'emit'); - _executeCallbackBuffer = jest.spyOn(mediator, '_executeCallbackBuffer'); }); afterEach(function () { mediator._kolibriModuleRegistry = {}; _registerMultipleEvents.mockRestore(); _registerOneTimeEvents.mockRestore(); emit.mockRestore(); - _executeCallbackBuffer.mockRestore(); }); describe('called with valid input', function () { let consoleMock; @@ -96,12 +83,6 @@ describe('Mediator', function () { it('should pass the kolibriModule to the emit method', function () { expect(emit).toHaveBeenCalledWith('kolibri_register', kolibriModule); }); - it('should call the _executeCallbackBuffer method', function () { - expect(_executeCallbackBuffer).toHaveBeenCalled(); - }); - it('should call pass the kolibriModule to the _executeCallbackBuffer method', function () { - expect(_executeCallbackBuffer).toHaveBeenCalledWith(kolibriModule); - }); it('should put the kolibriModule into the kolibriModule registry', function () { expect(mediator._kolibriModuleRegistry[kolibriModule.name]).toEqual(kolibriModule); }); @@ -135,9 +116,6 @@ describe('Mediator', function () { it('should not call the trigger method', function () { expect(emit).not.toHaveBeenCalled(); }); - it('should not call the _executeCallbackBuffer method', function () { - expect(_executeCallbackBuffer).not.toHaveBeenCalled(); - }); it('should leave the kolibriModule registry empty', function () { expect(mediator._kolibriModuleRegistry).toEqual({}); }); @@ -413,113 +391,6 @@ describe('Mediator', function () { }); }); }); - describe('_executeCallbackBuffer method', function () { - let spy, args; - beforeEach(function () { - spy = jest.fn(); - kolibriModule = { - name: 'test', - method: spy, - }; - args = ['this', 'that']; - mediator._callbackBuffer.test = [ - { - method: 'method', - args: args, - }, - ]; - mediator._executeCallbackBuffer(kolibriModule); - }); - it('should call the callback ', function () { - expect(spy).toHaveBeenCalled(); - }); - it('should pass the args to the callback ', function () { - expect(spy).toHaveBeenLastCalledWith(...args); - }); - it('should remove the entry from callback registry', function () { - expect(typeof mediator._callbackBuffer.test === 'undefined').toEqual(true); - }); - }); - describe('registerKolibriModuleAsync method', function () { - let stub; - beforeEach(function () { - const kolibriModuleUrls = ['test.js', 'test1.js']; - const events = { - event: 'method', - }; - const once = { - once: 'once_method', - }; - stub = jest.spyOn(mediator._eventDispatcher, '$on'); - mediator.registerKolibriModuleAsync(kolibriModuleName, kolibriModuleUrls, events, once); - }); - afterEach(function () { - stub.mockRestore(); - }); - it('should add create a callback buffer for the kolibriModule', function () { - expect(typeof mediator._callbackBuffer[kolibriModuleName] !== 'undefined').toEqual(true); - }); - it('should put two entries in the async callback registry', function () { - expect(mediator._asyncCallbackRegistry[kolibriModuleName].length).toEqual(2); - }); - it('should put a callback in each entry in the async callback registry', function () { - const registry = mediator._asyncCallbackRegistry; - expect(registry[kolibriModuleName][0].callback).toBeInstanceOf(Function); - expect(registry[kolibriModuleName][1].callback).toBeInstanceOf(Function); - }); - it('should call $on twice', function () { - expect(stub).toHaveBeenCalledTimes(2); - }); - it('should pass both events to $on', function () { - expect(stub).toHaveBeenCalledWith('event', expect.any(Function)); - expect(stub).toHaveBeenCalledWith('once', expect.any(Function)); - }); - describe('async callbacks', function () { - let args; - beforeEach(function () { - args = ['this', 'that']; - mediator._asyncCallbackRegistry[kolibriModuleName][0].callback(...args); - }); - it('should add an entry to the callback buffer when called', function () { - expect(mediator._callbackBuffer[kolibriModuleName].length).toEqual(1); - }); - it('should add args in the callback buffer when called', function () { - expect(mediator._callbackBuffer[kolibriModuleName][0].args).toEqual(args); - }); - }); - }); - describe('_clearAsyncCallbacks method', function () { - let event, stub, callback; - beforeEach(function () { - kolibriModule = { - name: 'test', - }; - event = 'event'; - callback = function () {}; - mediator._asyncCallbackRegistry[kolibriModule.name] = [ - { - event: event, - callback: callback, - }, - ]; - stub = jest.spyOn(mediator._eventDispatcher, '$off'); - mediator._clearAsyncCallbacks(kolibriModule); - }); - afterEach(function () { - stub.mockRestore(); - }); - it('should clear the callbacks', function () { - expect(typeof mediator._asyncCallbackRegistry[kolibriModule.name] === 'undefined').toEqual( - true, - ); - }); - it('should call $off once', function () { - expect(stub).toHaveBeenCalledTimes(1); - }); - it('should call $off with two args', function () { - expect(stub).toHaveBeenCalledWith(event, callback); - }); - }); describe('emit method', function () { let stub; beforeEach(function () { diff --git a/packages/kolibri/internal/pluginMediator.js b/packages/kolibri/internal/pluginMediator.js index 1d498e69504..a5dd4b6dd96 100644 --- a/packages/kolibri/internal/pluginMediator.js +++ b/packages/kolibri/internal/pluginMediator.js @@ -15,7 +15,6 @@ const logger = logging.getLogger(__filename); * @type {string[]} */ const publicMethods = [ - 'registerKolibriModuleAsync', 'registerKolibriModuleSync', 'stopListening', 'emit', @@ -48,12 +47,6 @@ export default function pluginMediatorFactory(facade) { **/ _kolibriModuleRegistry: {}, - /** - * Keep track of all callbacks that have been fired for as yet unloaded modules. - * kolibriModuleName: {Function[]} of callbacks - **/ - _callbackBuffer: {}, - /** * Keep track of all registered callbacks bound to events - this allows for easier * stopListening later. @@ -61,13 +54,6 @@ export default function pluginMediatorFactory(facade) { **/ _callbackRegistry: {}, - /** - * Keep track of all registered async callbacks bound to events - this allows for - * easier stopListening later. - * kolibriModuleName: {object[]} - with keys 'event' and 'callback'. - **/ - _asyncCallbackRegistry: {}, - // we use a Vue object solely for its event functionality _eventDispatcher: new Vue(), @@ -121,13 +107,7 @@ export default function pluginMediatorFactory(facade) { // Create an entry in the kolibriModule registry. this._kolibriModuleRegistry[kolibriModule.name] = kolibriModule; - // Clear any previously bound asynchronous callbacks for this kolibriModule. - this._clearAsyncCallbacks(kolibriModule); - - // Execute any callbacks that were called before the kolibriModule had loaded, - // in the order that they happened. - this._executeCallbackBuffer(kolibriModule); - console.info(`Kolibri Modules: ${kolibriModule.name} registered`); // eslint-disable-line no-console + logger.info(`Kolibri Modules: ${kolibriModule.name} registered`); this.emit('kolibri_register', kolibriModule); if (this._ready) { kolibriModule.ready(); @@ -250,106 +230,6 @@ export default function pluginMediatorFactory(facade) { } }, - /** - * Finds all callbacks that were called before the kolibriModule was loaded - * and registered synchronously and - * executes them in order of creation. - * @param {KolibriModule} kolibriModule - object of KolibriModule class - * @private - */ - _executeCallbackBuffer(kolibriModule) { - if (typeof this._callbackBuffer[kolibriModule.name] !== 'undefined') { - this._callbackBuffer[kolibriModule.name].forEach(buffer => { - // Do this to ensure proper 'this'ness. - kolibriModule[buffer.method](...buffer.args); - }); - delete this._callbackBuffer[kolibriModule.name]; - } - }, - - /** - * Registers a kolibriModule before it has been loaded into the page. Buffers - * any events that are fired, causing the - * arguments to be saved in the callback buffer array for this kolibriModule. - * @param {string} kolibriModuleName - the name of the kolibriModule - * @param {string[]} kolibriModuleUrls - the URLs of the Javascript and CSS - * files that constitute the kolibriModule - * @param {object} events - key, value pairs of event names and methods for - * repeating callbacks. - * @param {object} once - key value pairs of event names and methods for one - * time callbacks. - */ - registerKolibriModuleAsync(kolibriModuleName, kolibriModuleUrls, events, once) { - const self = this; - // Create a buffer for events that are fired before a kolibriModule has - // loaded. Keep track of the method and the arguments passed to the callback. - this._callbackBuffer[kolibriModuleName] = []; - const callbackBuffer = this._callbackBuffer[kolibriModuleName]; - // Look at all events, whether listened to once or multiple times. - const eventArray = []; - for (let i = 0; i < Object.getOwnPropertyNames(events).length; i += 1) { - const key = Object.getOwnPropertyNames(events)[i]; - eventArray.push([key, events[key]]); - } - for (let i = 0; i < Object.getOwnPropertyNames(once).length; i += 1) { - const key = Object.getOwnPropertyNames(once)[i]; - eventArray.push([key, once[key]]); - } - if (typeof this._asyncCallbackRegistry[kolibriModuleName] === 'undefined') { - this._asyncCallbackRegistry[kolibriModuleName] = []; - } - eventArray.forEach(tuple => { - const key = tuple[0]; - const value = tuple[1]; - // Create a callback function that will push objects to the callback buffer, - // and also cause loading of the the frontend assets that the kolibriModule - // needs, should an event it is listening for be emitted. - const callback = (...args) => { - const promise = new Promise((resolve, reject) => { - // First check that the kolibriModule hasn't already been loaded. - if (typeof self._kolibriModuleRegistry[kolibriModuleName] === 'undefined') { - // Add the details about the event callback to the buffer. - callbackBuffer.push({ - args, - method: value, - }); - // Load all the kolibriModule files. - Promise.all(kolibriModuleUrls.map(scriptLoader)) - .then(() => { - resolve(); - }) - .catch(() => { - const errorText = `Kolibri Modules: ${kolibriModuleName} failed to load`; - console.error(errorText); // eslint-disable-line no-console - reject(errorText); - }); - } - }); - return promise; - }; - // Listen to the event and call the above function - self._eventDispatcher.$on(key, callback); - // Keep track of all these functions for easy cleanup after - // the kolibriModule has been loaded. - self._asyncCallbackRegistry[kolibriModuleName].push({ - event: key, - callback, - }); - }); - }, - - /** - * Function to unbind and remove all callbacks created by the registerKolibriModuleAsync method. - * @param {KolibriModule} kolibriModule - object of KolibriModule class - * @private - */ - _clearAsyncCallbacks(kolibriModule) { - (this._asyncCallbackRegistry[kolibriModule.name] || []).forEach(async => { - this._eventDispatcher.$off(async.event, async.callback); - }); - delete this._asyncCallbackRegistry[kolibriModule.name]; - }, - /** * Proxy to the Vue object that is the global dispatcher. * Takes any arguments and passes them on. @@ -431,7 +311,7 @@ export default function pluginMediatorFactory(facade) { this._contentRendererUrls[kolibriModuleName] = kolibriModuleUrls; contentPresets.forEach(preset => { if (this._contentRendererRegistry[preset]) { - console.warn(`Kolibri Modules: Two content renderers are registering for ${preset}`); // eslint-disable-line no-console + logger.warn(`Kolibri Modules: Two content renderers are registering for ${preset}`); } else { this._contentRendererRegistry[preset] = kolibriModuleName; Vue.component(preset + RENDERER_SUFFIX, () => ({ @@ -537,7 +417,7 @@ export default function pluginMediatorFactory(facade) { } }) .catch(error => { - console.error('Kolibri Modules: ' + error); // eslint-disable-line no-console + logger.error('Kolibri Modules: ' + error); reject('Content renderer failed to load properly'); }); } From f7c41237aec97e20a7f92c3188e65a87fe7b0708 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Thu, 14 Nov 2024 16:16:10 -0800 Subject: [PATCH 7/8] Turn in context translation into a plugin. Make it unsafe-inline compliant. --- kolibri/__init__.py | 1 + kolibri/core/templates/kolibri/base.html | 7 ----- .../default/settings/translation.py | 19 ------------ .../plugins/context_translation/__init__.py | 0 .../context_translation/kolibri_plugin.py | 31 +++++++++++++++++++ .../context_translation/option_defaults.py | 5 +++ .../plugins/context_translation/settings.py | 12 +++++++ .../static/assets/context_translation/jipt.js | 2 ++ 8 files changed, 51 insertions(+), 26 deletions(-) delete mode 100644 kolibri/deployment/default/settings/translation.py create mode 100644 kolibri/plugins/context_translation/__init__.py create mode 100644 kolibri/plugins/context_translation/kolibri_plugin.py create mode 100644 kolibri/plugins/context_translation/option_defaults.py create mode 100644 kolibri/plugins/context_translation/settings.py create mode 100644 kolibri/plugins/context_translation/static/assets/context_translation/jipt.js diff --git a/kolibri/__init__.py b/kolibri/__init__.py index 79fe760a72a..bb3be4facce 100755 --- a/kolibri/__init__.py +++ b/kolibri/__init__.py @@ -23,6 +23,7 @@ INTERNAL_PLUGINS = [ "kolibri.plugins.app", "kolibri.plugins.coach", + "kolibri.plugins.context_translation", "kolibri.plugins.default_theme", "kolibri.plugins.demo_server", "kolibri.plugins.device", diff --git a/kolibri/core/templates/kolibri/base.html b/kolibri/core/templates/kolibri/base.html index f7761933244..efc2b6f5134 100644 --- a/kolibri/core/templates/kolibri/base.html +++ b/kolibri/core/templates/kolibri/base.html @@ -11,13 +11,6 @@ {% theme_favicon %} {% site_title %} - {% if LANGUAGE_CODE == "ach-ug" %} - - - {% endif %} {% frontend_base_head_markup %} diff --git a/kolibri/deployment/default/settings/translation.py b/kolibri/deployment/default/settings/translation.py deleted file mode 100644 index 0b46bc4abe0..00000000000 --- a/kolibri/deployment/default/settings/translation.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -import django.conf.locale - -from .base import * # noqa isort:skip @UnusedWildImport - -LANGUAGES = [("ach-ug", "In-context translation")] # noqa - -EXTRA_LANG_INFO = { - "ach-ug": { - "bidi": False, - "code": "ach-ug", - "name": "In-context translation", - "name_local": "Language Name", - } -} - -LANGUAGE_CODE = "ach-ug" - -django.conf.locale.LANG_INFO.update(EXTRA_LANG_INFO) diff --git a/kolibri/plugins/context_translation/__init__.py b/kolibri/plugins/context_translation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/kolibri/plugins/context_translation/kolibri_plugin.py b/kolibri/plugins/context_translation/kolibri_plugin.py new file mode 100644 index 00000000000..1ed829d9235 --- /dev/null +++ b/kolibri/plugins/context_translation/kolibri_plugin.py @@ -0,0 +1,31 @@ +from django.templatetags.static import static +from django.utils.safestring import mark_safe + +from kolibri.core.hooks import FrontEndBaseHeadHook +from kolibri.plugins import KolibriPluginBase +from kolibri.plugins.hooks import register_hook + + +class ContextTranslationPlugin(KolibriPluginBase): + """ + A plugin to enable support for translating the user interface of Kolibri + using Crowdin's in-context translation feature. + """ + + kolibri_option_defaults = "option_defaults" + django_settings = "settings" + + +@register_hook +class JIPTHeadHook(FrontEndBaseHeadHook): + @property + def head_html(self): + js_url = static("assets/context_translation/jipt.js") + return mark_safe( + "\n".join( + [ + f"""""", + """""", + ] + ) + ) diff --git a/kolibri/plugins/context_translation/option_defaults.py b/kolibri/plugins/context_translation/option_defaults.py new file mode 100644 index 00000000000..4d0a3eeb6be --- /dev/null +++ b/kolibri/plugins/context_translation/option_defaults.py @@ -0,0 +1,5 @@ +option_defaults = { + "Deployment": { + "LANGUAGES": "ach-ug", + } +} diff --git a/kolibri/plugins/context_translation/settings.py b/kolibri/plugins/context_translation/settings.py new file mode 100644 index 00000000000..d4e3660e5e6 --- /dev/null +++ b/kolibri/plugins/context_translation/settings.py @@ -0,0 +1,12 @@ +from django.conf import locale + +ACH_LANG_INFO = { + "ach-ug": { + "bidi": False, + "code": "ach-ug", + "name": "In-context translation", + "name_local": "Language Name", + } +} + +locale.LANG_INFO.update(ACH_LANG_INFO) diff --git a/kolibri/plugins/context_translation/static/assets/context_translation/jipt.js b/kolibri/plugins/context_translation/static/assets/context_translation/jipt.js new file mode 100644 index 00000000000..3145d6df8d1 --- /dev/null +++ b/kolibri/plugins/context_translation/static/assets/context_translation/jipt.js @@ -0,0 +1,2 @@ +var _jipt = []; +_jipt.push(['project', 'kolibri']); From a41a079563f0a396d8df52d6f0cb4f6c30146b46 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 15 Nov 2024 13:38:28 -0800 Subject: [PATCH 8/8] Add django_csp. Enforce CSPs, make additional hosts configurable. --- kolibri/deployment/default/settings/base.py | 29 +++++++++++++++ kolibri/deployment/default/settings/dev.py | 6 ++++ .../context_translation/kolibri_plugin.py | 2 +- .../context_translation/option_defaults.py | 1 + kolibri/utils/options.py | 36 +++++++++++++++++++ requirements/base.txt | 1 + 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/kolibri/deployment/default/settings/base.py b/kolibri/deployment/default/settings/base.py index 0194242a6cd..66c91932a09 100644 --- a/kolibri/deployment/default/settings/base.py +++ b/kolibri/deployment/default/settings/base.py @@ -99,6 +99,7 @@ "kolibri.core.auth.middleware.KolibriSessionMiddleware", "kolibri.core.device.middleware.KolibriLocaleMiddleware", "django.middleware.common.CommonMiddleware", + "csp.middleware.CSPMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "kolibri.core.auth.middleware.CustomAuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", @@ -441,3 +442,31 @@ # whether Kolibri is running within tests TESTING = False + + +# Content Security Policy header settings +# https://django-csp.readthedocs.io/en/latest/configuration.html +CSP_DEFAULT_SRC = ("'self'", "data:", "blob:") + tuple( + conf.OPTIONS["Deployment"]["CSP_HOST_SOURCES"] +) + +# Use a stricter script source policy to prevent blob: and data: from being used +CSP_SCRIPT_SRC = ("'self'",) + tuple(conf.OPTIONS["Deployment"]["CSP_HOST_SOURCES"]) + +# Allow inline styles, as we rely on them heavily in our templates +# and the Aphrodite CSS in JS library generates inline styles +CSP_STYLE_SRC = CSP_DEFAULT_SRC + ("'unsafe-inline'",) + +# Explicitly allow iframe embedding from the our zipcontent origin +# This is necessary for the zipcontent app to work +if conf.OPTIONS["Deployment"]["ZIP_CONTENT_ORIGIN"]: + # An explicit origin has been specified, just allow that as the iframe source. + frame_src = (conf.OPTIONS["Deployment"]["ZIP_CONTENT_ORIGIN"],) +else: + # Otherwise, we allow any http origin to be the iframe source. + # Because we 'self:' is not a valid CSP source value. + frame_src = ("http:", "https:") + +# Always allow 'self' and 'data' sources to allow for the kind of +# iframe manipulation needed for epub.js. +CSP_FRAME_SRC = CSP_DEFAULT_SRC + frame_src diff --git a/kolibri/deployment/default/settings/dev.py b/kolibri/deployment/default/settings/dev.py index 07d38b3455e..5be1fa72634 100644 --- a/kolibri/deployment/default/settings/dev.py +++ b/kolibri/deployment/default/settings/dev.py @@ -40,3 +40,9 @@ } SWAGGER_SETTINGS = {"DEFAULT_INFO": "kolibri.deployment.default.dev_urls.api_info"} + +# Ensure that the CSP is set up to allow webpack-dev-server to be accessed during development +# At the moment, this assumes the port will not change from 3000. +CSP_DEFAULT_SRC += ("localhost:3000", "ws:") # noqa F405 +CSP_SCRIPT_SRC += ("localhost:3000",) # noqa F405 +CSP_STYLE_SRC += ("localhost:3000",) # noqa F405 diff --git a/kolibri/plugins/context_translation/kolibri_plugin.py b/kolibri/plugins/context_translation/kolibri_plugin.py index 1ed829d9235..e2390d1f523 100644 --- a/kolibri/plugins/context_translation/kolibri_plugin.py +++ b/kolibri/plugins/context_translation/kolibri_plugin.py @@ -25,7 +25,7 @@ def head_html(self): "\n".join( [ f"""""", - """""", + """""", ] ) ) diff --git a/kolibri/plugins/context_translation/option_defaults.py b/kolibri/plugins/context_translation/option_defaults.py index 4d0a3eeb6be..a5fe0da0e50 100644 --- a/kolibri/plugins/context_translation/option_defaults.py +++ b/kolibri/plugins/context_translation/option_defaults.py @@ -1,5 +1,6 @@ option_defaults = { "Deployment": { "LANGUAGES": "ach-ug", + "CSP_HOST_SOURCES": "https://cdn.crowdin.com,https://fonts.googleapis.com,https://fonts.gstatic.com,https://crowdin-static.downloads.crowdin.com", } } diff --git a/kolibri/utils/options.py b/kolibri/utils/options.py index 542e312e1b1..7f2ea3327c3 100644 --- a/kolibri/utils/options.py +++ b/kolibri/utils/options.py @@ -358,6 +358,32 @@ def lazy_import_callback_list(value): return out +def _process_csp_source(value): + if not isinstance(value, str): + raise VdtValueError(value) + value = value.strip() + url = urlparse(value) + if not url.scheme or not url.netloc: + raise VdtValueError(value) + return value + + +def csp_source_list(value): + value = _process_list(value) + out = [] + errors = [] + for entry in value: + try: + entry_list = _process_csp_source(entry) + out.append(entry_list) + except ValueError: + errors.append(entry) + if errors: + raise VdtValueError(errors) + + return out + + base_option_spec = { "Cache": { "CACHE_BACKEND": { @@ -688,6 +714,15 @@ def lazy_import_callback_list(value): Boolean Flag to check Whether to enable Zeroconf discovery. """, }, + "CSP_HOST_SOURCES": { + "type": "csp_source_list", + "description": """ + List of host sources to use in the Content Security Policy header. This should be a list of + host sources as described by in: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#host-source + Allowing deployed Kolibri servers to specify additional hosts from which content can be loaded. + """, + }, }, "Python": { "PICKLE_PROTOCOL": { @@ -746,6 +781,7 @@ def _get_validator(): "multiprocess_bool": multiprocess_bool, "cache_option": cache_option, "lazy_import_callback_list": lazy_import_callback_list, + "csp_source_list": csp_source_list, } ) diff --git a/requirements/base.txt b/requirements/base.txt index 0c9cdf1e3be..0f4c2e21395 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,5 @@ diskcache==5.6.3 +django_csp==3.8 django-filter==21.1 django-js-reverse==0.10.2 djangorestframework==3.14.0