Skip to content

Commit

Permalink
📝 Documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
sergei-maertens committed Aug 6, 2023
1 parent ee23ae9 commit 9900342
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 7 deletions.
27 changes: 21 additions & 6 deletions cookie_consent/static/cookie_consent/cookiebar.module.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
*
* About modules: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
*
* The code is organized here in a way to make the templates work with Django's page
* cache. This means that anything user-specific (so different django session and even
* cookie consent cookies) cannot be baked into the templates, as that breaks caches.
*
* The cookie bar operates on the following principles:
*
* - The developer using the library includes the desired template in their django
* templates, using the HTML <template> element. This contains the content for the
* cookie bar.
* - The developer is responsible for loading some Javascript that loads this script.
* - The main export of this script needs to be called (showCookieBar), with the
* appropriate options.
* - The options include the backend URLs where the retrieve data, which selectors/DOM
* nodes to use for various functionality and the hooks to tap into the accept/decline
* life-cycle.
* - When a user accepts or declines (all) cookies, the call to the backend is made via
* a fetch request, bypassing any page caches and preventing full-page reloads.
*/
const DEFAULTS = {
statusUrl: undefined,
Expand Down Expand Up @@ -62,21 +79,19 @@ const registerEvents = (cookieBarNode) => {
.querySelector(acceptSelector)
.addEventListener('click', event => {
event.preventDefault();
console.log('accept clicked');
onAccept?.(event);
// TODO: discover scripts to toggle to text/javascript or module type
onAccept?.(event, COOKIE_STATUS.cookieGroups);
acceptCookiesBackend();
cookieBarNode.parentNode.removeChild(cookieBarNode);
});

cookieBarNode
.querySelector(declineSelector)
.addEventListener('click', event => {
event.preventDefault();
console.log('decline clicked');
onDecline?.(event);
onDecline?.(event, COOKIE_STATUS.cookieGroups);
// TODO: provide beforeDeclined hook?
// TODO: discover scripts to disable
declineCookiesBackend();
cookieBarNode.parentNode.removeChild(cookieBarNode);
});
};

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ User Guide
quickstart
concept
usage
javascript
example_app
settings
contributing
Expand Down
259 changes: 259 additions & 0 deletions docs/javascript.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
.. _javascript:

======================
Javascript integration
======================

Cookie consent supports "classic" pages where you submit the accept/decline form and
then it performs a full page reload. This strategy is simple and straight-forward, as
any dynamic scripts tied to the cookie groups are then automatically initialized.

However, this does not lead to the best user-experience. Consider a user filling out a
long form and half-way they decide to get rid of the "annoying cookie bar". Either
accepting or declining will make them lose their changes, providing a frustrating
experience.

Using the scripts we ship, you can provide a better user experience, at the cost of
more development work.

.. _showcookiebar_getting_started:

Getting started
===============

Requirements
------------

The new script is designed for modern Javascript and modern browsers. It should also
integrate with JS tooling like Webpack/Rollup/ESBuild... but we are not actively testing
this. Please let us know via Github issues what your issues and/or wishes are!

As such, the target browsers must support:

* ``<script type="module">`` OR you must process the source code with a compiler (like
Babel_).
* ``window.fetch``
* ``async``/``await`` syntax OR use a compiler like Babel.
* ES2020 (features like optional chaining are used)

In your Django template
-----------------------

**Add a template element for your content**

The ``<template>`` node is cloned and injected in the configured location. For example:

.. code-block:: django
{% url "cookie_consent_cookie_group_list" as url_cookies %}
<template id="cookie-consent__cookie-bar">
<div class="cookie-bar">
This site uses cookies for better performance and user experience.
Do you agree to use these cookies?
{# Button is the more accessible role, but an anchor tag would also work #}
<button type="button" class="cookie-consent__accept">Accept</button>
<button type="button" class="cookie-consent__decline">Decline</button>
<a href="{{ url_cookies }}">Cookies info</a>
</div>
</template>
This lets you, the developer, control the exact layout, styling and content of the
cookie notice.

**Include a script that calls the ``showCookieBar`` function**

The most straight-forward way is to include this in your Django template:

.. code-block:: django
{% load static cookie_consent_tags %}
{% static "cookie_consent/cookiebar.module.js" as cookiebar_src %}
{% url 'cookie_consent_status' as status_url %}
<script type="module">
import {showCookieBar} from '{{ cookiebar_src }}';
showCookieBar({
statusUrl: '{{ status_url|escapejs }}',
templateSelector: '#cookie-consent__cookie-bar',
cookieGroupsSelector: '#cookie-consent__cookie-groups',
onShow: () => document.querySelector('body').classList.add('with-cookie-bar'),
onAccept: () => document.querySelector('body').classList.remove('with-cookie-bar'),
onDecline: () => document.querySelector('body').classList.remove('with-cookie-bar'),
});
</script>
You call the function with the necessary options, and on page-load the cookie bar will
be properly initialized.

The ``status_url`` is special - it points to a backend view which returns the
user-specific cookie consent status, returning the appropriate accept and decline URLs
and other details relevant to cookie consent.

You can of course also import this function in your own Javascript entrypoint (if you
use Babel/Webpack or similar tooling) and initialize the cookie bar that way.

Options
=======

The ``showCookieBar`` function takes a few required options and many optional options to
tweak the behaviour to your wishes.

**Required options**

* ``statusUrl``: URL to the ``CookieStatusView`` - essential to determine the
accept/decline URLs and CSRF token. Use ``{% url 'cookie_consent_status' as status_url %}``
for the correct value, irrespective of your urlconf.

**Recommended options**

These options have default values, but to prevent surprises and maximum flexibility, you
should provide them. Please check the source code for their default values.

* ``templateSelector`` - CSS selector to find the template element of the cookie bar.
This element will be cloned and ultimately added to the page.

* ``cookieGroupsSelector`` - CSS selector to the element produced by
``{% all_cookie_groups 'cookie-consent__cookie-groups' %}``. This provides all
configured cookie groups in a JSON script tag and is read by ``showCookieBar`` to
determine if a bar should be shown at all (e.g. if there are no cookie groups,
nothing is done).

* ``acceptSelector`` - CSS selector to the element to accept all cookies. A ``click``
event listener is bound to this element to register the cookies accept action.

* ``declineSelector`` - CSS selector to the element to decline all cookies. A ``click``
event listener is bound to this element to register the cookies decline action.

**Optional**

* ``insertBefore`` - A CSS selector, DOM node or ``null``. If provided, the cookie bar
is prepended before this node, otherwise it is appended to the body element.

* ``onShow`` - an optional callback function, called right before the cookie bar is
added to the document.

* ``onAccept`` - an optional callback, called when the "cookies accept" element is
clicked. It receives the click event and list of cookie groups that were accepted.

* ``onDecline`` - an optional callback, called when the "cookies decline" element is
clicked. It receives the click event and list of cookie groups that were declined.

* ``csrfHeaderName`` - HTTP header name for the CSRF Token. Defaults to Django's default
value, so if you have a non-default ``settings.CSRF_HEADER_NAME``, you must provide
this.

Enabling other scripts after cookies were accepted
==================================================

The legacy version of ``showCookieBar`` supported emitting scripts with a custom type
in the Django templates, which where then changed to ``type="text/javascript"`` to make
them execute without a full page reload. The new version does not support this out of
the box, as it may interfere with page caches, Content Security Policies and was poorly
documented.

We recommend hooking into the ``onAccept`` and ``onDecline`` hooks to perform these
actions.

E.g. in the django template:

.. code-block:: django
<template id="analytics-scripts">
<script type="text/javascript">
// lots of interesting code
</script>
<script type="module" src="..."></script>
</template>
and the Javascript function:

.. code-block:: javascript
function onAccept(event, cookieGroups) {
const analyticsEnabled = cookieGroups.find(group => group.varname === 'analytics') != undefined;
if (analyticsEnabled) {
const template = document.getElementById('analytics-scripts').content;
const analyticsScripts = templateNode.content.cloneNode(true);
document.body.appendChild(analyticsScripts);
}
}
Passing this ``onAccept`` callback then adds the scripts after the user accepted the
cookies, causing them to execute. This way, there's no reliance on ``unsafe-eval``.

Considerations and design decisions made for the JS integration
===============================================================

We realize there is quite a bit of work to do to use this functionality. We've aimed for
a trade-off where the simple things are easy to do and the complex set-ups are
achievable.

The :ref:`showcookiebar_getting_started` section should be close to plug-and-play by
integrating well with Django's static files. Especially on modern browsers, we intend
to have a working solution without intricate Javascript knowledge.

For more advanced Javascript usage/developers, we expose hooks and options to tap into
the life-cycle. The code may also serve as a reference for your own implementation.

HttpOnly and CSRF
-----------------

The cookie-consent cookie itself can safely be set to ``HttpOnly`` so it cannot be
tampered with (or even read) from Javascript. This follows security best practices. The
new script no longer touches ``document.cookie``.

Accepting and declining cookies must be CSRF-protected and use ``POST`` requests. This
works out of the box with the async calls we make - the status endpoint provides the
CSRF token to the Javascript so that it can include this via an HTTP header.

This means that you can mark your CSRF cookies ``HttpOnly`` in Django.

Content Security Policy (CSP)
-----------------------------

Content Security Policies aim to lock down which scripts, styles... can run in the
browser. They are a good tool in helping prevent Cross-Site-Scripting attacks, by
specifying from which sources scripts are allowed to run and usually by blocking
``eval`` (which should be the bare minimum of what you block).

The new scripts play well with this - you can include your analytics scripts inside
``<template>`` nodes and inject them dynamically without resorting to ``eval``.
Additionally, they are held against the configured CSP. Including these in the template
also provide the option to set a ``nonce`` (e.g. when using django-csp).

For more advanced setups, it's even possible a nonce is injected by a reverse proxy -
with creative Javascript you can read this nonce (typically from a ``<meta>`` tag) and
included it in the scripts you add in the ``onAccept`` hook.

Page caches
-----------

You should now be able to use Django's page cache which caches the entire response for
a given URL. The new script fetches the user-specific cookie status via an async call
which bypasses the cache (or you configure it to ``Vary`` on the cookies).

Localization
------------

The template element approach allows you to use Django's built in translation machinery,
keeping your templates readable and properly HTML-escaped.

Hooks
-----

The ``onShow``, ``onAccept`` and ``onDecline`` hooks allow you to perform additional
actions on the main events. You can add your own markup and Javascript for more advanced
user experiences.

Integration with your Javascript stack
--------------------------------------

The source code is written in modern Javascript and you should be able to import the
module in Webpack-based builds (or similar). Likely the most challenging aspect is
getting the frontend-stack to pick up your files. Running ``manage.py collectstatic``
could help in ensuring that the source files are in a deterministic location, like
``<PROJECT_ROOT>/static/cookie_consent/cookiebar.module.js``.

Let us know how we can improve this though!

.. _Babel: https://babeljs.io/
3 changes: 3 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ without reloading page.
Asking users for cookie consent in templates
--------------------------------------------

.. warning:: The instructions below refer to the legacy integration. See
:ref:`javascript` for an updated approach.

``django-cookie-consent`` can show website visitors a cookie consent message. This
message informs users that the website uses cookies and requests their consent
to store them. The script responsible for displaying the message is
Expand Down
19 changes: 18 additions & 1 deletion testapp/templates/show-cookie-bar-script.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,29 @@
{% static "cookie_consent/cookiebar.module.js" as cookiebar_src %}
<script type="module">
import {showCookieBar} from '{{ cookiebar_src }}';

const showShareButton = () => {
const template = document.getElementById('show-share-button')
const showButtonScript = template.content.cloneNode(true);
document.body.appendChild(showButtonScript);
};

showCookieBar({
statusUrl: '{{ status_url|escapejs }}',
templateSelector: '#cookie-consent__cookie-bar',
cookieGroupsSelector: '#cookie-consent__cookie-groups',
onShow: () => document.querySelector('body').classList.add('with-cookie-bar'),
onAccept: () => document.querySelector('body').classList.remove('with-cookie-bar'),
onAccept: (event, cookieGroups) => {
document.querySelector('body').classList.remove('with-cookie-bar');
const hasSocial = cookieGroups.find(g => g.varname == 'social') !== undefined;
hasSocial && showShareButton();
},
onDecline: () => document.querySelector('body').classList.remove('with-cookie-bar'),
});
</script>

<template id="show-share-button">
<script type="text/javascript">
document.getElementById('share-button').style.display = 'block';
</script>
</template>
2 changes: 2 additions & 0 deletions testapp/templates/test_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ <h2>Social cookies</h2>
<button id="share-button" type="button" style="display:none">SHARE</button>
</p>

{# NOTE - this section is not compatible with django's full page cache #}
<h2>Optional cookies</h2>
{% if request|cookie_group_accepted:"optional" %}
<p>"optional" cookies accepted</p>
Expand All @@ -41,6 +42,7 @@ <h2>Optional cookies</h2>
{% if request|cookie_group_accepted:"foo=*:.foo.com" %}
<p>None existing cookies</p>
{% endif %}
{# END of section not compatible with page cache #}

<p>
<a href="{{ url_cookies }}">Cookies policy</a>
Expand Down

0 comments on commit 9900342

Please sign in to comment.