diff --git a/TWLight/forms.py b/TWLight/forms.py new file mode 100644 index 0000000000..4d1f9fdd35 --- /dev/null +++ b/TWLight/forms.py @@ -0,0 +1,59 @@ +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Hidden, Submit, Layout + +from django.conf import settings +from django.contrib.auth.models import User + +from django import forms +from django.utils.translation import get_language, gettext as _ + + +class EdsSearchForm(forms.Form): + """ """ + + lang = forms.ChoiceField(choices=settings.LANGUAGES) + schemaId = forms.CharField() + custid = forms.CharField() + groupid = forms.CharField() + profid = forms.CharField() + scope = forms.CharField() + site = forms.CharField() + direct = forms.CharField() + authtype = forms.CharField() + bquery = forms.CharField() + + def __init__(self, *args, **kwargs): + language_code = get_language() + lang = language_code + bquery = kwargs.pop("bquery", None) + + super().__init__(*args, **kwargs) + if language_code == "pt": + lang = "pt-pt" + elif language_code == "zh-hans": + lang = "zh-cn" + elif language_code == "zh-hant": + lang = "zh-tw" + self.helper = FormHelper() + self.helper.form_id = "search" + self.helper.form_action = "https://searchbox.ebsco.com/search/" + self.helper.form_method = "GET" + self.helper.label_class = "sr-only" + self.helper.layout = Layout( + Hidden("bquery", bquery), + Hidden("lang", lang), + Hidden("schemaId", "search"), + Hidden("custid", "ns253359"), + Hidden("groupid", "main"), + Hidden("profid", "eds"), + Hidden("scope", "site"), + Hidden("site", "eds-live"), + Hidden("direct", "true"), + Hidden("authtype", "url"), + Submit( + "submit", + # Translators: Shown in the search button. + _("Search"), + css_class="btn eds-search-button", + ), + ) diff --git a/TWLight/settings/base.py b/TWLight/settings/base.py index ffd4541cb0..78a71b95fb 100644 --- a/TWLight/settings/base.py +++ b/TWLight/settings/base.py @@ -271,6 +271,9 @@ def show_toolbar(request): # Overwrite django default SESSION_COOKIE_AGE SESSION_COOKIE_AGE = 259200 +# Add header to work with EDS +SECURE_REFERRER_POLICY = "origin" + # INTERNATIONALIZATION CONFIGURATION # ------------------------------------------------------------------------------ diff --git a/TWLight/static/css/new-local.css b/TWLight/static/css/new-local.css index 7cd7da4d0c..a279a4be82 100644 --- a/TWLight/static/css/new-local.css +++ b/TWLight/static/css/new-local.css @@ -390,6 +390,15 @@ COMMON CONTENT BLOCK CSS padding: 0.5rem 1rem; } +/* +---------------------------------------------------------------------------- +NOSCRIPT CSS +-------------------------------------------------------------------------- +*/ +#noscript { + color: red; +} + /* ---------------------------------------------------------------------------- HOMEPAGE CAROUSEL CSS diff --git a/TWLight/templates/eds_search_endpoint.html b/TWLight/templates/eds_search_endpoint.html new file mode 100644 index 0000000000..477b3e15a2 --- /dev/null +++ b/TWLight/templates/eds_search_endpoint.html @@ -0,0 +1,23 @@ +{% extends "new_base.html" %} +{% load i18n %} +{% load crispy_forms_tags %} +{% load twlight_perms %} +{% block content %} + {% comment %}Translators: On a special 'link to search' page, this message is shown if JavaScript is disabled.{% endcomment %} +

{% trans "JavaScript is disabled; use the button below to continue." %}

+ {% crispy form %} +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/TWLight/tests.py b/TWLight/tests.py index 15deb70283..8371868fea 100644 --- a/TWLight/tests.py +++ b/TWLight/tests.py @@ -34,6 +34,7 @@ PartnerCoordinatorOrSelf, CoordinatorsOnly, EditorsOnly, + EligibleEditorsOnly, SelfOnly, ToURequired, EmailRequired, @@ -81,6 +82,10 @@ class TestEditorsOnly(EditorsOnly, DispatchProvider): pass +class TestEligibleEditorsOnly(EligibleEditorsOnly, DispatchProvider): + pass + + class TestSelfOnly(SelfOnly, ObjGet, DispatchProvider): pass @@ -267,6 +272,66 @@ def test_editors_only_3(self): with self.assertRaises(PermissionDenied): test.dispatch(req) + def test_eligible_editors_only_1(self): + """ + EligibleEditorsOnly allows eligible editors. + """ + user = UserFactory() + _ = EditorFactory(user=user) + _.wp_bundle_eligible = True + + req = RequestFactory() + req.user = user + + test = TestEligibleEditorsOnly() + test.dispatch(req) + + def test_eligible_editors_only_2(self): + """ + EligibleEditorsOnly does *not* allow superusers who aren't editors. + """ + user = UserFactory(is_superuser=True) + self.assertFalse(hasattr(user, "editor")) + + req = RequestFactory() + req.user = user + + test = TestEligibleEditorsOnly() + with self.assertRaises(PermissionDenied): + test.dispatch(req) + + def test_eligible_editors_only_3(self): + """ + EligibleEditorsOnly does not allow non-superusers who aren't editors. + """ + user = UserFactory(is_superuser=False) + self.assertFalse(hasattr(user, "editor")) + + req = RequestFactory() + req.user = user + + test = TestEligibleEditorsOnly() + with self.assertRaises(PermissionDenied): + test.dispatch(req) + + def test_eligible_editors_only_4(self): + """ + EligibleEditorsOnly redirects ineligible editors. + """ + user = UserFactory(is_superuser=False) + self.assertFalse(hasattr(user, "editor")) + _ = EditorFactory(user=user) + _.wp_bundle_eligible = False + + req = RequestFactory() + req.user = user + + test = TestEligibleEditorsOnly() + + resp = test.dispatch(req) + # This test doesn't deny permission; it sends people to my_library. + self.assertTrue(isinstance(resp, HttpResponseRedirect)) + def test_self_only_1(self): """ SelfOnly allows users who are also the object returned by get_object. diff --git a/TWLight/urls.py b/TWLight/urls.py index 43c35345e7..6940db2ec6 100644 --- a/TWLight/urls.py +++ b/TWLight/urls.py @@ -28,7 +28,7 @@ from TWLight.users.views import TermsView from TWLight.ezproxy.urls import urlpatterns as ezproxy_urls -from .views import NewHomePageView +from .views import NewHomePageView, SearchEndpointFormView handler400 = "TWLight.views.bad_request" @@ -88,6 +88,11 @@ url(r"^contact/$", ContactUsView.as_view(), name="contact"), url(r"^$", NewHomePageView.as_view(), name="homepage"), url(r"^about/$", TemplateView.as_view(template_name="about.html"), name="about"), + url( + r"^search/$", + login_required(SearchEndpointFormView.as_view()), + name="search", + ), ] # Enable debug_toolbar if configured diff --git a/TWLight/users/oauth.py b/TWLight/users/oauth.py index 22f655be2c..46445833b0 100644 --- a/TWLight/users/oauth.py +++ b/TWLight/users/oauth.py @@ -293,12 +293,15 @@ def get(self, request, *args, **kwargs): next = query_dict.pop("next") # Set the return url to the value of 'next'. Basic. return_url = next[0] + # Pop the 'from_homepage' parameter out of the QueryDict. + # We don't need it here. + query_dict.pop("from_homepage", None) # If there is anything left in the QueryDict after popping # 'next', append it to the return url. This preserves state # for filtered lists and redirected form submissions like # the partner suggestion form. if query_dict: - return_url += "?" + urlencode(query_dict) + return_url += "&" + urlencode(query_dict) logger.info( "User is already authenticated. Sending them on " 'for post-login redirection per "next" parameter.' @@ -332,7 +335,8 @@ def get(self, request, *args, **kwargs): next = query_dict.pop("next") # Set the return url to the value of 'next'. Basic. return_url = next[0] - from_homepage = query_dict.get("from_homepage", None) + # Pop the 'from_homepage' parameter out of the QueryDict. + from_homepage = query_dict.pop("from_homepage", None) if from_homepage: logger.info("Logging in from homepage, redirecting to Meta login") @@ -513,12 +517,15 @@ def get(self, request, *args, **kwargs): next = query_dict.pop("next") # Set the return url to the value of 'next'. Basic. return_url = next[0] + # Pop the 'from_homepage' parameter out of the QueryDict. + # We don't need it here. + query_dict.pop("from_homepage", None) # If there is anything left in the QueryDict after popping # 'next', append it to the return url. This preserves state # for filtered lists and redirected form submissions like # the partner suggestion form. if query_dict: - return_url += "?" + urlencode(query_dict) + return_url += "&" + urlencode(query_dict) logger.info( "User authenticated. Sending them on for " 'post-login redirection per "next" parameter.' diff --git a/TWLight/view_mixins.py b/TWLight/view_mixins.py index 34909fcd82..a28cd0278a 100644 --- a/TWLight/view_mixins.py +++ b/TWLight/view_mixins.py @@ -21,6 +21,7 @@ from TWLight.applications.models import Application from TWLight.resources.models import Partner from TWLight.users.models import Editor +from TWLight.users.helpers.editor_data import editor_bundle_eligible from TWLight.users.groups import COORDINATOR_GROUP_NAME, RESTRICTED_GROUP_NAME import logging @@ -205,6 +206,30 @@ def dispatch(self, request, *args, **kwargs): return super(EditorsOnly, self).dispatch(request, *args, **kwargs) +class EligibleEditorsOnly(object): + """ + Restricts visibility to: + * Eligible Editors. + + Raises Permission denied for non-editors. + Redirects to my_library with message for ineligible editors. + """ + + def dispatch(self, request, *args, **kwargs): + user = request.user + if not test_func_editors_only(user): + messages.add_message( + request, + messages.WARNING, + "You must be a coordinator or an editor to do that.", + ) + raise PermissionDenied + elif not editor_bundle_eligible(user.editor): + # Send ineligible editors to my_library for info on eligibility. + return HttpResponseRedirect(reverse_lazy("users:my_library")) + return super().dispatch(request, *args, **kwargs) + + def test_func_tou_required(user): try: return user.is_superuser or user.userprofile.terms_of_use diff --git a/TWLight/views.py b/TWLight/views.py index c37aa7e832..b80c4d9681 100644 --- a/TWLight/views.py +++ b/TWLight/views.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- import json -from django.views.generic import TemplateView +from django.views.generic import RedirectView, TemplateView +from django.views.generic.edit import FormView from django.views import View from django.conf import settings from django.contrib.messages import get_messages @@ -17,6 +18,9 @@ from TWLight.resources.models import Partner, PartnerLogo from TWLight.resources.helpers import get_partner_description, get_tag_dict +from .forms import EdsSearchForm +from .view_mixins import EligibleEditorsOnly + import logging from django.views.defaults import ERROR_400_TEMPLATE_NAME, ERROR_PAGE_TEMPLATE @@ -125,6 +129,20 @@ def get(self, request): return render(request, "homepage.html", context) +class SearchEndpointFormView(EligibleEditorsOnly, FormView): + """ + Allows persistent links to EDS searches with referring URL authentication. + """ + + def get_form_kwargs(self, **kwargs): + kwargs = super().get_form_kwargs() + kwargs["bquery"] = self.request.GET.get("q") + return kwargs + + template_name = "eds_search_endpoint.html" + form_class = EdsSearchForm + + @sensitive_variables() @requires_csrf_token def bad_request(request, exception, template_name=ERROR_400_TEMPLATE_NAME): diff --git a/conf/local.nginx.conf b/conf/local.nginx.conf index a2afe311bb..38ef48a046 100644 --- a/conf/local.nginx.conf +++ b/conf/local.nginx.conf @@ -53,8 +53,6 @@ server { # redirects, we set the Host: header above already. proxy_redirect off; proxy_pass http://twlight; - # Add header to work with EDS - add_header Referrer-Policy 'origin'; } error_page 500 502 503 504 /500.html; diff --git a/conf/production.nginx.conf b/conf/production.nginx.conf index 298086637f..135e3c9af6 100644 --- a/conf/production.nginx.conf +++ b/conf/production.nginx.conf @@ -94,8 +94,6 @@ server { # redirects, we set the Host: header above already. proxy_redirect off; proxy_pass http://twlight; - # Add header to work with EDS - add_header Referrer-Policy 'origin'; } proxy_intercept_errors on; diff --git a/conf/staging.nginx.conf b/conf/staging.nginx.conf index 66160a84b7..7ef891563f 100644 --- a/conf/staging.nginx.conf +++ b/conf/staging.nginx.conf @@ -95,8 +95,6 @@ server { # redirects, we set the Host: header above already. proxy_redirect off; proxy_pass http://twlight; - # Add header to work with EDS - add_header Referrer-Policy 'origin'; } proxy_intercept_errors on;