PageSourceSearch

https://tieto.traficom.fi/cookie/src/cookieHandler.js

js traficom.fi collected 2026-10-02 02:21:41 UTC 17,529 bytes, 528 lines download raw bytes

1/* eslint-disable lines-between-class-members */
2/* eslint-disable class-methods-use-this */
3
4import { parse, serialize } from './cookie.js'
5
6export default class CookieHandler {
7  #COOKIE_DAYS = 365
8  #shadowDomUpdateCallback
9  #siteSettings
10  #cookieName = 'consent_privacySettings' // Overridable default value
11  #formReference
12  #updatingDom = false
13
14  /**
15   * Represents a CookieHandler object.
16   * @constructor
17   * @param {Object} options - The options for the CookieHandler.
18   * @param {string} options.siteSettingsJsonUrl - Path to JSON file with site settings.
19   * @param {Object} options.siteSettingsObj - Site settings object.
20   * @param {Function} options.shadowDomUpdateCallback - Callback function to update shadow DOM.
21   * @param {Object} options.backReference - Reference to the back reference object.
22   */
23  constructor({
24    siteSettingsObj, // Site settings object
25    shadowDomUpdateCallback, // Callback function to update shadow DOM checkboxes
26  }) {
27    this.#siteSettings = siteSettingsObj
28    this.#shadowDomUpdateCallback = (consentedGroupNames) => {
29      shadowDomUpdateCallback(consentedGroupNames, this.#formReference)
30    }
31    this.#cookieName = this.#siteSettings.cookieName || this.#cookieName // Optional override for cookie name
32    this.#verifySiteSettings()
33  }
34
35  /**
36   * Sets the form reference for the cookie handler.
37   *
38   * @param {Object} formReference - The reference to the form.
39   */
40  setFormReference(formReference) {
41    this.#formReference = formReference
42  }
43
44  /**
45   * Get the consent status for the specified cookie group names.
46   * @param {string[]} groupNamesArray - An array of group names.
47   * @return {Promise<boolean>} A promise that resolves to true if all the groups are accepted, otherwise false.
48   */
49  getConsentStatus(groupNamesArray) {
50    // Check if group names are provided as an array and not empty
51    if (!Array.isArray(groupNamesArray) || groupNamesArray.length === 0) {
52      // eslint-disable-next-line no-console
53      console.error(
54        'Cookie consent: Group names must be provided as an non-empty array.'
55      )
56      return false
57    }
58
59    const browserCookieState = this.getCookie()
60
61    // Check if our cookie exists and has groups set
62    if (!browserCookieState || !browserCookieState.groups) {
63      // console.log('Cookie is not set properly', browserCookieState, browserCookieState.groups);
64      return false
65    }
66
67    // Check if all groups are in accepted groups
68    return groupNamesArray.every(
69      (groupName) => !!browserCookieState.groups[groupName]
70    )
71  }
72
73  /**
74   * Sets the status of given cookie groups to accepted.
75   *
76   * @param {Array} acceptedGroupsArray - An array of cookie group names to be set as accepted.
77   * @return {Promise<boolean>} - A promise that resolves to true if the groups' status is successfully set to accepted, otherwise false.
78   */
79  async setGroupsStatusToAccepted(acceptedGroupsArray) {
80    if (!Array.isArray(acceptedGroupsArray)) {
81      console.error(
82        'Cookie consent: Accepted groups must be provided as an array.'
83      )
84      return false
85    }
86
87    const browserCookie = this.getCookie() || { groups: {} }
88
89    const siteSettingsGroups = [
90      ...this.#siteSettings.requiredGroups,
91      ...this.#siteSettings.optionalGroups,
92    ]
93
94    // Calculate checksums (unchanged behavior)
95    await Promise.all(
96      siteSettingsGroups.map(async (group) => {
97        group.checksum = await this.#getChecksum(group)
98      })
99    )
100
101    // Validate accepted groups
102    const groupsWhitelistedForApi =
103      this.#siteSettings.groupsWhitelistedForApi || []
104
105    const notWhitelistedGroups = acceptedGroupsArray.filter(
106      (group) => !groupsWhitelistedForApi.includes(group)
107    )
108    if (notWhitelistedGroups.length > 0) {
109      console.error(
110        `Cookie consent: The group(s) "${notWhitelistedGroups.join(
111          ', '
112        )}" are not whitelisted.`
113      )
114      return false
115    }
116
117    const notFoundGroups = acceptedGroupsArray.filter(
118      (group) =>
119        !siteSettingsGroups.some((siteGroup) => siteGroup.groupId === group)
120    )
121    if (notFoundGroups.length > 0) {
122      console.error(
123        `Cookie consent: The group(s) "${notFoundGroups.join(
124          ', '
125        )}" not found in site settings.`
126      )
127      return false
128    }
129
130    // Merge accepted groups
131    const currentlyAccepted = Object.keys(browserCookie.groups || {})
132    const mergedGroups = [
133      ...new Set([...currentlyAccepted, ...acceptedGroupsArray]),
134    ]
135
136    // Persist consent — banner MUST be hidden after explicit consent
137    this.saveConsentedGroups(mergedGroups, false)
138
139    return true
140  }
141
142  /**
143   * Retrieves the status of the component.
144   * @return {Promise<{ cookie: string, monitor: string }>} The status object containing the cookie and monitor status.
145   */
146  async getStatus() {
147    let cookie = 'unset'
148
149    const browserCookie = await this.getCookie()
150    if (browserCookie) {
151      cookie = browserCookie
152    }
153
154    return cookie
155  }
156
157  /**
158   * Retrieves and parses the cookie consent cookie.
159   * @private
160   * @param {string} [cookieName] - The name of the cookie to be parsed.
161   * @return {Object|boolean} The parsed cookie object, or false if the cookie is not set or parsing is unsuccessful.
162   */
163  getCookie(cookieName = undefined) {
164    try {
165      let cookieNameValue = cookieName
166      if (this && this.#cookieName && !cookieName) {
167        cookieNameValue = this.#cookieName
168      } else if (!cookieName) {
169        // `this` is not set, and cookieName is not provided
170        return false
171      }
172      const cookieString = parse(document.cookie)[cookieNameValue]
173      if (!cookieString) {
174        //console.error('Cookie is not set');
175        return false
176      }
177      return JSON.parse(cookieString)
178    } catch (err) {
179      // eslint-disable-next-line no-console
180      console.error(`Cookie parsing unsuccessful:\n${err}`)
181      return false
182    }
183  }
184
185  getAllKeysInConsentedGroups(consentedGroupNames = null) {
186    let groupNames = consentedGroupNames
187    if (!groupNames) {
188      groupNames = this.getConsentedGroupNames()
189    }
190    const consentedKeys = {}
191    consentedKeys.cookie = this.#getKeysInConsentedGroups(groupNames, 1)
192
193    // Make sure that consentedKeys.cookie array of strings contains this.#cookieName string as one item
194    if (!consentedKeys.cookie.includes(this.#cookieName)) {
195      consentedKeys.cookie.push(this.#cookieName)
196    }
197
198    consentedKeys.localStorage = this.#getKeysInConsentedGroups(groupNames, 2)
199    consentedKeys.sessionStorage = this.#getKeysInConsentedGroups(groupNames, 3)
200    consentedKeys.indexedDB = this.#getKeysInConsentedGroups(groupNames, 4)
201    consentedKeys.cacheStorage = this.#getKeysInConsentedGroups(groupNames, 5)
202    return consentedKeys
203  }
204
205  /**
206   * Saves the consented cookie groups (and required groups) to cookie, unsets others.
207   * @private
208   * @param {Array} consentedGroupNames - The names of the consented cookie groups.
209   * @param {boolean} showBanner - Whether to show the banner or not.
210   */
211  saveConsentedGroups(consentedGroupNames = [], showBanner = false) {
212    const consentedGroups = {}
213    const consentedGroupAndRequiredGroupNames = [
214      ...this.getRequiredGroupNames(),
215      ...consentedGroupNames,
216    ]
217
218    // Find all groups and set current timestamp
219    const allGroups = [
220      ...this.#siteSettings.requiredGroups,
221      ...this.#siteSettings.optionalGroups,
222    ]
223    const timestamp = new Date().toISOString() // Get the current timestamp
224
225    allGroups.forEach((group) => {
226      if (consentedGroupAndRequiredGroupNames.includes(group.groupId)) {
227        consentedGroups[group.groupId] = {
228          //checksum: group.checksum,
229          acceptedAt: timestamp, // Add the timestamp to the group
230        }
231      }
232    })
233
234    const data = {
235      groups: consentedGroups,
236      ...(showBanner && { showBanner: true }), // Only add showBanner if it's true
237    }
238
239    this.#setCookie(data)
240
241    // Update shadow dom checkbox status
242    if (!this.#updatingDom) {
243      this.#updatingDom = true
244      try {
245        this.#shadowDomUpdateCallback(consentedGroupNames)
246      } finally {
247        this.#updatingDom = false
248      }
249    }
250  }
251
252  /**
253   * Removes before saving the cookies and stored items that have consent withdrawn.
254   *
255   * @param {Array<string>} consentedGroupNames - The names of the consented groups.
256   * @param {object} monitorReference - The reference to the monitor object.
257   * @return {void}
258   */
259  removeConsentWithdrawnCookiesBeforeSave(
260    consentedGroupNames,
261    monitorReference
262  ) {
263    const consentedKeysArray =
264      this.getAllKeysInConsentedGroups(consentedGroupNames)
265    const reason = 'consent withdrawn'
266    monitorReference.BROWSER_STORAGES.forEach(async (storageType) => {
267      const currentStoredKeysArray =
268        await monitorReference.getCurrentKeys(storageType)
269      monitorReference.deleteKeys(
270        storageType,
271        consentedKeysArray[storageType],
272        currentStoredKeysArray,
273        reason
274      )
275    })
276  }
277
278  /**
279   * Returns an array of required cookie group names.
280   *
281   * @return {string[]} An array of required cookie group names.
282   */
283  getRequiredGroupNames() {
284    return this.#siteSettings.requiredGroups.map((group) => group.groupId)
285  }
286
287  /**
288   * Get checksum from string
289   * @private
290   * @param {string} message - The string to be hashed.
291   * @param {number} [length=8] - The length of the hash (default is 8).
292   * @return {string} - The hash in base16 from the string.
293   *
294   * Reference: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
295   */
296  async #getChecksum(message, length = 8) {
297    let messageString = message
298    if (typeof message !== 'string') {
299      messageString = JSON.stringify(message)
300    }
301    const msgUint8 = new TextEncoder().encode(messageString) // encode as (utf-8) Uint8Array
302    const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) // hash the message using SHA-256
303    const hashArray = Array.from(new Uint8Array(hashBuffer)) // convert buffer to byte array
304    const hashHex = hashArray
305      .map((b) => b.toString(16).padStart(2, '0'))
306      .join('') // convert bytes to hex string
307    // Return only length number of hash
308    return hashHex.substring(0, length)
309  }
310
311  getConsentedGroupNames() {
312    let consentedGroups = []
313
314    const browserCookie = this.getCookie()
315    if (browserCookie && browserCookie.groups) {
316      consentedGroups = Object.keys(browserCookie.groups)
317    }
318
319    // Ensure that required groups are in consented groups for monitoring
320    const requiredGroups = this.getRequiredGroupNames()
321    consentedGroups = [...new Set([...requiredGroups, ...consentedGroups])]
322
323    return consentedGroups
324  }
325
326  /**
327   * Sets a cookie with the provided data.
328   * @private
329   * @param {Object} cookieData - The data to be stored in the cookie.
330   */
331  #setCookie(cookieData) {
332    const expiryDate = new Date()
333    expiryDate.setDate(expiryDate.getDate() + this.#COOKIE_DAYS)
334    document.cookie = serialize(this.#cookieName, JSON.stringify(cookieData), {
335      sameSite: 'lax',
336      expires: expiryDate,
337      path: '/',
338      domain: this.#getCookieDomain(),
339    })
340  }
341
342  /**
343   * Returns the cookie domain so the consent cookie is shared between
344   * www and non-www variants (e.g. www.traficom.fi and traficom.fi).
345   * Other subdomains (e.g. tieto.traficom.fi) are separate sites and
346   * keep their own consent — we only strip the www. prefix.
347   *
348   * note: this hack is only necessary since we have the www and non-www
349   * site as separate. if we decide to redirect to only one, we won't need
350   * this anymore and this code can be removed.
351   *
352   * @private
353   * @return {string|undefined} The domain attribute value, or undefined
354   *   for localhost, IP addresses, and non-www hostnames.
355   */
356  #getCookieDomain() {
357    const hostname = window.location.hostname
358    // Skip domain attribute for localhost and IP addresses
359    if (hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
360      return undefined
361    }
362    // Only strip "www." so the cookie is shared between www and non-www.
363    // Other subdomains are separate sites and should manage their own consent.
364    if (hostname.startsWith('www.')) {
365      return hostname.slice(4)
366    }
367    // No www prefix — omit domain attribute so cookie stays on this exact host
368    return undefined
369  }
370
371  /**
372   * Retrieves the keys in consented groups based on the provided parameters.
373   * @private
374   * @param {Array} consentedGroupNames - An array of consented group names.
375   * @param {string} type - The type of cookies to filter.
376   * @return {Array} - An array of consented cookie keys.
377   */
378  #getKeysInConsentedGroups(consentedGroupNames, type) {
379    const consentedKeys = new Set()
380
381    // Add relevant robotCookies to accepted cookies
382    this.#siteSettings.robotCookies?.forEach((cookie) => {
383      if (cookie.type === type) {
384        consentedKeys.add(cookie.name)
385      }
386    })
387
388    const allGroups = [
389      ...this.#siteSettings.requiredGroups,
390      ...this.#siteSettings.optionalGroups,
391    ]
392    allGroups.forEach((group) => {
393      if (consentedGroupNames.includes(group.groupId)) {
394        group.cookies.forEach((cookie) => {
395          if (cookie.type === type) {
396            consentedKeys.add(cookie.name)
397          }
398        })
399      }
400    })
401    return Array.from(consentedKeys)
402  }
403
404  /**
405   * Removes invalid groups from the cookie based on the browser cookie state and site settings.
406   *
407   * @private
408   */
409  async #removeInvalidGroupsFromCookie() {
410    const browserCookieState = this.getCookie()
411    const siteSettingsGroups = [
412      ...this.#siteSettings.requiredGroups,
413      ...this.#siteSettings.optionalGroups,
414    ]
415
416    // Checksums for all groups calculated in parallel without waiting for each
417    await Promise.all(
418      siteSettingsGroups.map(async (group) => {
419        // eslint-disable-next-line no-param-reassign
420        group.checksum = await this.#getChecksum(group) // This await is needed to ensure that all checksums are calculated before continuing
421      })
422    )
423
424    let invalidGroupsFound = false
425    const newCookieGroups = []
426
427    // Loop through all groups in site settings and store each groups name and checksum
428    const siteSettingsGroupsChecksums = {}
429    siteSettingsGroups.forEach((group) => {
430      siteSettingsGroupsChecksums[group.groupId] = group.checksum
431    })
432
433    // Loop through browser cookie groups and check if they are in site settings, store valid groups to be saved
434    if (browserCookieState.groups) {
435      Object.keys(browserCookieState.groups).forEach((groupName) => {
436        const isValidGroup =
437          this.#siteSettings.requiredGroups.some(
438            (g) => g.groupId === groupName
439          ) ||
440          this.#siteSettings.optionalGroups.some((g) => g.groupId === groupName)
441
442        if (isValidGroup) {
443          newCookieGroups.push(groupName)
444        } else {
445          invalidGroupsFound = true
446          // eslint-disable-next-line no-console
447          console.info(
448            `Invalid group found in browser cookie: '${groupName}', removing from cookie.`
449          )
450        }
451      })
452    }
453
454    if (invalidGroupsFound) {
455      const showBanner = true
456      this.saveConsentedGroups(newCookieGroups, showBanner)
457    }
458  }
459
460  /**
461   * Verify siteSettings validity
462   * Checks done:
463   * * At least one required group is needed
464   * * One of the required groups must contain the consent cookie
465   * * No duplicate group names
466   * @private
467   * @throws {Error} If the required group or cookie is missing in the site settings.
468   * @throws {Error} If there are multiple cookie groups with identical names in the site settings.
469   */
470  #verifySiteSettings() {
471    // Check that there is at least one required group
472    if (this.#siteSettings.requiredGroups.length === 0) {
473      throw new Error(
474        `Cookie consent: At least one required group is needed to store consent in '${
475          this.#cookieName
476        }'.`
477      )
478    }
479
480    // Check that there is at least one required group that contains the cookie and its type is cookie
481    const requiredGroupWithCookie = this.#siteSettings.requiredGroups.find(
482      (group) =>
483        group.cookies.some(
484          (cookie) => cookie.name === this.#cookieName && cookie.type === 1
485        )
486    )
487
488    // If no required group contains the cookie, throw an error
489    if (!requiredGroupWithCookie) {
490      throw new Error(
491        `Cookie consent: No group found in requiredGroups that contains cookie '${
492          this.#cookieName
493        }'.`
494      )
495    }
496
497    const siteSettingsGroups = [
498      ...this.#siteSettings.requiredGroups,
499      ...this.#siteSettings.optionalGroups,
500    ]
501
502    const cookieNames = new Set()
503    const duplicateGroupNames = new Set()
504    siteSettingsGroups.forEach((group) => {
505      if (cookieNames.has(group.groupId)) {
506        duplicateGroupNames.add(group.groupId)
507      }
508      cookieNames.add(group.groupId)
509    })
510    if (duplicateGroupNames.size > 0) {
511      throw new Error(
512        `Cookie consent: Groups '${Array.from(duplicateGroupNames).join(
513          ', '
514        )}' found multiple times in settings.`
515      )
516    }
517  }
518
519  /**
520   * Initializes the cookie handler.
521   *
522   * @return {Promise<Object>} - A promise that resolves to the site settings.
523   */
524  async init() {
525    await this.#removeInvalidGroupsFromCookie()
526    return this.#siteSettings
527  }
528}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.