diff --git a/contentcuration/contentcuration/frontend/shared/languageSwitcher/mixin.js b/contentcuration/contentcuration/frontend/shared/languageSwitcher/mixin.js index 8b75b03191..682c676eae 100644 --- a/contentcuration/contentcuration/frontend/shared/languageSwitcher/mixin.js +++ b/contentcuration/contentcuration/frontend/shared/languageSwitcher/mixin.js @@ -1,4 +1,5 @@ import { availableLanguages, currentLanguage, sortLanguages } from '../i18n'; + import client from 'shared/client'; export default { diff --git a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue index e018472528..f437e06e51 100644 --- a/contentcuration/contentcuration/frontend/shared/views/AppBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/AppBar.vue @@ -75,9 +75,30 @@ - - {{ $tr('logIn') }} - + @@ -84,11 +100,13 @@ import { mapActions, mapState } from 'vuex'; import KolibriLogo from './KolibriLogo'; + import LanguageSwitcherModal from 'shared/languageSwitcher/LanguageSwitcherModal'; export default { name: 'MainNavigationDrawer', components: { KolibriLogo, + LanguageSwitcherModal, }, props: { value: { @@ -96,6 +114,11 @@ default: false, }, }, + data() { + return { + showLanguageModal: false, + }; + }, computed: { ...mapState({ user: state => state.session.currentUser, @@ -131,6 +154,7 @@ channelsLink: 'Channels', administrationLink: 'Administration', settingsLink: 'Settings', + changeLanguage: 'Change language', helpLink: 'Help and support', logoutLink: 'Sign out', copyright: '© {year} Learning Equality', diff --git a/contentcuration/contentcuration/settings.py b/contentcuration/contentcuration/settings.py index bd857d651b..1c7d962d9f 100644 --- a/contentcuration/contentcuration/settings.py +++ b/contentcuration/contentcuration/settings.py @@ -427,3 +427,5 @@ def gettext(s): DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +LANGUAGE_COOKIE_AGE = 3600 * 24 * 14 diff --git a/contentcuration/contentcuration/templates/pwa/service_worker.js b/contentcuration/contentcuration/templates/pwa/service_worker.js index 583a43bdf2..f69abd79bf 100644 --- a/contentcuration/contentcuration/templates/pwa/service_worker.js +++ b/contentcuration/contentcuration/templates/pwa/service_worker.js @@ -1,6 +1,5 @@ {% load js_reverse %} -{% js_reverse_inline %} {% autoescape off %} {{ webpack_service_worker }} diff --git a/contentcuration/contentcuration/tests/test_setlanguage.py b/contentcuration/contentcuration/tests/test_setlanguage.py new file mode 100644 index 0000000000..941db98f5d --- /dev/null +++ b/contentcuration/contentcuration/tests/test_setlanguage.py @@ -0,0 +1,150 @@ +import json + +from django.conf import settings +from django.http import HttpResponseNotAllowed +from django.test import override_settings +from django.test import TestCase +from django.urls import reverse +from django.urls import translate_url +from django.utils.translation import get_language +from django.utils.translation import LANGUAGE_SESSION_KEY + + +@override_settings(LANGUAGE_CODE="en") +class I18NTests(TestCase): + """ + Tests set_language view in kolibri/core/views.py + Copied from https://github.com/django/django/blob/stable/1.11.x/tests/view_tests/tests/test_i18n.py + """ + + def set_post_data(self, lang_code, next_url=""): + post_data = { + "language": lang_code, + "next": next_url, + } + return json.dumps(post_data) + + def _get_inactive_language_code(self): + """Return language code for a language which is not activated.""" + current_language = get_language() + return [ + code for code, name in settings.LANGUAGES if not code == current_language + ][0] + + def test_setlang(self): + """ + The set_language view can be used to change the session language. + """ + lang_code = self._get_inactive_language_code() + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("base"), lang_code), + ) + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + + def test_setlang_next_valid(self): + """ + The set_language view can be used to change the session language. + The user is redirected to the "next" argument. + """ + lang_code = self._get_inactive_language_code() + next_url = reverse("channels") + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code, next_url), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("channels"), lang_code), + ) + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + + def test_setlang_next_invalid(self): + """ + The set_language view can be used to change the session language. + The user is redirected to base redirect if the "next" argument is invalid. + """ + lang_code = self._get_inactive_language_code() + next_url = "/not/a/real/url" + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code, next_url), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("base"), lang_code), + ) + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + + def test_setlang_null(self): + """ + Test language code set to null which shoul direct to default language "en" + """ + lang_code = self._get_inactive_language_code() + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("base"), lang_code), + ) + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + lang_code = None + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("base"), "en"), + ) + self.assertFalse(LANGUAGE_SESSION_KEY in self.client.session) + + def test_setlang_null_next_valid(self): + """ + The set_language view can be used to change the session language. + The user is redirected to the "next" argument. + """ + lang_code = self._get_inactive_language_code() + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("base"), lang_code), + ) + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + next_url = reverse("channels") + lang_code = None + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code, next_url), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("channels"), "en"), + ) + self.assertFalse(LANGUAGE_SESSION_KEY in self.client.session) + + def test_setlang_null_next_invalid(self): + """ + The set_language view can be used to change the session language. + The user is redirected to user redirect if the "next" argument is invalid. + """ + lang_code = self._get_inactive_language_code() + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("base"), lang_code), + ) + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + next_url = "/not/a/real/url" + lang_code = None + response = self.client.post(reverse("set_language"), self.set_post_data(lang_code, next_url), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.content.decode("utf-8"), + translate_url(reverse("base"), "en"), + ) + self.assertFalse(LANGUAGE_SESSION_KEY in self.client.session) + + def test_setlang_get(self): + """ + The set_language view is forbidden to be accessed via GET + """ + lang_code = self._get_inactive_language_code() + response = self.client.get(reverse("set_language"), params=self.set_post_data(lang_code), content_type='application/json') + self.assertEqual(type(response), HttpResponseNotAllowed) diff --git a/contentcuration/contentcuration/views/base.py b/contentcuration/contentcuration/views/base.py index 3347513239..9a7339481c 100644 --- a/contentcuration/contentcuration/views/base.py +++ b/contentcuration/contentcuration/views/base.py @@ -1,7 +1,6 @@ import json from builtins import str from urllib.parse import urlsplit -from urllib.parse import urlunsplit from django.conf import settings from django.contrib.auth.decorators import login_required @@ -22,6 +21,8 @@ from django.urls import reverse from django.urls import reverse_lazy from django.urls import translate_url +from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.translation import check_for_language from django.utils.translation import get_language from django.utils.translation import LANGUAGE_SESSION_KEY from django.views.decorators.http import require_POST @@ -51,7 +52,6 @@ from contentcuration.models import License from contentcuration.models import TaskResult from contentcuration.serializers import SimplifiedChannelProbeCheckSerializer -from contentcuration.utils.i18n import SUPPORTED_LANGUAGES from contentcuration.utils.messages import get_messages from contentcuration.viewsets.channelset import PublicChannelSetSerializer @@ -342,53 +342,70 @@ def activate_channel_endpoint(request): return HttpResponse(json.dumps({"success": True})) -# Taken from kolibri.core.views which was -# modified from django.views.i18n @require_POST +# flake8: noqa: C901 def set_language(request): """ + We are using set_language from official Django set_language redirect view + https://docs.djangoproject.com/en/3.2/_modules/django/views/i18n/#set_language + and slighty modifying it according to our use case as we donot use AJAX, hence we donot + redirect rather just respond the required URL! + + Since this view changes how the user will see the rest of the site, it must only be accessed as a POST request. If called as a GET request, it will error. """ payload = json.loads(request.body) lang_code = payload.get(LANGUAGE_QUERY_PARAMETER) - next_url = urlsplit(payload.get("next")) if payload.get("next") else None - if lang_code and lang_code in SUPPORTED_LANGUAGES: - if next_url and is_valid_path(next_url.path): - # If it is a recognized path, then translate it to the new language and return it. - next_path = urlunsplit( - ( - next_url[0], - next_url[1], - translate_url(next_url[2], lang_code), - next_url[3], - next_url[4], - ) - ) - else: - # Just redirect to the base URL w/ the lang_code - next_path = translate_url(reverse('base'), lang_code) - response = HttpResponse(next_path) - if hasattr(request, "session"): - request.session[LANGUAGE_SESSION_KEY] = lang_code - else: - lang_code = get_language() - if next_url and is_valid_path(next_url.path): - # If it is a recognized path, then translate it using the default language code for this device - next_path = urlunsplit( - ( - next_url[0], - next_url[1], - translate_url(next_url[2], lang_code), - next_url[3], - next_url[4], - ) + next_url = payload.get("next") + + if ( + (next_url or request.accepts('text/html')) and + not url_has_allowed_host_and_scheme( + url=next_url, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ) + ): + next_url = request.META.get('HTTP_REFERER') + if not url_has_allowed_host_and_scheme( + url=next_url, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + next_url = translate_url(reverse('base'), lang_code) + next_url_split = urlsplit(next_url) if next_url else None + if next_url and not is_valid_path(next_url_split.path): + next_url = translate_url(reverse('base'), lang_code) + response = HttpResponse(next_url) if next_url else HttpResponse(status=204) + if request.method == 'POST': + if lang_code and check_for_language(lang_code): + if next_url: + next_trans = translate_url(next_url, lang_code) + if next_trans != next_url: + response = HttpResponse(next_trans) + if hasattr(request, 'session'): + # Storing the language in the session is deprecated. + # (RemovedInDjango40Warning) + request.session[LANGUAGE_SESSION_KEY] = lang_code + response.set_cookie( + settings.LANGUAGE_COOKIE_NAME, lang_code, + max_age=settings.LANGUAGE_COOKIE_AGE, + path=settings.LANGUAGE_COOKIE_PATH, + domain=settings.LANGUAGE_COOKIE_DOMAIN, + secure=settings.LANGUAGE_COOKIE_SECURE, + httponly=settings.LANGUAGE_COOKIE_HTTPONLY, + samesite=settings.LANGUAGE_COOKIE_SAMESITE, ) else: - # Just redirect to the base URL w/ the lang_code, likely the default language - next_path = translate_url(reverse('base'), lang_code) - response = HttpResponse(next_path) - if hasattr(request, "session"): - request.session.pop(LANGUAGE_SESSION_KEY, "") + lang_code = get_language() + if lang_code and check_for_language(lang_code): + if next_url: + next_trans = translate_url(next_url, lang_code) + if next_trans != next_url: + response = HttpResponse(next_trans) + if hasattr(request, "session"): + request.session.pop(LANGUAGE_SESSION_KEY, "") + return response