Skip to content

Commit 62c238e

Browse files
committed
Add valid hash to versioned url
Closes gh-282
1 parent 826454a commit 62c238e

File tree

2 files changed

+208
-10
lines changed

2 files changed

+208
-10
lines changed

src/js/08-copy-versioned-url.js

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,31 @@
1616
;(function () {
1717
'use strict'
1818

19-
var copyUrl = document.getElementById('copy-url')
20-
if (!copyUrl) return
19+
activateCopyUrl(document.getElementById('copy-url'))
2120

22-
copyUrl.addEventListener('click', function (event) {
23-
var versionedUrl = document.querySelector('meta[name="versioned-url"]')?.content
24-
window.navigator.clipboard.writeText(versionedUrl)
25-
this.classList.add('copied')
26-
setTimeout(() => {
27-
this.classList.remove('copied')
28-
}, 1500)
29-
})
21+
function activateCopyUrl (copyUrl) {
22+
if (!copyUrl) return
23+
24+
copyUrl.addEventListener('click', function (event) {
25+
const hash = _hash(window)
26+
const versionedUrl = document.querySelector('meta[name="versioned-url"]')?.content + hash
27+
window.navigator.clipboard.writeText(versionedUrl)
28+
this.classList.add('copied')
29+
setTimeout(() => {
30+
this.classList.remove('copied')
31+
}, 1500)
32+
})
33+
}
34+
35+
function _hash (window) {
36+
const hash = window.location.hash
37+
return isValidHash(hash) ? hash : ''
38+
}
39+
40+
// ensure malicious user cannot inject code via URL hash
41+
function isValidHash (hash) {
42+
if (!hash || typeof hash !== 'string') return false
43+
const isHashRegex = /^#[-.\w]+$/
44+
return isHashRegex.test(hash)
45+
}
3046
})()

test/08-copy-versioned-url-test.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/* eslint-env mocha */
2+
'use strict'
3+
4+
const { expect } = require('./harness')
5+
6+
describe('08-copy-versioned-url', () => {
7+
const run = function () {
8+
const module = '../src/js/08-copy-versioned-url.js'
9+
delete require.cache[require.resolve(module)]
10+
require(module)
11+
}
12+
13+
let versionedUrl
14+
let button
15+
let timeout
16+
let clipboard
17+
let meta
18+
let document
19+
let window
20+
21+
beforeEach(async () => {
22+
versionedUrl = 'https://docs.spring.io/spring-security/reference/index.html'
23+
button = {
24+
classes: [],
25+
click: function () {
26+
console.log('hi')
27+
},
28+
addEventListener: function (event, callback) {
29+
button.click = function () {
30+
callback.call(this)
31+
}
32+
},
33+
classList: {
34+
add: function (cls) {
35+
button.classes.push(cls)
36+
},
37+
remove: function (cls) {
38+
const index = button.classes.indexOf(cls)
39+
if (index > -1) {
40+
button.classes.splice(index, 1)
41+
}
42+
},
43+
},
44+
}
45+
timeout = {
46+
invocations: [],
47+
setTimeout: function (callback, time) {
48+
timeout.invocations.push({ callback: callback, time: time })
49+
},
50+
run: function () {
51+
timeout.invocations.forEach((i) => {
52+
i.callback()
53+
})
54+
timeout.invocations = []
55+
},
56+
}
57+
clipboard = {
58+
content: '',
59+
writeText: function (text) {
60+
clipboard.content = text
61+
},
62+
}
63+
meta = {
64+
content: versionedUrl,
65+
}
66+
document = {
67+
getElementById: function (id) {
68+
button.id = id
69+
return button
70+
},
71+
querySelector: function (expression) {
72+
meta.expression = expression
73+
return meta
74+
},
75+
}
76+
window = {
77+
location: {
78+
hash: undefined,
79+
},
80+
navigator: {
81+
clipboard: clipboard,
82+
},
83+
}
84+
global.setTimeout = timeout.setTimeout
85+
global.document = document
86+
global.window = window
87+
})
88+
89+
afterEach(async () => {
90+
delete global.setTimeout
91+
delete global.document
92+
delete global.window
93+
})
94+
95+
it('button undefined', async () => {
96+
document.getElementById = function (id) {}
97+
run()
98+
// no errors (skips setup)
99+
})
100+
101+
it('button id is copy-url', async () => {
102+
run()
103+
expect(button.id).eqls('copy-url')
104+
})
105+
106+
it('meta expression is meta[name="versioned-url"]', async () => {
107+
run()
108+
button.click()
109+
expect(meta.expression).eqls('meta[name="versioned-url"]')
110+
})
111+
112+
it('versioned-url expression undefined]', async () => {
113+
run()
114+
document.querySelector = function (q) {}
115+
button.click()
116+
})
117+
118+
it('click button adds copied class & timeout clears it', async () => {
119+
run()
120+
expect(button.classes).eqls([])
121+
button.click()
122+
expect(button.classes).eqls(['copied'])
123+
expect(timeout.invocations[0].time).eqls(1500)
124+
timeout.run()
125+
expect(button.classes).eqls([])
126+
})
127+
128+
it('hash is undefined', async () => {
129+
run()
130+
button.click()
131+
expect(clipboard.content).eqls(versionedUrl)
132+
})
133+
134+
it('hash is simple', async () => {
135+
const hash = '#welcome'
136+
window.location.hash = hash
137+
run()
138+
button.click()
139+
expect(clipboard.content).eqls(versionedUrl + hash)
140+
})
141+
142+
// spring boot does this
143+
it('hash contains .', async () => {
144+
const hash = '#topic.subtopic'
145+
window.location.hash = hash
146+
run()
147+
button.click()
148+
expect(clipboard.content).eqls(versionedUrl + hash)
149+
})
150+
151+
it('hash contains -', async () => {
152+
const hash = '#topic-subtopic'
153+
window.location.hash = hash
154+
run()
155+
button.click()
156+
expect(clipboard.content).eqls(versionedUrl + hash)
157+
})
158+
159+
it('hash contains _', async () => {
160+
const hash = '#topic_subtopic'
161+
window.location.hash = hash
162+
run()
163+
button.click()
164+
expect(clipboard.content).eqls(versionedUrl + hash)
165+
})
166+
167+
it('hash contains number', async () => {
168+
const hash = '#topic1_subtopic2'
169+
window.location.hash = hash
170+
run()
171+
button.click()
172+
expect(clipboard.content).eqls(versionedUrl + hash)
173+
})
174+
175+
it('hash contains invalid', async () => {
176+
const hash = '#topic<script'
177+
window.location.hash = hash
178+
run()
179+
button.click()
180+
expect(clipboard.content).eqls(versionedUrl)
181+
})
182+
})

0 commit comments

Comments
 (0)