Skip to content

Commit 97aca3e

Browse files
authored
Merge pull request #278 from torchbox/integration/light-mode
Light mode integration branch
2 parents 4af134d + 3bb6eba commit 97aca3e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+982
-110
lines changed

cloudflare/workers.js

Lines changed: 269 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,285 @@
1+
// NOTE: A 'Cache Level' page rule set to 'Cache Everything' will
2+
// prevent private cookie cache skipping from working, as it is
3+
// applied after this worker runs.
4+
5+
// When any cookie in this list is present in the request, cache will be skipped
6+
const PRIVATE_COOKIES = ['sessionid'];
7+
8+
// These querystring keys are stripped from the request as they are generally not
9+
// needed by the origin.
10+
const STRIP_QUERYSTRING_KEYS = [
11+
'utm_source',
12+
'utm_campaign',
13+
'utm_medium',
14+
'utm_term',
15+
'utm_content',
16+
'gclid',
17+
'fbclid',
18+
'dm_i', // DotDigital
19+
'msclkid',
20+
'al_applink_data', // Meta outbound app links
21+
22+
// https://docs.flying-press.com/cache/ignore-query-strings
23+
'age-verified',
24+
'ao_noptimize',
25+
'usqp',
26+
'cn-reloaded',
27+
'sscid',
28+
'ef_id',
29+
'_bta_tid',
30+
'_bta_c',
31+
'fb_action_ids',
32+
'fb_action_types',
33+
'fb_source',
34+
'_ga',
35+
'adid',
36+
'_gl',
37+
'gclsrc',
38+
'gdfms',
39+
'gdftrk',
40+
'gdffi',
41+
'_ke',
42+
'trk_contact',
43+
'trk_msg',
44+
'trk_module',
45+
'trk_sid',
46+
'mc_cid',
47+
'mc_eid',
48+
'mkwid',
49+
'pcrid',
50+
'mtm_source',
51+
'mtm_medium',
52+
'mtm_campaign',
53+
'mtm_keyword',
54+
'mtm_cid',
55+
'mtm_content',
56+
'epik',
57+
'pp',
58+
'pk_source',
59+
'pk_medium',
60+
'pk_campaign',
61+
'pk_keyword',
62+
'pk_cid',
63+
'pk_content',
64+
'redirect_log_mongo_id',
65+
'redirect_mongo_id',
66+
'sb_referer_host',
67+
];
68+
69+
// If this is true, the querystring keys stripped from the request will be
70+
// addeed to any Location header served by a redirect.
71+
const REPLACE_STRIPPED_QUERYSTRING_ON_REDIRECT_LOCATION = false;
72+
73+
// If this is true, querystring key are stripped if they have no value eg. ?foo
74+
// Disabled by default, but highly recommended
75+
const STRIP_VALUELESS_QUERYSTRING_KEYS = false;
76+
77+
// Only these status codes should be considered cacheable
78+
// (from https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4)
79+
const CACHABLE_HTTP_STATUS_CODES = [200, 203, 206, 300, 301, 410];
80+
181
addEventListener('fetch', (event) => {
282
event.respondWith(main(event));
383
});
484

585
async function main(event) {
6-
const newRequest = stripSessionCookie(event.request);
7-
return fetch(newRequest);
86+
const cache = caches.default;
87+
let request = event.request;
88+
let strippedParams;
89+
[request, strippedParams] = stripQuerystring(request);
90+
91+
if (!requestIsCachable(request)) {
92+
// If the request isn't cacheable, return a Response directly from the origin.
93+
return fetch(request);
94+
}
95+
96+
const cachingRequest = getCachingRequest(request);
97+
let response = await cache.match(cachingRequest);
98+
99+
if (!response) {
100+
// If we didn't get a response from the cache, fetch one from the origin
101+
// and put it in the cache.
102+
response = await fetch(request);
103+
if (responseIsCachable(response)) {
104+
event.waitUntil(cache.put(cachingRequest, response.clone()));
105+
}
106+
}
107+
108+
if (REPLACE_STRIPPED_QUERYSTRING_ON_REDIRECT_LOCATION) {
109+
response = replaceStrippedQsOnRedirectResponse(
110+
response,
111+
strippedParams,
112+
);
113+
}
114+
115+
return response;
8116
}
9117

