Skip to content

Commit fd628c5

Browse files
committed
Force an Asset Manager handshake on the draft stack
Each draft image on a page independently triggers its own Signon/Asset Manager OAuth handshake when there's no existing session. With more than one draft image, these handshakes race and clobber each other's CSRF state, so all but (at best) one image fail to load. In this commit, we load a single placeholder image from the draft-assets host, and once its request has settled - whether it succeeds or fails, we only care that the handshake has finished; we then retry every draft image already on the page with a cache-busting query param, so they get a fresh attempt against what should now be a warm session cookie. Added an `asset-manager-session.js` module that is inlined directly into the page via a `<script>` tag, and only on draft-stack pages, rather than shipped as part of the site's main JavaScript bundle. This is so that live pages are completely unaffected and don't load or run any of this code. It still follows GOV.UK's standard `data-module` convention, so it's found and started automatically like any other page module.
1 parent d566cfd commit fd628c5

4 files changed

Lines changed: 176 additions & 1 deletion

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* Warms up the Asset Manager draft-assets session cookie via a single
2+
* placeholder image request, then retries any draft images already on
3+
* the page once that request has settled (success or failure).
4+
*
5+
* Usage: add `data-module="AssetManagerSession"` and
6+
* `data-placeholder-asset-url="..."` to an element.
7+
*/
8+
9+
/* istanbul ignore next */
10+
window.GOVUK = window.GOVUK || {}
11+
/* istanbul ignore next */
12+
window.GOVUK.Modules = window.GOVUK.Modules || {};
13+
14+
(function (Modules) {
15+
function AssetManagerSession (element) {
16+
this.element = element
17+
}
18+
19+
AssetManagerSession.prototype.init = function () {
20+
window.addEventListener('load', this.warmUpSession.bind(this))
21+
}
22+
23+
AssetManagerSession.prototype.warmUpSession = function () {
24+
const draftAssets = this.draftAssetImages()
25+
// fewer than 2 images means there's no concurrent-request race to fix, so nothing to warm up
26+
if (draftAssets.length < 2) return
27+
28+
const reloadDraftImages = this.reloadDraftImages.bind(this, draftAssets)
29+
const placeholder = new Image()
30+
placeholder.onload = reloadDraftImages
31+
placeholder.onerror = reloadDraftImages
32+
placeholder.src = this.element.getAttribute('data-placeholder-asset-url')
33+
}
34+
35+
AssetManagerSession.prototype.draftAssetImages = function () {
36+
return [...document.images].filter((image) =>
37+
image.src.includes('assets.') // currently, asset urls in draft preview point to their live link: 'assets.xyz' instead of 'draft-assets.xyz'. Created a backlog item for this: https://gov-uk.atlassian.net/browse/WHIT-4008.
38+
)
39+
}
40+
41+
AssetManagerSession.prototype.reloadDraftImages = function (draftAssets) {
42+
draftAssets.forEach((image) => {
43+
// re-setting src tries to fetch the image again, with the hope that a prior success is just served from cache
44+
image.setAttribute('src', image.getAttribute('src'))
45+
})
46+
}
47+
48+
Modules.AssetManagerSession = AssetManagerSession
49+
})(window.GOVUK.Modules)

