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.