From b71fc9028e2e2ee1b4b4f7fc069e91bb0883dce6 Mon Sep 17 00:00:00 2001
From: Oliver Tacke <o.tacke@posteo.de>
Date: Mon, 19 Jun 2023 14:10:17 +0200
Subject: [PATCH] Sanitize zoom sequence value

---
 src/js/timeline/Timeline.js | 51 +++++++++++++++++++++++++++++++++++++
 1 file changed, 51 insertions(+)

diff --git a/src/js/timeline/Timeline.js b/src/js/timeline/Timeline.js
index 576dce91..c1795870 100644
--- a/src/js/timeline/Timeline.js
+++ b/src/js/timeline/Timeline.js
@@ -426,6 +426,9 @@ class Timeline {
         // Set TimeNav Height
         this.options.timenav_height = this._calculateTimeNavHeight(this.options.timenav_height);
 
+        // Sanitize zoom sequence
+        this.options.zoom_sequence = this._sanitizeZoomSequence(this.options.zoom_sequence);
+
         // Create TimeNav
         this._timenav = new TimeNav(this._el.timenav, this.config, this.options, this.language);
         this._timenav.on('loaded', this._onTimeNavLoaded, this);
@@ -724,6 +727,51 @@ class Timeline {
         return height;
     }
 
+    /**
+     * Sanitize value of zoom sequence.
+     *     Should be an array with numeric values or respective string or
+     *     undefined if not set by author
+     * @param {number[]|string} [zoomSequence] - Values intended as possible zoom levels
+     * @returns {number[]} Sanitized array of possible zoom levels.
+     */
+    _sanitizeZoomSequence(zoomSequence) {
+        // Ensure string contains proper number array representation
+        if (typeof zoomSequence === 'string') {
+            // Forgive spaces in URL fragment
+            zoomSequence = decodeURI(zoomSequence).replace(/\s/g, '');
+
+            const validRegExp = /^\[([0-9]*[.])?[0-9]+(,([0-9]*[.])?[0-9]+)*\]$/;
+            if (!validRegExp.test(zoomSequence)) {
+                return Timeline.DEFAULT_ZOOM_SEQUENCE; // Not valid string representation of array with numbers
+            }
+
+            zoomSequence = zoomSequence
+                .substring(1, zoomSequence.length - 1)
+                .split(',');
+        }
+
+        if (!Array.isArray(zoomSequence)) {
+            return Timeline.DEFAULT_ZOOM_SEQUENCE; // Nothing that we can work with
+        }
+
+        // Remove invalid values and sort in ascending order
+        zoomSequence = zoomSequence.reduce((valid, value) => {
+            const numberValue = Number.parseFloat(value);
+            if (
+                Number.isNaN(numberValue) ||
+                numberValue < 0 || // Zoom factor must be greater than zero
+                valid.includes(numberValue) // Duplicate
+            ) {
+                return valid;
+            }
+
+            return [...valid, numberValue];
+        }, []);
+        zoomSequence.sort((a, b) => a - b);
+
+        return zoomSequence;
+    }
+
     _validateOptions() {
         // assumes that this.options and this.config have been set.
         var INTEGER_PROPERTIES = ['timenav_height', 'timenav_height_min', 'marker_height_min', 'marker_width_min', 'marker_padding', 'start_at_slide', 'slide_padding_lr'];
@@ -1023,6 +1071,9 @@ class Timeline {
 
 }
 
+/** @const {number[]} DEFAULT_ZOOM_SEQUENCE Array of Fibonacci numbers for TimeNav zoom levels */
+Timeline.DEFAULT_ZOOM_SEQUENCE = [0.5, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
+
 classMixin(Timeline, I18NMixins, Events)
 
 export { Timeline }