]> git.immae.eu Git - github/shaarli/Shaarli.git/blame - assets/default/js/base.js
Security: fix multiple XSS vulnerabilities + fix search tags with special chars
[github/shaarli/Shaarli.git] / assets / default / js / base.js
CommitLineData
a33c5653 1import Awesomplete from 'awesomplete';
b9b41d25 2
82e3bb5f
A
3/**
4 * Find a parent element according to its tag and its attributes
5 *
6 * @param element Element where to start the search
7 * @param tagName Expected parent tag name
8 * @param attributes Associative array of expected attributes (name=>value).
9 *
10 * @returns Found element or null.
11 */
a33c5653 12function findParent(element, tagName, attributes) {
9192a48b 13 const parentMatch = (key) => attributes[key] !== '' && element.getAttribute(key).indexOf(attributes[key]) !== -1;
a33c5653
A
14 while (element) {
15 if (element.tagName.toLowerCase() === tagName) {
16 if (Object.keys(attributes).find(parentMatch)) {
17 return element;
18 }
aa4797ba 19 }
a33c5653
A
20 element = element.parentElement;
21 }
22 return null;
aa4797ba
A
23}
24
82e3bb5f
A
25/**
26 * Ajax request to refresh the CSRF token.
27 */
cd10bc23 28function refreshToken(basePath, callback) {
a33c5653 29 const xhr = new XMLHttpRequest();
764d34a7 30 xhr.open('GET', `${basePath}/admin/token`);
a33c5653 31 xhr.onload = () => {
301c7ab1
A
32 const elements = document.querySelectorAll('input[name="token"]');
33 [...elements].forEach((element) => {
301c7ab1
A
34 element.setAttribute('value', xhr.responseText);
35 });
cd10bc23
A
36
37 if (callback) {
38 callback(xhr.response);
39 }
a33c5653
A
40 };
41 xhr.send();
42}
43
44function createAwesompleteInstance(element, tags = []) {
45 const awesome = new Awesomplete(Awesomplete.$(element));
46 // Tags are separated by a space
47 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]);
48 // Insert new selected tag in the input
49 awesome.replace = (text) => {
50 const before = awesome.input.value.match(/^.+ \s*|/)[0];
51 awesome.input.value = `${before}${text} `;
52 };
53 // Highlight found items
54 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]);
55 // Don't display already selected items
56 const reg = /(\w+) /g;
57 let match;
58 awesome.data = (item, input) => {
59 while ((match = reg.exec(input))) {
60 if (item === match[1]) {
61 return '';
62 }
63 }
64 return item;
65 };
66 awesome.minChars = 1;
67 if (tags.length) {
68 awesome.list = tags;
69 }
70
71 return awesome;
aa4797ba
A
72}
73
82e3bb5f
A
74/**
75 * Update awesomplete list of tag for all elements matching the given selector
76 *
77 * @param selector CSS selector
78 * @param tags Array of tags
79 * @param instances List of existing awesomplete instances
80 */
a33c5653
A
81function updateAwesompleteList(selector, tags, instances) {
82 if (instances.length === 0) {
82e3bb5f 83 // First load: create Awesomplete instances
a33c5653
A
84 const elements = document.querySelectorAll(selector);
85 [...elements].forEach((element) => {
86 instances.push(createAwesompleteInstance(element, tags));
87 });
88 } else {
89 // Update awesomplete tag list
90 instances.map((item) => {
91 item.list = tags;
92 return item;
93 });
94 }
95 return instances;
82e3bb5f
A
96}
97
aa4797ba
A
98/**
99 * html_entities in JS
100 *
101 * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
102 */
a33c5653 103function htmlEntities(str) {
9192a48b 104 return str.replace(/[\u00A0-\u9999<>&]/gim, (i) => `&#${i.charCodeAt(0)};`);
aa4797ba
A
105}
106
a0737313
A
107/**
108 * Add the class 'hidden' to city options not attached to the current selected continent.
109 *
110 * @param cities List of <option> elements
111 * @param currentContinent Current selected continent
112 * @param reset Set to true to reset the selected value
113 */
a33c5653
A
114function hideTimezoneCities(cities, currentContinent, reset = null) {
115 let first = true;
116 if (reset == null) {
117 reset = false;
118 }
119 [...cities].forEach((option) => {
120 if (option.getAttribute('data-continent') !== currentContinent) {
121 option.className = 'hidden';
122 } else {
123 option.className = '';
124 if (reset === true && first === true) {
125 option.setAttribute('selected', 'selected');
126 first = false;
127 }
aa4797ba 128 }
a33c5653
A
129 });
130}
131
132/**
133 * Retrieve an element up in the tree from its class name.
134 */
135function getParentByClass(el, className) {
136 const p = el.parentNode;
137 if (p == null || p.classList.contains(className)) {
138 return p;
139 }
140 return getParentByClass(p, className);
141}
142
143function toggleHorizontal() {
144 [...document.getElementById('shaarli-menu').querySelectorAll('.menu-transform')].forEach((el) => {
145 el.classList.toggle('pure-menu-horizontal');
146 });
147}
148
149function toggleMenu(menu) {
150 // set timeout so that the panel has a chance to roll up
151 // before the menu switches states
152 if (menu.classList.contains('open')) {
153 setTimeout(toggleHorizontal, 500);
154 } else {
155 toggleHorizontal();
156 }
157 menu.classList.toggle('open');
158 document.getElementById('menu-toggle').classList.toggle('x');
159}
160
161function closeMenu(menu) {
162 if (menu.classList.contains('open')) {
163 toggleMenu(menu);
164 }
165}
166
167function toggleFold(button, description, thumb) {
168 // Switch fold/expand - up = fold
169 if (button.classList.contains('fa-chevron-up')) {
170 button.title = document.getElementById('translation-expand').innerHTML;
171 if (description != null) {
172 description.style.display = 'none';
173 }
174 if (thumb != null) {
175 thumb.style.display = 'none';
176 }
177 } else {
178 button.title = document.getElementById('translation-fold').innerHTML;
179 if (description != null) {
180 description.style.display = 'block';
181 }
182 if (thumb != null) {
183 thumb.style.display = 'block';
184 }
185 }
186 button.classList.toggle('fa-chevron-down');
187 button.classList.toggle('fa-chevron-up');
188}
189
190function removeClass(element, classname) {
191 element.className = element.className.replace(new RegExp(`(?:^|\\s)${classname}(?:\\s|$)`), ' ');
192}
193
194function init(description) {
195 function resize() {
196 /* Fix jumpy resizing: https://stackoverflow.com/a/18262927/1484919 */
9192a48b
A
197 const scrollTop = window.pageYOffset
198 || (document.documentElement || document.body.parentNode || document.body).scrollTop;
a33c5653
A
199
200 description.style.height = 'auto';
201 description.style.height = `${description.scrollHeight + 10}px`;
202
203 window.scrollTo(0, scrollTop);
204 }
205
206 /* 0-timeout to get the already changed text */
207 function delayedResize() {
208 window.setTimeout(resize, 0);
209 }
210
211 const observe = (element, event, handler) => {
212 element.addEventListener(event, handler, false);
213 };
214 observe(description, 'change', resize);
215 observe(description, 'cut', delayedResize);
216 observe(description, 'paste', delayedResize);
217 observe(description, 'drop', delayedResize);
218 observe(description, 'keydown', delayedResize);
219
220 resize();
221}
222
223(() => {
818b3193
A
224 const basePath = document.querySelector('input[name="js_base_path"]').value;
225
a33c5653
A
226 /**
227 * Handle responsive menu.
228 * Source: http://purecss.io/layouts/tucked-menu-vertical/
229 */
230 const menu = document.getElementById('shaarli-menu');
231 const WINDOW_CHANGE_EVENT = ('onorientationchange' in window) ? 'orientationchange' : 'resize';
232
233 const menuToggle = document.getElementById('menu-toggle');
234 if (menuToggle != null) {
235 menuToggle.addEventListener('click', () => toggleMenu(menu));
236 }
237
238 window.addEventListener(WINDOW_CHANGE_EVENT, () => closeMenu(menu));
239
240 /**
241 * Fold/Expand shaares description and thumbnail.
242 */
243 const foldAllButtons = document.getElementsByClassName('fold-all');
244 const foldButtons = document.getElementsByClassName('fold-button');
245
246 [...foldButtons].forEach((foldButton) => {
247 // Retrieve description
248 let description = null;
249 let thumbnail = null;
250 const linklistItem = getParentByClass(foldButton, 'linklist-item');
251 if (linklistItem != null) {
252 description = linklistItem.querySelector('.linklist-item-description');
253 thumbnail = linklistItem.querySelector('.linklist-item-thumbnail');
254 if (description != null || thumbnail != null) {
255 foldButton.style.display = 'inline';
256 }
257 }
258
259 foldButton.addEventListener('click', (event) => {
260 event.preventDefault();
261 toggleFold(event.target, description, thumbnail);
262 });
263 });
264
265 if (foldAllButtons != null) {
266 [].forEach.call(foldAllButtons, (foldAllButton) => {
267 foldAllButton.addEventListener('click', (event) => {
268 event.preventDefault();
269 const state = foldAllButton.firstElementChild.getAttribute('class').indexOf('down') !== -1 ? 'down' : 'up';
270 [].forEach.call(foldButtons, (foldButton) => {
271 if ((foldButton.firstElementChild.classList.contains('fa-chevron-up') && state === 'down')
272 || (foldButton.firstElementChild.classList.contains('fa-chevron-down') && state === 'up')
273 ) {
274 return;
275 }
276 // Retrieve description
277 let description = null;
278 let thumbnail = null;
279 const linklistItem = getParentByClass(foldButton, 'linklist-item');
280 if (linklistItem != null) {
281 description = linklistItem.querySelector('.linklist-item-description');
282 thumbnail = linklistItem.querySelector('.linklist-item-thumbnail');
283 if (description != null || thumbnail != null) {
284 foldButton.style.display = 'inline';
285 }
286 }
287
288 toggleFold(foldButton.firstElementChild, description, thumbnail);
289 });
290 foldAllButton.firstElementChild.classList.toggle('fa-chevron-down');
291 foldAllButton.firstElementChild.classList.toggle('fa-chevron-up');
292 foldAllButton.title = state === 'down'
293 ? document.getElementById('translation-fold-all').innerHTML
294 : document.getElementById('translation-expand-all').innerHTML;
295 });
296 });
297 }
298
299 /**
300 * Confirmation message before deletion.
301 */
302 const deleteLinks = document.querySelectorAll('.confirm-delete');
303 [...deleteLinks].forEach((deleteLink) => {
304 deleteLink.addEventListener('click', (event) => {
305 if (!confirm(document.getElementById('translation-delete-link').innerHTML)) {
306 event.preventDefault();
307 }
308 });
309 });
310
311 /**
312 * Close alerts
313 */
314 const closeLinks = document.querySelectorAll('.pure-alert-close');
315 [...closeLinks].forEach((closeLink) => {
316 closeLink.addEventListener('click', (event) => {
317 const alert = getParentByClass(event.target, 'pure-alert-closable');
318 alert.style.display = 'none';
319 });
320 });
321
322 /**
323 * New version dismiss.
324 * Hide the message for one week using localStorage.
325 */
326 const newVersionDismiss = document.getElementById('new-version-dismiss');
327 const newVersionMessage = document.querySelector('.new-version-message');
328 if (newVersionMessage != null
329 && localStorage.getItem('newVersionDismiss') != null
330 && parseInt(localStorage.getItem('newVersionDismiss'), 10) + (7 * 24 * 60 * 60 * 1000) > (new Date()).getTime()
331 ) {
332 newVersionMessage.style.display = 'none';
333 }
334 if (newVersionDismiss != null) {
335 newVersionDismiss.addEventListener('click', () => {
336 localStorage.setItem('newVersionDismiss', (new Date()).getTime().toString());
337 });
338 }
339
340 const hiddenReturnurl = document.getElementsByName('returnurl');
341 if (hiddenReturnurl != null) {
342 hiddenReturnurl.value = window.location.href;
343 }
344
345 /**
346 * Autofocus text fields
347 */
348 const autofocusElements = document.querySelectorAll('.autofocus');
349 let breakLoop = false;
350 [].forEach.call(autofocusElements, (autofocusElement) => {
351 if (autofocusElement.value === '' && !breakLoop) {
352 autofocusElement.focus();
353 breakLoop = true;
354 }
355 });
356
357 /**
358 * Handle sub menus/forms
359 */
360 const openers = document.getElementsByClassName('subheader-opener');
361 if (openers != null) {
362 [...openers].forEach((opener) => {
363 opener.addEventListener('click', (event) => {
364 event.preventDefault();
365
366 const id = opener.getAttribute('data-open-id');
367 const sub = document.getElementById(id);
368
369 if (sub != null) {
370 [...document.getElementsByClassName('subheader-form')].forEach((element) => {
371 if (element !== sub) {
372 removeClass(element, 'open');
a0737313 373 }
a33c5653
A
374 });
375
376 sub.classList.toggle('open');
a0737313 377 }
a33c5653 378 });
a0737313 379 });
a33c5653
A
380 }
381
382 /**
383 * Remove CSS target padding (for fixed bar)
384 */
385 if (location.hash !== '') {
386 const anchor = document.getElementById(location.hash.substr(1));
387 if (anchor != null) {
388 const padsize = anchor.clientHeight;
389 window.scroll(0, window.scrollY - padsize);
390 anchor.style.paddingTop = '0';
391 }
392 }
393
394 /**
395 * Text area resizer
396 */
397 const description = document.getElementById('lf_description');
398
399 if (description != null) {
400 init(description);
401 // Submit editlink form with CTRL + Enter in the text area.
402 description.addEventListener('keydown', (event) => {
403 if (event.ctrlKey && event.keyCode === 13) {
404 document.getElementById('button-save-edit').click();
405 }
406 });
407 }
408
409 /**
410 * Bookmarklet alert
411 */
412 const bookmarkletLinks = document.querySelectorAll('.bookmarklet-link');
413 const bkmMessage = document.getElementById('bookmarklet-alert');
414 [].forEach.call(bookmarkletLinks, (link) => {
415 link.addEventListener('click', (event) => {
416 event.preventDefault();
417 alert(bkmMessage.value);
418 });
419 });
420
a33c5653
A
421 const continent = document.getElementById('continent');
422 const city = document.getElementById('city');
423 if (continent != null && city != null) {
424 continent.addEventListener('change', () => {
425 hideTimezoneCities(city, continent.options[continent.selectedIndex].value, true);
426 });
427 hideTimezoneCities(city, continent.options[continent.selectedIndex].value, false);
428 }
429
430 /**
431 * Bulk actions
432 */
fc574e64 433 const linkCheckboxes = document.querySelectorAll('.link-checkbox');
a33c5653
A
434 const bar = document.getElementById('actions');
435 [...linkCheckboxes].forEach((checkbox) => {
436 checkbox.style.display = 'inline-block';
fc574e64
A
437 checkbox.addEventListener('change', () => {
438 const linkCheckedCheckboxes = document.querySelectorAll('.link-checkbox:checked');
a33c5653
A
439 const count = [...linkCheckedCheckboxes].length;
440 if (count === 0 && bar.classList.contains('open')) {
441 bar.classList.toggle('open');
442 } else if (count > 0 && !bar.classList.contains('open')) {
443 bar.classList.toggle('open');
444 }
445 });
446 });
447
448 const deleteButton = document.getElementById('actions-delete');
449 const token = document.getElementById('token');
450 if (deleteButton != null && token != null) {
451 deleteButton.addEventListener('click', (event) => {
452 event.preventDefault();
453
454 const links = [];
fc574e64 455 const linkCheckedCheckboxes = document.querySelectorAll('.link-checkbox:checked');
a33c5653
A
456 [...linkCheckedCheckboxes].forEach((checkbox) => {
457 links.push({
458 id: checkbox.value,
459 title: document.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML,
460 });
461 });
462
463 let message = `Are you sure you want to delete ${links.length} links?\n`;
464 message += 'This action is IRREVERSIBLE!\n\nTitles:\n';
465 const ids = [];
466 links.forEach((item) => {
467 message += ` - ${item.title}\n`;
468 ids.push(item.id);
469 });
470
471 if (window.confirm(message)) {
9c75f877 472 window.location = `${basePath}/admin/shaare/delete?id=${ids.join('+')}&token=${token.value}`;
a33c5653
A
473 }
474 });
475 }
476
8d03f705
A
477 const changeVisibilityButtons = document.querySelectorAll('.actions-change-visibility');
478 if (changeVisibilityButtons != null && token != null) {
479 [...changeVisibilityButtons].forEach((button) => {
480 button.addEventListener('click', (event) => {
481 event.preventDefault();
482 const visibility = event.target.getAttribute('data-visibility');
483
484 const links = [];
485 const linkCheckedCheckboxes = document.querySelectorAll('.link-checkbox:checked');
486 [...linkCheckedCheckboxes].forEach((checkbox) => {
487 links.push({
488 id: checkbox.value,
489 title: document.querySelector(`.linklist-item[data-id="${checkbox.value}"] .linklist-link`).innerHTML,
490 });
491 });
492
9192a48b
A
493 const ids = links.map((item) => item.id);
494 window.location = (
495 `${basePath}/admin/shaare/visibility?token=${token.value}&newVisibility=${visibility}&id=${ids.join('+')}`
496 );
8d03f705
A
497 });
498 });
499 }
500
fc574e64
A
501 /**
502 * Select all button
503 */
504 const selectAllButtons = document.querySelectorAll('.select-all-button');
505 [...selectAllButtons].forEach((selectAllButton) => {
506 selectAllButton.addEventListener('click', (e) => {
507 e.preventDefault();
508 const checked = selectAllButton.classList.contains('filter-off');
509 [...selectAllButtons].forEach((selectAllButton2) => {
510 selectAllButton2.classList.toggle('filter-off');
511 selectAllButton2.classList.toggle('filter-on');
512 });
513 [...linkCheckboxes].forEach((linkCheckbox) => {
514 linkCheckbox.checked = checked;
515 linkCheckbox.dispatchEvent(new Event('change'));
516 });
517 });
518 });
519
a33c5653
A
520 /**
521 * Tag list operations
522 *
523 * TODO: support error code in the backend for AJAX requests
524 */
525 const tagList = document.querySelector('input[name="taglist"]');
526 let existingTags = tagList ? tagList.value.split(' ') : [];
527 let awesomepletes = [];
528
529 // Display/Hide rename form
530 const renameTagButtons = document.querySelectorAll('.rename-tag');
531 [...renameTagButtons].forEach((rename) => {
532 rename.addEventListener('click', (event) => {
533 event.preventDefault();
534 const block = findParent(event.target, 'div', { class: 'tag-list-item' });
535 const form = block.querySelector('.rename-tag-form');
536 if (form.style.display === 'none' || form.style.display === '') {
537 form.style.display = 'block';
538 } else {
539 form.style.display = 'none';
540 }
541 block.querySelector('input').focus();
542 });
543 });
544
545 // Rename a tag with an AJAX request
546 const renameTagSubmits = document.querySelectorAll('.validate-rename-tag');
547 [...renameTagSubmits].forEach((rename) => {
548 rename.addEventListener('click', (event) => {
549 event.preventDefault();
550 const block = findParent(event.target, 'div', { class: 'tag-list-item' });
551 const input = block.querySelector('.rename-tag-input');
552 const totag = input.value.replace('/"/g', '\\"');
553 if (totag.trim() === '') {
554 return;
555 }
556 const refreshedToken = document.getElementById('token').value;
557 const fromtag = block.getAttribute('data-tag');
72fbbcd6 558 const fromtagUrl = block.getAttribute('data-tag-url');
a33c5653 559 const xhr = new XMLHttpRequest();
9c75f877 560 xhr.open('POST', `${basePath}/admin/tags`);
a33c5653
A
561 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
562 xhr.onload = () => {
563 if (xhr.status !== 200) {
564 alert(`An error occurred. Return code: ${xhr.status}`);
565 location.reload();
566 } else {
567 block.setAttribute('data-tag', totag);
72fbbcd6 568 block.setAttribute('data-tag-url', encodeURIComponent(totag));
a33c5653
A
569 input.setAttribute('name', totag);
570 input.setAttribute('value', totag);
571 findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
572 block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
818b3193
A
573 block
574 .querySelector('a.tag-link')
575 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
72fbbcd6
A
576 block
577 .querySelector('a.count')
578 .setAttribute('href', `${basePath}/add-tag/${encodeURIComponent(totag)}`);
818b3193
A
579 block
580 .querySelector('a.rename-tag')
9c75f877 581 .setAttribute('href', `${basePath}/admin/tags?fromtag=${encodeURIComponent(totag)}`);
a33c5653
A
582
583 // Refresh awesomplete values
9192a48b 584 existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
a33c5653
A
585 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
586 }
587 };
72fbbcd6 588 xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
818b3193 589 refreshToken(basePath);
a33c5653
A
590 });
591 });
592
593 // Validate input with enter key
594 const renameTagInputs = document.querySelectorAll('.rename-tag-input');
595 [...renameTagInputs].forEach((rename) => {
596 rename.addEventListener('keypress', (event) => {
597 if (event.keyCode === 13) { // enter
598 findParent(event.target, 'div', { class: 'tag-list-item' }).querySelector('.validate-rename-tag').click();
599 }
600 });
601 });
602
603 // Delete a tag with an AJAX query (alert popup confirmation)
604 const deleteTagButtons = document.querySelectorAll('.delete-tag');
605 [...deleteTagButtons].forEach((rename) => {
606 rename.style.display = 'inline';
607 rename.addEventListener('click', (event) => {
608 event.preventDefault();
609 const block = findParent(event.target, 'div', { class: 'tag-list-item' });
610 const tag = block.getAttribute('data-tag');
72fbbcd6 611 const tagUrl = block.getAttribute('data-tag-url');
4fa9a3c5 612 const refreshedToken = document.getElementById('token').value;
a33c5653
A
613
614 if (confirm(`Are you sure you want to delete the tag "${tag}"?`)) {
615 const xhr = new XMLHttpRequest();
9c75f877 616 xhr.open('POST', `${basePath}/admin/tags`);
a33c5653
A
617 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
618 xhr.onload = () => {
619 block.remove();
620 };
72fbbcd6 621 xhr.send(`deletetag=1&fromtag=${tagUrl}&token=${refreshedToken}`);
818b3193 622 refreshToken(basePath);
a33c5653 623
9192a48b 624 existingTags = existingTags.filter((tagItem) => tagItem !== tag);
a33c5653
A
625 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
626 }
627 });
628 });
629
630 const autocompleteFields = document.querySelectorAll('input[data-multiple]');
631 [...autocompleteFields].forEach((autocompleteField) => {
632 awesomepletes.push(createAwesompleteInstance(autocompleteField));
633 });
cd10bc23
A
634
635 const exportForm = document.querySelector('#exportform');
636 if (exportForm != null) {
637 exportForm.addEventListener('submit', (event) => {
638 event.preventDefault();
639
640 refreshToken(basePath, () => {
641 event.target.submit();
642 });
643 });
644 }
a33c5653 645})();