10-
/**
11-
* Strip session cookies from the front-end.
12-
*
13-
* It's important that you disable this script from:
14-
* - /admin/*
15-
* - /review/*
16-
* - /contact/*
17-
*
18-
* Otherwise CSRF won't work.
19-
*
118+
/*
119+
* Cacheability Utilities
120+
*/
121+
function requestIsCachable(request) {
122+
/*
123+
* Given a Request, determine if it should be cached.
124+
* Currently the only factor here is whether a private cookie is present.
125+
*/
126+
return !hasPrivateCookie(request);
127+
}
128+
129+
function responseIsCachable(response) {
130+
/*
131+
* Given a Response, determine if it should be cached.
132+
* Currently the only factor here is whether the status code is cachable.
133+
*/
134+
return CACHABLE_HTTP_STATUS_CODES.includes(response.status);
135+
}
136+
137+
function getCachingRequest(request) {
138+
/**
139+
* When needed, modify a request for use as the cache key.
140+
*
141+
* Note: Modifications to this request are not sent upstream.
142+
*/
143+
const cookies = getCookies(request);
144+
145+
const requestURL = new URL(request.url);
146+
147+
// Cache based on the mode
148+
requestURL.searchParams.set(
149+
'cookie-torchbox-mode',
150+
cookies['torchbox-mode'] || 'dark',
151+
);
152+
153+
return new Request(requestURL, request);
154+
}
155+
156+
/*
157+
* Request Utilities
20158
*/
21-
function stripSessionCookie(request) {
22-
const newHeaders = new Headers(request.headers);
159+
function stripQuerystring(request) {
160+
/**
161+
* Given a Request, return a new Request with the ignored or blank querystring keys stripped out,
162+
* along with an object representing the stripped values.
163+
*/
23164
const url = new URL(request.url);
24-
const cookieString = newHeaders.get('Cookie');
25-
if (
26-
cookieString !== null &&
27-
(cookieString.includes('csrftoken') ||
28-
cookieString.includes('sessionid'))
29-
) {
30-
const newValue = stripCookie(
31-
stripCookie(newHeaders.get('Cookie'), 'sessionid'),
32-
'csrftoken',
33-
);
34-
newHeaders.set('Cookie', newValue);
35-
return new Request(request.url, {
36-
headers: newHeaders,
37-
method: request.method,
38-
body: request.body,
39-
redirect: request.redirect,
40-
});
165+
166+
const stripKeys = STRIP_QUERYSTRING_KEYS.filter((v) =>
167+
url.searchParams.has(v),
168+
);
169+
170+
let strippedParams = {};
171+
172+
if (stripKeys.length) {
173+
stripKeys.reduce((acc, key) => {
174+
acc[key] = url.searchParams.getAll(key);
175+
url.searchParams.delete(key);
176+
return acc;
177+
}, strippedParams);
178+
}
179+
180+
if (STRIP_VALUELESS_QUERYSTRING_KEYS) {
181+
// Strip query params without values to avoid unnecessary cache misses
182+
for (const [key, value] of url.searchParams.entries()) {
183+
if (!value) {
184+
url.searchParams.delete(key);
185+
strippedParams[key] = '';
186+
}
187+
}
188+
}
189+
190+
return [new Request(url, request), strippedParams];
191+
}
192+
193+
function hasPrivateCookie(request) {
194+
/*
195+
* Given a Request, determine if one of the 'private' cookies are present.
196+
*/
197+
const cookieHeader = request.headers.get('Cookie');
198+
if (!cookieHeader) {
199+
return false;
200+
}
201+
202+
const allCookies = getCookies(request);
203+
204+
// Check if any of the private cookies are present and have a non-empty value
205+
for (const cookieName of PRIVATE_COOKIES) {
206+
if (cookieName in allCookies && allCookies[cookieName]) {
207+
return true;
208+
}
209+
}
210+
return false;
211+
}
212+
213+
function getCookies(request) {
214+
/*
215+
* Extract the cookies from a given request
216+
*/
217+
const cookieHeader = request.headers.get('Cookie');
218+
if (!cookieHeader) {
219+
return {};
220+
}
221+
222+
return cookieHeader.split(';').reduce((cookieMap, cookieString) => {
223+
const [cookieKey, cookieValue] = cookieString.split('=');
224+
return { ...cookieMap, [cookieKey.trim()]: cookieValue.trim() };
225+
}, {});
226+
}
227+
228+
/**
229+
* Response Utilities
230+
*/
231+
232+
function replaceStrippedQsOnRedirectResponse(response, strippedParams) {
233+
/**
234+
* Given an existing Response, and an object of stripped querystring keys,
235+
* determine if the response is a redirect.
236+
* If it is, add the stripped querystrings to the location header.
237+
* This allows us to persist tracking querystrings (like UTM) over redirects.
238+
*/
239+
response = new Response(response.body, response);
240+
241+
if ([301, 302].includes(response.status)) {
242+
const locationHeaderValue = response.headers.get('location');
243+
let locationUrl;
244+
245+
if (!locationHeaderValue) {
246+
return response;
247+
}
248+
249+
const isAbsolute = isUrlAbsolute(locationHeaderValue);
250+
251+
if (!isAbsolute) {
252+
// If the Location URL isn't absolute, we need to provide a Host so we can use
253+
// a URL object.
254+
locationUrl = new URL(
255+
locationHeaderValue,
256+
'http://www.example.com',
257+
);
258+
} else {
259+
locationUrl = new URL(locationHeaderValue);
260+
}
261+
262+
for (const [key, value] of Object.entries(strippedParams)) {
263+
locationUrl.searchParams.append(key, value);
264+
}
265+
266+
let newLocation;
267+
268+
if (isAbsolute) {
269+
newLocation = locationUrl.toString();
270+
} else {
271+
newLocation = `${locationUrl.pathname}${locationUrl.search}`;
272+
}
273+
274+
response.headers.set('location', newLocation);
41275
}
42276

43-
return request;
277+
return response;
44278
}
45279

46280
/**
47-
* Strip a cookie from the cookie string and return a new cookie string.
281+
* URL Utilities
48282
*/
49-
function stripCookie(cookiesString, cookieName) {
50-
return cookiesString
51-
.split(';')
52-
.filter((v) => {
53-
return v.split('=')[0].trim() !== cookieName;
54-
})
55-
.join(';');
283+
function isUrlAbsolute(url) {
284+
return url.indexOf('://') > 0 || url.indexOf('//') === 0;
56285
}

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ services:
4040
- ./manage.py:/app/manage.py:rw
4141
- ./pyproject.toml:/app/pyproject.toml:rw
4242
- ./poetry.lock:/app/poetry.lock:rw
43+
- ./cloudflare:/app/cloudflare:rw
4344

4445
# Frontend config
4546
- ./.babelrc.js:/app/.babelrc.js:rw

docs/front-end/themes_and_modes.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,20 @@ There are currently 4 themes in use: coral, lagoon, banana and earth. The CSS to
1616

1717
The drop-caps svgs use a semi-transparent version of the accent colours, and the contrast of the resulting colour also needs to be checked. The [colour contrast checker](https://chromewebstore.google.com/detail/colour-contrast-checker/nmmjeclfkgjdomacpcflgdkgpphpmnfe?hl=en-GB&utm_source=ext_sidebar) is a useful chrome extension to assist with this.
1818

19+
## Defaults
20+
21+
The site will show in dark mode by default, and if a theme is not selected for a page or any parent pages, then it will display with the coral theme.
22+
1923
## CSS Variables
2024

2125
All colours should be set using CSS variables, and these are updated according to the theme or mode in use on any given page.
2226

23-
## Defaults
27+
The colours are named to match the figma colours. There is a section of the figma file that is only visible to editors which defines all the colours, and sets out a grid of the different colours used in different themes, which are variables file follows when setting up the themes. For reference these are in the screenshots below.
2428

25-
The site will show in dark mode by default, and if a theme is not selected for a page or any parent pages, then it will display with the coral theme.
29+
![Colour definitions](/images/colour-definitions.png)
30+
31+
<figcaption>Colour definitions</figcaption>
32+
33+
![Colour grid](/images/colour-grid.png)
34+
35+
<figcaption>Colour grid</figcaption>

docs/images/colour-definitions.png

62.9 KB
Loading

docs/images/colour-grid.png

64.1 KB
Loading

tailwind.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ module.exports = {
2020
inherit: 'inherit',
2121
current: 'currentColor',
2222
transparent: 'transparent',
23+
background: 'var(--color--background)',
24+
heading: 'var(--color--heading)',
2325
},
2426
screens: {
2527
sm: '410px',

tbx/core/context_processors.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
import json
2+
13
from django.conf import settings
24

35
from tbx.core.models import ImportantPageSettings
46

57

68
def global_vars(request):
9+
10+
# Read the mode cookie to determine the user's saved preference for light or dark mode, if it exists
11+
# Ensure it is one of the allowed values
12+
mode = request.COOKIES.get("torchbox-mode", "dark")
13+
if mode not in settings.ALLOWED_MODES:
14+
mode = "dark"
15+
716
return {
817
"GOOGLE_TAG_MANAGER_ID": getattr(settings, "GOOGLE_TAG_MANAGER_ID", None),
918
"SEO_NOINDEX": settings.SEO_NOINDEX,
@@ -13,4 +22,7 @@ def global_vars(request):
1322
"CARBON_EMISSIONS_PAGE": ImportantPageSettings.for_request(
1423
request
1524
).carbon_emissions_page,
25+
"ALLOWED_MODES": json.dumps(settings.ALLOWED_MODES),
26+
"MODE": mode,
27+
"BASE_DOMAIN": settings.BASE_DOMAIN,
1628
}

tbx/core/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class UnauthorizedHTTPError(Exception):
2+
"""Raised when the user is not authorized to access a resource and the request accepts HTML."""
3+
4+
pass

0 commit comments

Comments
 (0)