app/views/shared/_footer_navigation.html.erb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,24 @@
88
</div>
99
</div>
1010
<% end %>
11+
12+
<% if draft_host? %>
13+
<%
14+
host = if GovukEnvironment.current == "staging"
15+
"staging.publishing.service.gov.uk"
16+
elsif GovukEnvironment.current == "integration"
17+
"integration.publishing.service.gov.uk"
18+
else
19+
"publishing.service.gov.uk"
20+
end
21+
placeholder_asset_url = "https://draft-assets.#{host}/media/5e59279b86650c53b2cefbfe/placeholder.jpg"
22+
%>
23+
<div
24+
data-module="AssetManagerSession"
25+
data-placeholder-asset-url="<%= placeholder_asset_url %>"
26+
class="govuk-visually-hidden">
27+
</div>
28+
<%= javascript_tag nonce: true do %>
29+
<%= raw File.read(Rails.root.join("app/assets/javascripts/asset-manager-session.js")) %>
30+
<% end %>
31+
<% end %>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
describe('An asset manager session module', function () {
2+
let element, originalImage
3+
4+
const addImage = (src) => {
5+
const img = document.createElement('img')
6+
img.src = src
7+
document.body.appendChild(img)
8+
return img
9+
}
10+
11+
const trackReloads = (image) => {
12+
const reloadedUrls = []
13+
const originalSetAttribute = image.setAttribute.bind(image)
14+
image.setAttribute = (name, value) => {
15+
if (name === 'src') reloadedUrls.push(value)
16+
originalSetAttribute(name, value)
17+
}
18+
return reloadedUrls
19+
}
20+
21+
const stubImage = ({ succeeds }) => {
22+
class FakeImage {
23+
get src () {
24+
return this._src
25+
}
26+
27+
set src (value) {
28+
this._src = value
29+
succeeds
30+
? this.onload && this.onload()
31+
: this.onerror && this.onerror()
32+
}
33+
}
34+
window.Image = FakeImage
35+
}
36+
37+
beforeEach(function () {
38+
originalImage = window.Image
39+
element = document.createElement('div')
40+
element.setAttribute('data-module', 'AssetManagerSession')
41+
element.setAttribute(
42+
'data-placeholder-asset-url',
43+
'https://draft-assets.test.gov.uk/media/placeholder/placeholder.jpg'
44+
)
45+
})
46+
47+
afterEach(function () {
48+
window.Image = originalImage
49+
document.querySelectorAll('img').forEach((img) => img.remove())
50+
})
51+
52+
it('does nothing when fewer than two draft images are on the page', function () {
53+
addImage('https://draft-assets.test.gov.uk/media/1/one.jpg')
54+
stubImage({ succeeds: true })
55+
56+
new GOVUK.Modules.AssetManagerSession(element).warmUpSession()
57+
58+
expect(document.images[0].src).toEqual(
59+
'https://draft-assets.test.gov.uk/media/1/one.jpg'
60+
)
61+
})
62+
63+
it('retries all draft images once the placeholder request succeeds', function () {
64+
const first = addImage('https://draft-assets.test.gov.uk/media/1/one.jpg')
65+
const second = addImage(
66+
'https://draft-assets.test.gov.uk/media/2/two.jpg?foo=bar'
67+
)
68+
const firstReloads = trackReloads(first)
69+
const secondReloads = trackReloads(second)
70+
stubImage({ succeeds: true })
71+
72+
new GOVUK.Modules.AssetManagerSession(element).warmUpSession()
73+
74+
expect(firstReloads).toEqual([
75+
'https://draft-assets.test.gov.uk/media/1/one.jpg'
76+
])
77+
expect(secondReloads).toEqual([
78+
'https://draft-assets.test.gov.uk/media/2/two.jpg?foo=bar'
79+
])
80+
})
81+
82+
it('also retries all draft images when the placeholder request errors', function () {
83+
const first = addImage('https://draft-assets.test.gov.uk/media/1/one.jpg')
84+
addImage('https://draft-assets.test.gov.uk/media/2/two.jpg')
85+
const firstReloads = trackReloads(first)
86+
stubImage({ succeeds: false })
87+
88+
new GOVUK.Modules.AssetManagerSession(element).warmUpSession()
89+
90+
expect(firstReloads).toEqual([
91+
'https://draft-assets.test.gov.uk/media/1/one.jpg'
92+
])
93+
})
94+
95+
it('ignores non-asset-manager images when counting/retrying', function () {
96+
const other = addImage('https://static.test.gov.uk/media/1/one.jpg')
97+
addImage('https://draft-assets.test.gov.uk/media/2/two.jpg')
98+
stubImage({ succeeds: true })
99+
100+
new GOVUK.Modules.AssetManagerSession(element).warmUpSession()
101+
102+
expect(other.src).toEqual('https://static.test.gov.uk/media/1/one.jpg')
103+
})
104+
})

spec/support/jasmine-browser.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"srcFiles": [
44
"public/assets/frontend/test-dependencies-*.js",
55
"public/assets/frontend/dependencies-*.js",
6-
"tmp/nyc_output/assets/**/*.js"
6+
"tmp/nyc_output/assets/**/*.js",
7+
"app/assets/javascripts/asset-manager-session.js"
78
],
89
"specDir": "spec/javascripts",
910
"specFiles": [

0 commit comments

Comments
 (0)