diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm index 43d69e3f1d..5b61a0aeab 100644 --- a/Bugzilla/CGI.pm +++ b/Bugzilla/CGI.pm @@ -22,6 +22,7 @@ use Bugzilla::Util; use Bugzilla::Search::Recent; use File::Basename; +use List::Util qw(any none); use URI; BEGIN { @@ -493,7 +494,7 @@ sub param { sub _fix_utf8 { my $input = shift; - + # The is_utf8 is here in case CGI gets smart about UTF-8 someday. utf8::decode($input) if defined $input && !ref $input && !utf8::is_utf8($input); return $input; @@ -514,6 +515,16 @@ sub should_set { sub send_cookie { my ($self, %paramhash) = @_; + # We check to see if the cookie be set is essential and if + # not we check to see if the user has given consent to set it + if (Bugzilla->params->{cookie_consent_enabled} + && $self->cookie_consent_required + && !$self->cookie_consented + ) + { + return undef if none { $_ eq $paramhash{'-name'} } ESSENTIAL_COOKIES; + } + # Complain if -value is not given or empty (bug 268146). if (!exists($paramhash{'-value'}) || !$paramhash{'-value'}) { ThrowCodeError('cookies_need_value'); @@ -666,6 +677,30 @@ sub set_dated_content_disp { $self->{'_content_disp'} = $disposition; } +# Return true/false if a user has consent to non-essential cookies +# 1. If cookie is not present then no consent +# 2. If cookie is present and equal to 'yes' then we have consent +# 3. Any other value we do not have consent +sub cookie_consented { + my ($self) = @_; + if (defined $self->cookie(CONSENT_COOKIE) + && $self->cookie(CONSENT_COOKIE) eq 'yes') + { + return 1; + } + return 0; +} + +# Return true if client is accessing this site +# from within a required consent country +sub cookie_consent_required { + my ($self) = @_; + return 1 if $ENV{CI}; + my $client_region = $self->http('X-Client-Region') || ''; + return 1 if any { $client_region eq $_ } COOKIE_CONSENT_COUNTRIES; + return 0; +} + # If a cookie is requested that has been set but not yet stored in the browser, # then we can return it here. 'X' means the cookie is being removed sub cookie { diff --git a/Bugzilla/Config/Admin.pm b/Bugzilla/Config/Admin.pm index 1c678f848f..ea5c44ca0b 100644 --- a/Bugzilla/Config/Admin.pm +++ b/Bugzilla/Config/Admin.pm @@ -53,6 +53,12 @@ sub get_param_list { type => 't', default => 'https://product-details.mozilla.org/1.0', }, + + { + name => 'cookie_consent_enabled', + type => 'b', + default => 0, + } ); return @param_list; } diff --git a/Bugzilla/Constants.pm b/Bugzilla/Constants.pm index 0aa8ac903a..8ef9e546c5 100644 --- a/Bugzilla/Constants.pm +++ b/Bugzilla/Constants.pm @@ -208,6 +208,10 @@ use Memoize; JOB_QUEUE_VIEW_MAX_JOBS BOUNCE_COUNT_MAX + + CONSENT_COOKIE + ESSENTIAL_COOKIES + COOKIE_CONSENT_COUNTRIES ); @Bugzilla::Constants::EXPORT_OK = qw(contenttypes); @@ -678,6 +682,27 @@ use constant JOB_QUEUE_VIEW_MAX_JOBS => 2500; # before the account is completely disabled. use constant BOUNCE_COUNT_MAX => 5; +# Consent cookie name +use constant CONSENT_COOKIE => 'moz-consent-pref'; + +# List of essential cookies that cannot be opted out +use constant ESSENTIAL_COOKIES => qw( + bugzilla + Bugzilla_login + Bugzilla_logincookie + Bugzilla_login_request_cookie + github_state + github_token + mfa_verification_token + moz-consent-pref + sudo +); + +# List of countries that require cookie consent +use constant COOKIE_CONSENT_COUNTRIES => qw( + AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IS IT LV + LI LT LU MT NL NO PL PT RO SK SI ES SE CH GB ); + sub bz_locations { # Force memoize() to re-compute data per project, to avoid @@ -745,7 +770,7 @@ sub DEFAULT_CSP { my %policy = ( default_src => ['self'], script_src => - ['self', 'nonce', 'unsafe-inline', 'https://www.google-analytics.com'], + ['self', 'nonce', 'unsafe-inline'], frame_src => [ # This is for extensions/BMO/web/js/firefox-crash-table.js 'https://crash-stop-addon.herokuapp.com', @@ -760,9 +785,6 @@ sub DEFAULT_CSP { # This is for extensions/BMO/web/js/firefox-crash-table.js 'https://product-details.mozilla.org', - # This is for extensions/GoogleAnalytics using beacon or XHR - 'https://www.google-analytics.com', - # This is from extensions/OrangeFactor/web/js/orange_factor.js 'https://treeherder.mozilla.org/api/failurecount/', @@ -805,7 +827,6 @@ sub SHOW_BUG_MODAL_CSP { script_src => [ 'self', 'nonce', 'unsafe-inline', 'unsafe-eval', - 'https://www.google-analytics.com' ], img_src => ['self', 'data:', 'https://secure.gravatar.com'], media_src => ['self'], @@ -815,9 +836,6 @@ sub SHOW_BUG_MODAL_CSP { # This is for extensions/BMO/web/js/firefox-crash-table.js 'https://product-details.mozilla.org', - # This is for extensions/GoogleAnalytics using beacon or XHR - 'https://www.google-analytics.com', - # This is from extensions/OrangeFactor/web/js/orange_factor.js 'https://treeherder.mozilla.org/api/failurecount/', ], diff --git a/extensions/BugModal/template/en/default/bug/create/create-modal.html.tmpl b/extensions/BugModal/template/en/default/bug/create/create-modal.html.tmpl index 5391ca154a..887a4a37c8 100644 --- a/extensions/BugModal/template/en/default/bug/create/create-modal.html.tmpl +++ b/extensions/BugModal/template/en/default/bug/create/create-modal.html.tmpl @@ -15,6 +15,7 @@ "js/bug.js", # Possible Duplicates table "js/attachment.js", "extensions/BugModal/web/create.js", + "js/util.js" ] style_urls = [ "skins/standard/attachment.css", diff --git a/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl index 26e8a29443..13a31a5c11 100644 --- a/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl @@ -64,7 +64,8 @@ "extensions/ComponentWatching/web/js/overlay.js", "js/bugzilla-readable-status-min.js", "js/field.js", - "js/comments.js" + "js/comments.js", + "js/util.js" ); jquery.push( "contextMenu", @@ -117,7 +118,8 @@ remember_collapsed: [% user.settings.ui_remember_collapsed.value == "on" ? "true" : "false" %], inline_attachments: [% user.settings.inline_attachments.value == "on" ? "true" : "false" %], autosize_comments: [% user.settings.autosize_comments.value == "on" ? "true" : "false" %] - } + }, + cookie_consent: [% Bugzilla.cgi.consent_cookie ? "true" : "false" %] }; [% IF user.id %] BUGZILLA.default_assignee = '[% bug.component_obj.default_assignee.login FILTER js %]'; diff --git a/extensions/BugModal/web/bug_modal.js b/extensions/BugModal/web/bug_modal.js index e8de1c4b36..29dbaa495d 100644 --- a/extensions/BugModal/web/bug_modal.js +++ b/extensions/BugModal/web/bug_modal.js @@ -21,7 +21,7 @@ function slide_module(module, action, fast) { 'aria-label': is_visible ? latch.data('label-expanded') : latch.data('label-collapsed'), }); if (BUGZILLA.user.settings.remember_collapsed && module.is(':visible')) - localStorage.setItem(module.attr('id') + '.visibility', is_visible ? 'show' : 'hide'); + Bugzilla.Storage.set(module.attr('id') + '.visibility', is_visible ? 'show' : 'hide'); } if (action == 'show') { @@ -43,7 +43,7 @@ function init_module_visibility() { var id = that.attr('id'); if (!id) return; if (that.data('non-stick')) return; - var stored = localStorage.getItem(id + '.visibility'); + var stored = Bugzilla.Storage.get(id + '.visibility'); if (stored) { slide_module(that, stored, true); } @@ -139,7 +139,7 @@ $(function() { // restore edit mode after navigating back function restoreEditMode() { if (!$('#editing').val()) { - if (localStorage.getItem('modal-perm-edit-mode') === 'true') { + if (Bugzilla.Storage.get('modal-perm-edit-mode') === 'true') { $('#mode-btn').click(); $('#action-enable-perm-edit').attr('aria-checked', 'true'); } @@ -170,7 +170,7 @@ $(function() { text: text, savedAt: Date.now() }; - localStorage.setItem(bugCommentCacheKey, JSON.stringify(value)); + Bugzilla.Storage.set(bugCommentCacheKey, JSON.stringify(value)); } /** @@ -180,7 +180,7 @@ $(function() { * to take such special cases into account. Otherwise the current bug’s comment cache will be removed. */ const clearSavedBugComment = (bug_id = BUGZILLA.bug_id) => { - localStorage.removeItem(`bug-modal-saved-comment-${bug_id}`); + Bugzilla.Storage.delete(`bug-modal-saved-comment-${bug_id}`); }; /** @@ -200,7 +200,7 @@ $(function() { function restoreSavedBugComment() { expireSavedComments(); - let value = JSON.parse(localStorage.getItem(bugCommentCacheKey)); + let value = Bugzilla.Storage.get(bugCommentCacheKey); if (value){ let commentBox = document.querySelector("textarea#comment"); if (commentBox.value === '') @@ -213,10 +213,10 @@ $(function() { function expireSavedComments() { const AGE_THRESHOLD = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds. let expiredKeys = []; - for (let i = 0; i < localStorage.length; i++) { - let key = localStorage.key(i); + for (let i = 0; i < window.localStorage.length; i++) { + let key = window.localStorage.key(i); if (key.match(/^bug-modal-saved-comment-/)) { - let value = JSON.parse(localStorage.getItem(key)); + let value = Bugzilla.Storage.get(key); let savedAt = value['savedAt'] || 0; let age = Date.now() - savedAt; if (age < 0 || age > AGE_THRESHOLD) { @@ -225,7 +225,7 @@ $(function() { } } expiredKeys.forEach((key) => { - localStorage.removeItem(key); + Bugzilla.Storage.delete(key); }); } @@ -543,7 +543,7 @@ $(function() { event.preventDefault(); const enabled = $(this).attr('aria-checked') !== 'true'; $(this).attr('aria-checked', enabled); - localStorage.setItem('modal-perm-edit-mode', enabled); + Bugzilla.Storage.set('modal-perm-edit-mode', enabled); }); // reset diff --git a/extensions/BugModal/web/create.js b/extensions/BugModal/web/create.js index 36ea5b7fa3..2a664cb86b 100644 --- a/extensions/BugModal/web/create.js +++ b/extensions/BugModal/web/create.js @@ -59,7 +59,7 @@ window.addEventListener('DOMContentLoaded', () => { $toggleAdvanced.textContent = $toggleAdvanced.dataset[advancedStateStr]; if (cache) { - window.localStorage.setItem('create-form.advanced', advancedStateStr); + Bugzilla.Storage.set('create-form.advanced', advancedStateStr); } }; @@ -73,7 +73,7 @@ window.addEventListener('DOMContentLoaded', () => { // Check the local storage or the TUI cookie used on the legacy form to see if the user wants // to show advanced fields on the bug form. let showAdvanced = - window.localStorage.getItem('create-form.advanced') === 'show' + Bugzilla.Storage.get('create-form.advanced') === 'show' || /\bTUI=\S*?expert_fields=1\b/.test(document.cookie); if (showAdvanced) { diff --git a/extensions/GoogleAnalytics/Config.pm b/extensions/GoogleAnalytics/Config.pm deleted file mode 100644 index f4699db378..0000000000 --- a/extensions/GoogleAnalytics/Config.pm +++ /dev/null @@ -1,16 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# This Source Code Form is "Incompatible With Secondary Licenses", as -# defined by the Mozilla Public License, v. 2.0. - -package Bugzilla::Extension::GoogleAnalytics; - -use 5.10.1; -use strict; -use warnings; - -use constant NAME => 'GoogleAnalytics'; - -__PACKAGE__->NAME; diff --git a/extensions/GoogleAnalytics/Extension.pm b/extensions/GoogleAnalytics/Extension.pm deleted file mode 100644 index fb7e8adaee..0000000000 --- a/extensions/GoogleAnalytics/Extension.pm +++ /dev/null @@ -1,23 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# This Source Code Form is "Incompatible With Secondary Licenses", as -# defined by the Mozilla Public License, v. 2.0. - -package Bugzilla::Extension::GoogleAnalytics; - -use 5.10.1; -use strict; -use warnings; -use parent qw(Bugzilla::Extension); - -our $VERSION = '0.1'; - -sub config_add_panels { - my ($self, $args) = @_; - my $modules = $args->{panel_modules}; - $modules->{GoogleAnalytics} = "Bugzilla::Extension::GoogleAnalytics::Config"; -} - -__PACKAGE__->NAME; diff --git a/extensions/GoogleAnalytics/lib/Config.pm b/extensions/GoogleAnalytics/lib/Config.pm deleted file mode 100644 index 1c453ff74b..0000000000 --- a/extensions/GoogleAnalytics/lib/Config.pm +++ /dev/null @@ -1,38 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# This Source Code Form is "Incompatible With Secondary Licenses", as -# defined by the Mozilla Public License, v. 2.0. - -package Bugzilla::Extension::GoogleAnalytics::Config; - -use 5.10.1; -use strict; -use warnings; - -use Bugzilla::Config::Common; - -sub get_param_list { - my ($class) = @_; - - my @params = ( - { - name => 'google_analytics_tracking_id', - type => 't', - default => '', - checker => sub { - my ($tracking_id) = (@_); - - return 'must be like UA-XXXXXX-X' - unless $tracking_id =~ m{^(UA-[[:xdigit:]]+-[[:xdigit:]]+)?$}; - return ''; - } - }, - {name => 'google_analytics_debug', type => 'b', default => 0}, - ); - - return @params; -} - -1; diff --git a/extensions/GoogleAnalytics/template/en/default/admin/params/googleanalytics.html.tmpl b/extensions/GoogleAnalytics/template/en/default/admin/params/googleanalytics.html.tmpl deleted file mode 100644 index 3fdce57e6d..0000000000 --- a/extensions/GoogleAnalytics/template/en/default/admin/params/googleanalytics.html.tmpl +++ /dev/null @@ -1,20 +0,0 @@ -[%# This Source Code Form is subject to the terms of the Mozilla Public - # License, v. 2.0. If a copy of the MPL was not distributed with this - # file, You can obtain one at http://mozilla.org/MPL/2.0/. - # - # This Source Code Form is "Incompatible With Secondary Licenses", as - # defined by the Mozilla Public License, v. 2.0. - #%] - -[% - title = "Google Analytics" - desc = "Configure Google Analytics" -%] - -[% - param_descs = { - google_analytics_tracking_id => "Google Analytics Tracking ID", - google_analytics_debug => "If this option is set, the debug version of the analytics.js " _ - "library will be used.", - } -%] diff --git a/extensions/GoogleAnalytics/template/en/default/hook/global/header-additional_header.html.tmpl b/extensions/GoogleAnalytics/template/en/default/hook/global/header-additional_header.html.tmpl deleted file mode 100644 index f96e0d0f9a..0000000000 --- a/extensions/GoogleAnalytics/template/en/default/hook/global/header-additional_header.html.tmpl +++ /dev/null @@ -1,24 +0,0 @@ -[%# This Source Code Form is subject to the terms of the Mozilla Public - # License, v. 2.0. If a copy of the MPL was not distributed with this - # file, You can obtain one at http://mozilla.org/MPL/2.0/. - # - # This Source Code Form is "Incompatible With Secondary Licenses", as - # defined by the Mozilla Public License, v. 2.0. - #%] - -[%# Disable tracking of DNT-enabled users as well as private bugs #%] -[% RETURN IF !Bugzilla.params.google_analytics_tracking_id || Bugzilla.cgi.http('dnt') == '1' || - (bug.defined && bug.groups_in.size) %] - - - diff --git a/extensions/GoogleAnalytics/template/en/default/hook/global/header-start.html.tmpl b/extensions/GoogleAnalytics/template/en/default/hook/global/header-start.html.tmpl deleted file mode 100644 index 020cbb94a0..0000000000 --- a/extensions/GoogleAnalytics/template/en/default/hook/global/header-start.html.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -[%# This Source Code Form is subject to the terms of the Mozilla Public - # License, v. 2.0. If a copy of the MPL was not distributed with this - # file, You can obtain one at http://mozilla.org/MPL/2.0/. - # - # This Source Code Form is "Incompatible With Secondary Licenses", as - # defined by the Mozilla Public License, v. 2.0. - #%] - -[% USE Bugzilla %] - -[%# Disable tracking of DNT-enabled users as well as private bugs #%] -[% RETURN IF !Bugzilla.params.google_analytics_tracking_id || Bugzilla.cgi.http('dnt') == '1' || - (bug.defined && bug.groups_in.size) %] - -[% IF !javascript_urls %] - [% javascript_urls = [] %] -[% END %] - -[% javascript_urls.push('extensions/GoogleAnalytics/web/js/analytics.js') %] diff --git a/extensions/GoogleAnalytics/web/js/analytics.js b/extensions/GoogleAnalytics/web/js/analytics.js deleted file mode 100644 index 1765611db6..0000000000 --- a/extensions/GoogleAnalytics/web/js/analytics.js +++ /dev/null @@ -1,29 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this file, - * You can obtain one at http://mozilla.org/MPL/2.0/. - * - * This Source Code Form is "Incompatible With Secondary Licenses", as - * defined by the Mozilla Public License, v. 2.0. */ - -window.addEventListener('DOMContentLoaded', () => { - 'use strict'; - - const $meta = document.querySelector('meta[name="google-analytics"]'); - - if (!$meta) { - return; - } - - // Activate Google Analytics - window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date; - ga('create', $meta.content, 'auto'); - ga('set', 'anonymizeIp', true); - ga('set', 'transport', 'beacon'); - // Record a crafted location (template name) and title instead of actual URL - ga('set', 'location', $meta.dataset.location); - ga('set', 'title', $meta.dataset.title); - // Custom Dimension: logged in (true) or out (false) - ga('set', 'dimension1', !!BUGZILLA.user.login); - // Track page view - ga('send', 'pageview'); -}, { once: true }); diff --git a/index.cgi b/index.cgi index db836248d5..6d228946ea 100755 --- a/index.cgi +++ b/index.cgi @@ -69,8 +69,6 @@ if ( } else { my $template = Bugzilla->template; - $C->content_security_policy( - script_src => ['self', 'https://www.google-analytics.com']); # Return the appropriate HTTP response headers. print $cgi->header( diff --git a/js/consent-settings.js b/js/consent-settings.js new file mode 100644 index 0000000000..504cbaf749 --- /dev/null +++ b/js/consent-settings.js @@ -0,0 +1,44 @@ +(function () { + "use strict"; + function getFormData() { + return (document.querySelector( + 'input[name="cookie-radio-preference"]:checked', + ).value === "yes") + ? true + : false; + } + function setFormData(preference) { + if (preference === "yes") { + document + .getElementById("cookie-radio-preference-yes") + .setAttribute("checked", ""); + document + .getElementById("cookie-radio-preference-no") + .removeAttribute("checked"); + } else { + document + .getElementById("cookie-radio-preference-yes") + .removeAttribute("checked"); + document + .getElementById("cookie-radio-preference-no") + .setAttribute("checked", ""); + } + } + function onFormSubmit(e) { + e.preventDefault(); + MozConsentBanner.setConsentCookie(getFormData()); + showSuccessMessage(); + } + function showSuccessMessage() { + var e = document.getElementById("cookie-consent-form-submit-success"); + e.style.display = "block"; + e.focus(); + } + + window.addEventListener('DOMContentLoaded', () => { + document + .getElementById("cookie-consent-form") + .addEventListener("submit", onFormSubmit); + setFormData(MozConsentBanner.getConsentCookie()); + }); +})(); diff --git a/js/consent.js b/js/consent.js new file mode 100644 index 0000000000..82004372f4 --- /dev/null +++ b/js/consent.js @@ -0,0 +1,209 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +var MozConsentBanner = {}; +var EVENT_NAME_STATUS = 'mozConsentStatus'; +var EVENT_NAME_OPEN = 'mozConsentOpen'; +var EVENT_NAME_CLOSE = 'mozConsentClose'; +var COOKIE_ID = 'moz-consent-pref'; +var COOKIE_EXPIRY_DAYS = 182; // 6 months +var MSG_OPTIONS_ERROR_DISABLED = 'verifyOptions(): cookies are disabled.'; +var MSG_OPTIONS_ERROR_EXPIRY = 'verifyOptions(): options.cookieExpiryDays not set.'; +var MSG_OPTIONS_ERROR_HELPER = 'verifyOptions(): options.helper not set.'; +var MSG_OPTIONS_ERROR_OPT_OUT = 'verifyOptions(): options.optOut not set.'; +var MSG_OPTIONS_VALID = 'verifyOptions(): success.'; +var options; + +/** + * Sets cookie indicating consent choice. + * @param {Object} data - consent settings. + */ +MozConsentBanner.setConsentCookie = function (value) { + try { + var date = new Date(); + date.setDate(date.getDate() + options.cookieExpiryDays); + var expires = date.toUTCString(); + options.helper.setItem(options.cookieID, (value ? 'yes' : 'no'), + expires, '/', options.cookieDomain, false, 'lax'); + return true; + } catch (e) { + return false; + } +}; + +/** + * Removes cookie indicating consent choice. + */ +MozConsentBanner.clearConsentCookie = function () { + return options.helper.removeItem(options.cookieID, '/', options.cookieDomain, false, 'lax'); +}; + +/** + * Get consent cookie value if set. + * @returns {Boolean} + */ +MozConsentBanner.getConsentCookie = function () { + try { + return options.helper.hasItem(options.cookieID) && options.helper.getItem(options.cookieID); + } catch (e) { + return false; + } +}; + +/** + * Helper for dispatching a custom event with a + * given name and data payload. + * @param {Object} eventName + * @param {Object} eventData + */ +MozConsentBanner.dispatchEvent = function (eventName, eventData) { + if (typeof window.CustomEvent === 'function') { + // Modern browsers + window.dispatchEvent(new CustomEvent(eventName, { + detail: eventData + })); + } else if (typeof document.createEvent === 'function') { + // Internet Explorer 10 + var customEvent = document.createEvent('CustomEvent'); + customEvent.initCustomEvent(eventName, false, false, eventData); + window.dispatchEvent(customEvent); + } else if (typeof document.createEventObject === 'function') { + // Internet Explorer 9 + var customEventObj = document.createEventObject(); + customEventObj.type = eventName; + customEventObj.bubbles = false; + customEventObj.cancelable = false; + customEventObj.detail = eventData; // Optional data for the event + window.fireEvent('on' + customEventObj.type, customEventObj); + } +}; + +/** + * Event handler for accepting cookies. + */ +MozConsentBanner.onAcceptClick = function () { + MozConsentBanner.setConsentCookie(true); + MozConsentBanner.dispatchEvent(EVENT_NAME_STATUS, true); + MozConsentBanner.dispatchEvent(EVENT_NAME_CLOSE, {}); +}; + +/** + * Event handler for rejecting cookies. + */ +MozConsentBanner.onRejectClick = function () { + MozConsentBanner.setConsentCookie(false); + MozConsentBanner.dispatchEvent(EVENT_NAME_STATUS, false); + MozConsentBanner.dispatchEvent(EVENT_NAME_CLOSE, {}); +}; + +MozConsentBanner.onClearClick = function () { + MozConsentBanner.clearConsentCookie(); + MozConsentBanner.dispatchEvent(EVENT_NAME_STATUS, false); + MozConsentBanner.dispatchEvent(EVENT_NAME_OPEN, {}); +} + +/** + * Validates BannerOptions configuration object. + * @param {BannerOptions} object containing one or more properties. + * @returns {String} message to indicate either success or error. + */ +MozConsentBanner.verifyOptions = function (options) { + // Verify cookie expiry has been set. + if (typeof options.cookieExpiryDays !== 'number') { + return MSG_OPTIONS_ERROR_EXPIRY; + } + + // Verify cookie helper dependency is defined. + if (!options.helper || typeof options.helper.setItem !== 'function') { + return MSG_OPTIONS_ERROR_HELPER; + } + + // Verify that optOut flag is set. + if (typeof options.optOut !== 'boolean') { + return MSG_OPTIONS_ERROR_OPT_OUT; + } + + // Verify that cookies are enabled in the browser. + if (!options.helper.enabled()) { + return MSG_OPTIONS_ERROR_DISABLED; + } + return MSG_OPTIONS_VALID; +}; + +/** + * MozConsentBanner.init() configuration options: + * @typedef {Object} BannerOptions + * @property {string} cookieDomain - Sets a consent cookie for a specific host name. + * @property {number} cookieExpiryDays - Consent cookie expiry by number of days. + * @property {string} cookieID - Consent cookie identifier. + * @property {object} helper (required) - reference to @mozmeao/cookie-helper peer dependency. + * @property {object} optOut - Sets the banner to be opt-out instead of the default opt-in. + */ + +/** + * Initializes cookie banner and binds events. + * @param {BannerOptions} object containing one or more properties. + * @returns {Boolean} to indicate if initialization succeeded. + */ +MozConsentBanner.init = function (config) { + /** + * Default configuration options that can be overridden via init(). + */ + var defaults = { + cookieDomain: null, + cookieExpiryDays: COOKIE_EXPIRY_DAYS, + cookieID: COOKIE_ID, + optOut: false, + eventName: EVENT_NAME_STATUS, + helper: null + }; + for (var opt in config) { + if (Object.prototype.hasOwnProperty.call(config, opt)) { + defaults[opt] = config[opt]; + } + } + options = defaults; + + /** + * If domain for consent cookie has not be supplied via config, + * set it via default BUGZILLA urlbase + */ + if (!options.cookieDomain) { + const url = new URL(BUGZILLA.config.urlbase); + options.cookieDomain = url.hostname; + } + + /** + * Validate options object before trying to show the banner. + */ + var configValidationMsg = MozConsentBanner.verifyOptions(options); + if (configValidationMsg !== MSG_OPTIONS_VALID) { + if (window.console && window.console.error) { + console.error(configValidationMsg); // eslint-disable-line no-console + } + return false; + } + + /** + * If cookie exists and consent has previously been given, + * despatch consent event. Otherwise show the banner. + */ + var consent = MozConsentBanner.getConsentCookie(); + if (consent) { + MozConsentBanner.dispatchEvent(EVENT_NAME_STATUS, consent); + } else { + MozConsentBanner.dispatchEvent(EVENT_NAME_OPEN, {}); + + /** + * If the banner is opt out, despatch event to indicate + * cookies and analytics are OK to load. + */ + if (options.optOut) { + MozConsentBanner.dispatchEvent(EVENT_NAME_STATUS, true); + } + } + return true; +}; diff --git a/js/cookie-helper.js b/js/cookie-helper.js new file mode 100644 index 0000000000..d84f54bb5e --- /dev/null +++ b/js/cookie-helper.js @@ -0,0 +1,117 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +var CookieHelper = { + getItem: function getItem(sKey) { + 'use strict'; + + if (!sKey) { + return null; + } + return decodeURIComponent(document.cookie.replace(new RegExp('(?:(?:^|.*;)\\s*' + encodeURIComponent(sKey).replace(/[-.+*]/g, '\\$&') + '\\s*\\=\\s*([^;]*).*$)|^.*$'), '$1')) || null; + }, + setItem: function setItem(sKey, sValue, vEnd, sPath, sDomain, bSecure, vSamesite) { + 'use strict'; + + if (!sKey || /^(?:expires|max-age|path|domain|secure|samesite)$/i.test(sKey)) { + return false; + } + var sExpires = ''; + if (vEnd) { + switch (vEnd.constructor) { + case Number: + sExpires = vEnd === Infinity ? '; expires=Fri, 31 Dec 9999 23:59:59 GMT' : '; max-age=' + vEnd; + break; + case String: + sExpires = '; expires=' + vEnd; + break; + case Date: + sExpires = '; expires=' + vEnd.toUTCString(); + break; + } + } + vSamesite = this.checkSameSite(vSamesite); + + // setting the samesite attribute to 'none' requires the cookie to be 'secure' + if (vSamesite === 'none') { + bSecure = true; + } + document.cookie = encodeURIComponent(sKey) + '=' + encodeURIComponent(sValue) + sExpires + (sDomain ? '; domain=' + sDomain : '') + (sPath ? '; path=' + sPath : '') + (bSecure ? '; secure' : '') + (!vSamesite ? '' : '; samesite=' + vSamesite); + return true; + }, + removeItem: function removeItem(sKey, sPath, sDomain, bSecure, vSamesite) { + 'use strict'; + + if (!this.hasItem(sKey)) { + return false; + } + return this.setItem(sKey, '', 'Thu, 01 Jan 1970 00:00:00 GMT', sPath, sDomain, bSecure, vSamesite); + }, + hasItem: function hasItem(sKey) { + 'use strict'; + + if (!sKey) { + return false; + } + return new RegExp('(?:^|;\\s*)' + encodeURIComponent(sKey).replace(/[-.+*]/g, '\\$&') + '\\s*\\=').test(document.cookie); + }, + keys: function keys() { + 'use strict'; + + var aKeys = document.cookie.replace( + // see issue 11338. + // eslint-disable-next-line no-useless-backreference + /((?:^|\s*;)[^=]+)(?=;|$)|^\s*|\s*(?:=[^;]*)?(?:\1|$)/g, '').split(/\s*(?:=[^;]*)?;\s*/); + for (var nLen = aKeys.length, nIdx = 0; nIdx < nLen; nIdx++) { + aKeys[nIdx] = decodeURIComponent(aKeys[nIdx]); + } + return aKeys; + }, + enabled: function enabled() { + 'use strict'; + + /** + * Cookies feature detect lifted from Modernizr + * https://github.com/Modernizr/Modernizr/blob/master/feature-detects/cookies.js + * + * navigator.cookieEnabled cannot detect custom or nuanced cookie blocking + * configurations. For example, when blocking cookies via the Advanced + * Privacy Settings in IE9, it always returns true. And there have been + * issues in the past with site-specific exceptions. + * Don't rely on it. + * try..catch because in some situations `document.cookie` is exposed but throws a + * SecurityError if you try to access it; e.g. documents created from data URIs + * or in sandboxed iframes (depending on flags/context) + */ + try { + // Create cookie + document.cookie = 'cookietest=1; SameSite=Lax'; + var ret = document.cookie.indexOf('cookietest=') !== -1; + // Delete cookie + document.cookie = 'cookietest=1; SameSite=Lax; expires=Thu, 01-Jan-1970 00:00:01 GMT'; + return ret; + } catch (e) { + return false; + } + }, + checkSameSite: function checkSameSite(vSamesite) { + 'use strict'; + + /** + * valid vSamesite values are 'lax', 'strict' and 'none' (case insensitive). + * otherwise it will be 'lax' + */ + if (!vSamesite) { + return null; + } + vSamesite = vSamesite.toString().toLowerCase(); + if (vSamesite === 'lax' || vSamesite === 'none' || vSamesite === 'strict') { + return vSamesite; + } else { + return 'lax'; + } + } +}; diff --git a/js/global.js b/js/global.js index 26c0056de7..db2c413a59 100644 --- a/js/global.js +++ b/js/global.js @@ -243,6 +243,32 @@ const scroll_element_into_view = ($target, complete) => { $target.scrollIntoViewIfNeeded?.() ?? $target.scrollIntoView(); } +const openBanner = () => { + // Bind click event listeners for banner buttons + document + .getElementById("moz-consent-banner-button-accept") + .addEventListener("click", MozConsentBanner.onAcceptClick, false); + document + .getElementById("moz-consent-banner-button-reject") + .addEventListener("click", MozConsentBanner.onRejectClick, false); + + // Show the banner + document.getElementById("moz-consent-banner").classList.add("is-visible"); +}; + +const closeBanner = () => { + // Unbind click event listeners + document + .getElementById("moz-consent-banner-button-accept") + .removeEventListener("click", MozConsentBanner.onAcceptClick, false); + document + .getElementById("moz-consent-banner-button-reject") + .removeEventListener("click", MozConsentBanner.onRejectClick, false); + + // Hide the banner + document.getElementById("moz-consent-banner").classList.remove("is-visible"); +}; + window.addEventListener('DOMContentLoaded', focus_main_content, { once: true }); window.addEventListener('load', detect_blocked_gravatars, { once: true }); @@ -255,18 +281,38 @@ window.addEventListener('DOMContentLoaded', () => { fetch(url, { method: "POST" }).then( response => announcement.style.display = "none" ); - localStorage.setItem("announcement_checksum", checksum); + Bugzilla.Storage.set("announcement_checksum", checksum); } announcement.addEventListener('click', hide_announcement); window.addEventListener('visibilitychange', () => { if (!window.hidden) { - const hidden_checksum = localStorage.getItem("announcement_checksum"); + const hidden_checksum = Bugzilla.Storage.get("announcement_checksum"); if (hidden_checksum && hidden_checksum == announcement.dataset.checksum) { announcement.style.display = "none"; } } }); } + + // Mozilla Consent Banner + // Bind open and close events before calling init(). + if (BUGZILLA.config.cookie_consent_enabled && BUGZILLA.config.cookie_consent_required) { + window.addEventListener('mozConsentOpen', openBanner, false); + window.addEventListener('mozConsentReset', openBanner, false); + window.addEventListener('mozConsentClose', closeBanner, false); + window.addEventListener('mozConsentStatus', (e) => { + console.log(e.detail); // eslint-disable-line no-console + }); + MozConsentBanner.init({ + helper: CookieHelper, + }); + + // Listen for click to reset cookie preference + let $reset_cookie_consent = document.getElementById('reset_cookie_consent'); + if ($reset_cookie_consent) { + $reset_cookie_consent.addEventListener('click', MozConsentBanner.onClearClick); + } + } }, { once: true }); // Global header diff --git a/js/util.js b/js/util.js index 0891e9d079..5c79dcc714 100644 --- a/js/util.js +++ b/js/util.js @@ -850,21 +850,19 @@ Bugzilla.Storage = class LocalStorage { * Get a value. * @param {string} key A storage key. * @param {any} [fallback] Whether to return `{}` instead of `null` when the value is unavailable. - * @returns {object | null} A storage value. + * @returns A storage value or null. */ - static get(key, fallback = false) { + static get(key) { const cache = window.localStorage.getItem(key); - const fallbackValue = fallback ? {} : null; - - if (cache === null) { - return fallbackValue; - } - - try { - return JSON.parse(cache); - } catch { - return fallbackValue; + let value = null; + if (cache !== null) { + try { + value = JSON.parse(cache); + } catch { + value = cache; + } } + return value; } /** @@ -873,7 +871,15 @@ Bugzilla.Storage = class LocalStorage { * @param {object} value A storage value. */ static set(key, value) { - window.localStorage.setItem(key, JSON.stringify(value)); + if (BUGZILLA.user.cookie_consent === 'no' + && !BUGZILLA.config.essential_cookies.includes(key)) + { + return null; + } + if (typeof value === 'object' && value !== null) { + value = JSON.stringify(value); + } + window.localStorage.setItem(key, value); } /** diff --git a/qa/t/1_test_cookie_consent.t b/qa/t/1_test_cookie_consent.t new file mode 100644 index 0000000000..d87d6cebec --- /dev/null +++ b/qa/t/1_test_cookie_consent.t @@ -0,0 +1,115 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is 'Incompatible With Secondary Licenses', as +# defined by the Mozilla Public License, v. 2.0. + +use strict; +use warnings; +use lib qw(lib ../../lib ../../local/lib/perl5); + +use Test::More 'no_plan'; + +use List::Util qw(first none); +use QA::Util; + +my ($sel, $config) = get_selenium(); + +# Turn on cookie consent +log_in($sel, $config, 'admin'); +set_parameters($sel, + {'Administrative Policies' => {'cookie_consent_enabled-on' => undef}}); + +# Accept all cookies for admin user +$sel->click_ok('moz-consent-banner-button-accept'); + +# Make sure that a 'moz-consent-pref' cookie was set to yes +my $cookies = $sel->driver->get_all_cookies; +my $pref_cookie = first { $_->{name} eq 'moz-consent-pref' } @{$cookies}; +ok($pref_cookie && $pref_cookie->{value} eq 'yes', + 'Consent cookie set to yes properly'); + +# Create a test bug +file_bug_in_product($sel, 'TestProduct'); +my $bug_summary = 'Cookie consent test bug'; +$sel->type_ok('short_desc', $bug_summary); +my $bug1_id = create_bug($sel, $bug_summary); + +# Run a buglist query to set a cookie such as LASTORDER which is non-essential +open_advanced_search_page($sel); +$sel->type_ok('short_desc', 'Cookie consent test bug'); +$sel->select_ok('order', 'label=Bug Number', 'Select order by bug number'); +$sel->click_ok('Search'); +$sel->wait_for_page_to_load_ok(WAIT_TIME); +$sel->title_is('Bug List'); +$sel->is_text_present_ok('One bug found'); +$sel->is_text_present_ok('Cookie consent test bug'); + +$cookies = $sel->driver->get_all_cookies; +my $last_order_cookie = first { $_->{name} eq 'LASTORDER' } @{$cookies}; +ok($last_order_cookie, 'Last order cookie set properly'); + +# Change cookie preferences to reject +$sel->click_ok('header-account-menu-button'); +$sel->click_ok('link=Cookies'); +$sel->wait_for_page_to_load(WAIT_TIME); +$sel->title_is('Cookie Settings'); +$sel->click_ok('cookie-radio-preference-no'); +$sel->click_ok('cookie-consent-save'); +$sel->is_text_present_ok('Your Cookie settings have been updated'); + +# Clear all cookies except the consent cookie and login stuff +$cookies = $sel->driver->get_all_cookies; +foreach my $cookie (@{$cookies}) { + my $name = $cookie->{name}; + unless ($name eq 'Bugzilla_login' + || $name eq 'Bugzilla_logincookie' + || $name eq 'moz-consent-pref') + { + $sel->driver->delete_cookie_named($name); + } +} + +# Verify that LASTORDER and COLUMNLIST are no longer set +open_advanced_search_page($sel); +$sel->type_ok('short_desc', 'Cookie consent test bug'); +$sel->select_ok('order', 'label=Bug Number', 'Select order by bug number'); +$sel->click_ok('Search'); +$sel->wait_for_page_to_load_ok(WAIT_TIME); +$sel->title_is('Bug List'); +$sel->is_text_present_ok('One bug found'); +$sel->is_text_present_ok('Cookie consent test bug'); + +$cookies = $sel->driver->get_all_cookies; +$last_order_cookie = first { $_->{name} eq 'LASTORDER' } @{$cookies}; +ok(!$last_order_cookie, 'Last order cookie not set properly'); + +# Logout and clear all cookies. Then we will reject all cookies and verify +logout($sel); +$sel->driver->delete_all_cookies(); +$sel->open_ok('/home', 'Go to home page'); +$sel->click_ok('moz-consent-banner-button-reject'); + +log_in($sel, $config, 'admin'); +open_advanced_search_page($sel); +$sel->type_ok('short_desc', 'Cookie consent test bug'); +$sel->select_ok('order', 'label=Bug Number', 'Select order by bug number'); +$sel->click_ok('Search'); +$sel->wait_for_page_to_load_ok(WAIT_TIME); +$sel->title_is('Bug List'); +$sel->is_text_present_ok('One bug found'); +$sel->is_text_present_ok('Cookie consent test bug'); + +$cookies = $sel->driver->get_all_cookies; +$last_order_cookie = first { $_->{name} eq 'LASTORDER' } @{$cookies}; +ok(!$last_order_cookie, 'Last order cookie not set properly'); + +## Turn off cookie consent +set_parameters($sel, + {'Administrative Policies' => {'cookie_consent_enabled-off' => undef}}); +logout($sel); + +done_testing; + +1; diff --git a/skins/standard/consent.css b/skins/standard/consent.css new file mode 100644 index 0000000000..301c0dcced --- /dev/null +++ b/skins/standard/consent.css @@ -0,0 +1,151 @@ +@keyframes a-slide-up { + 0% { + opacity: 0; + transform: translateY(60px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} +.moz-consent-banner { + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; + background: #ffffff; + color: #000000; + display: none; + font-family: Inter, sans-serif; + text-size-adjust: 100%; +} +.moz-consent-banner.is-visible { + display: block; +} +.moz-consent-banner .moz-consent-banner-content { + background: #ffffff; + margin: 0 auto; + max-width: 1152px; + padding: 16px 16px 0 16px; +} +.moz-consent-banner .moz-consent-banner-heading { + color: #000000; + font-family: Inter, sans-serif; + font-size: 1.5rem; + font-size: 24px; + margin: 0 0 16px; +} +.moz-consent-banner .moz-consent-banner-copy p { + font-size: 16px; + font-size: 1rem; + max-width: 60em; + line-height: 1.5; +} +.moz-consent-banner .moz-consent-banner-copy a:link, +.moz-consent-banner .moz-consent-banner-copy a:visited { + color: #000000; + text-decoration: underline; +} +.moz-consent-banner .moz-consent-banner-copy a:hover, +.moz-consent-banner .moz-consent-banner-copy a:active, +.moz-consent-banner .moz-consent-banner-copy a:focus { + text-decoration: none; +} +.moz-consent-banner .moz-consent-banner-controls { + text-align: center; +} +.moz-consent-banner .moz-consent-banner-controls a { + display: inline-block; + font-size: 16px; + font-size: 1rem; + margin-bottom: 16px; +} +.moz-consent-banner .moz-consent-banner-button { + background-color: #000000; + border-radius: 4px; + border: 2px solid #000000; + box-sizing: border-box; + color: #ffffff; + cursor: pointer; + display: inline-block; + font-family: Inter, sans-serif; + font-size: 16px; + font-size: 1rem; + font-weight: bold; + line-height: 1.5; + margin-bottom: 16px; + padding: 6px 24px; + text-align: center; + transition: background-color 100ms, box-shadow 100ms, color 100ms; + width: 100%; +} +.moz-consent-banner .moz-consent-banner-button:focus { + border-color: #0060df; + box-shadow: 0 0 0 2px rgba(0, 144, 237, 0.5); + outline-offset: 1px; +} +.moz-consent-banner .moz-consent-banner-button:hover { + background-color: #ededf0; + border-color: #000000; + box-shadow: none; + color: #000000; +} +.moz-consent-banner .moz-consent-banner-button:active { + background-color: #ededf0; + border-color: #5e5e72; + color: #000000; +} +@media (min-width: 768px) { + .moz-consent-banner .moz-consent-banner-content { + padding: 24px 48px 0 48px; + } + .moz-consent-banner .moz-consent-banner-controls { + text-align: left; + } + [dir=rtl] .moz-consent-banner .moz-consent-banner-controls { + text-align: right; + } + .moz-consent-banner .moz-consent-banner-controls .moz-consent-banner-button, + .moz-consent-banner .moz-consent-banner-controls a { + display: inline-block; + margin-bottom: 24px; + margin-right: 16px; + } + [dir=rtl] .moz-consent-banner .moz-consent-banner-controls .moz-consent-banner-button, + [dir=rtl] .moz-consent-banner .moz-consent-banner-controls a { + margin-left: 16px; + margin-right: 0; + } + .moz-consent-banner .moz-consent-banner-controls .moz-consent-banner-button { + width: auto; + } +} +@media (min-height: 600px) { + .moz-consent-banner { + animation: a-slide-up 600ms ease 0ms both; + background: transparent; + bottom: 0; + left: 0; + padding: 8px; + position: fixed; + right: 0; + z-index: 1000; + } + .moz-consent-banner .moz-consent-banner-content { + border-radius: 16px; + box-shadow: 0 5px 16px 2px rgba(29, 17, 51, 0.25); + } +} +@media (min-height: 600px) and (min-width: 768px) { + .moz-consent-banner { + padding: 16px; + } +} +@media (min-height: 600px) and (min-width: 1024px) { + .moz-consent-banner { + padding: 24px; + } +} +@media (min-height: 600px) and (prefers-reduced-motion: reduce) { + .moz-consent-banner { + animation: none; + } +} diff --git a/skins/standard/page.css b/skins/standard/page.css index 5451c227dc..3239288833 100644 --- a/skins/standard/page.css +++ b/skins/standard/page.css @@ -10,9 +10,8 @@ template/en/default/pages/ directory. */ #main-inner { - margin: 15px auto; - /* People have an easier time reading narrower columns of text. */ - max-width: 50em; + margin: 8px auto; + max-width: 1024px; } #main-inner p, diff --git a/template/en/default/account/prefs/account.html.tmpl b/template/en/default/account/prefs/account.html.tmpl index 884766c4bc..27c2565357 100644 --- a/template/en/default/account/prefs/account.html.tmpl +++ b/template/en/default/account/prefs/account.html.tmpl @@ -24,6 +24,8 @@ # new_login_name: string. The user's new Bugzilla login whilst not confirmed. (optional) #%] +[% USE Bugzilla %] + [%# BMO - add hook for displaying user-profile link %] [% Hook.process('start') %] @@ -173,10 +175,6 @@ [% END %] -
get_b[%''%]ug
which covers JSON-RPC
log_user_requests => "This option controls logging of authenticated requests in the user_request_log table"
- product_details_endpoint => "Endpoint used for getting version details of Mozilla products"}
+ product_details_endpoint => "Endpoint used for getting version details of Mozilla products"
+
+ cookie_consent_enabled => "Turn on cookie consent banner for specific countries."}
%]
diff --git a/template/en/default/global/footer.html.tmpl b/template/en/default/global/footer.html.tmpl
index f1ad69fc9f..3e81099fc5 100644
--- a/template/en/default/global/footer.html.tmpl
+++ b/template/en/default/global/footer.html.tmpl
@@ -25,6 +25,30 @@
[% Hook.process("end") %]
+[% USE Bugzilla %]
+[% IF Param("cookie_consent_enabled")
+ && Bugzilla.cgi.cookie_consent_required %]
+
+[% END %]