Parcourir la source

Merge branch 'feature/api-ping-data-collect' into develop

rigwild il y a 4 ans
Parent
commit
616823b3fe

+ 1 - 1
.eslintrc.js

@@ -92,7 +92,7 @@ module.exports = {
     'no-redeclare': 2,
     'no-script-url': 2,
     'no-self-compare': 2,
-    'no-sequences': 2,
+    'no-sequences': 0,
     'no-throw-literal': 2,
     'no-void': 2,
     'no-warning-comments': [0, { terms: ['todo', 'fixme'], location: 'start' }],

+ 4 - 1
config.messagesId.js

@@ -2,6 +2,9 @@
 
 // List of IDs for messages sent using WebSockets
 
+// Message ID for data collection
+export const COLLECT_DATA = 'COLLECT_DATA'
+
 // Message IDs for experiments events
 export const EXPERIMENT = {
   // An experiment was started
@@ -14,4 +17,4 @@ export const EXPERIMENT = {
   VALIDATED: 'EXPERIMENT_VALIDATED'
 }
 
-export default { EXPERIMENT }
+export default { COLLECT_DATA, EXPERIMENT }

+ 2 - 0
package.json

@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@hapi/boom": "^7.4.2",
+    "body-parser": "^1.19.0",
     "compression": "^1.7.4",
     "core-js": "^2.6.5",
     "cors": "^2.8.5",
@@ -23,6 +24,7 @@
     "morgan": "^1.9.1",
     "serve-static": "^1.13.2",
     "sharp": "^0.22.1",
+    "ua-parser-js": "^0.7.19",
     "winston": "^3.2.1",
     "ws": "^7.0.0"
   },

+ 15 - 9
server/functions.js

@@ -3,7 +3,7 @@
 import { promises as fs } from 'fs'
 import path from 'path'
 import boom from '@hapi/boom'
-import { logger, imagesPath, fileNameConvention, sceneFileNameBlackList } from '../config'
+import { logger, imagesPath, fileNameConvention, sceneFileNameBlackList, TEST_MODE } from '../config'
 
 /**
  * Call the error handler if a middleware function throw an error
@@ -13,24 +13,30 @@ import { logger, imagesPath, fileNameConvention, sceneFileNameBlackList } from '
  */
 export const asyncMiddleware = fn => (req, res, next) => {
   Promise.resolve(fn(req, res, next)).catch(err => {
-    // Check whether the error is a boom error
-    if (!err.isBoom) {
-      // The error was not recognized, send a 500 HTTP error
-      return next(boom.internal(err))
-    }
-    // It is a boom error, pass it to the error handler
     next(err)
   })
 }
 
 // Middleware to handle middleware errors
 export const errorHandler = (err, req, res, next) => {
+  // Check whether the error is a boom error
+  if (!err.isBoom) {
+    // Check if error is invalid JSON body
+    if (err instanceof SyntaxError && err.status === 400 && err.hasOwnProperty('body'))
+      err = boom.badRequest(err)
+    else {
+      // The error was not recognized, send a 500 HTTP error
+      err = boom.internal(err)
+    }
+  }
+
   const { output: { payload } } = err
 
   // Pass the error to the logging handler
   let errorLogged = new Error(`Error ${payload.statusCode} - ${payload.error} - Message :\n${payload.message}`)
   errorLogged.stack = err.stack
-  logger.error(formatError(errorLogged, err.data))
+
+  if (!TEST_MODE) logger.error(formatError(errorLogged, err.data))
 
   // Send the error to the client
   res.status(payload.statusCode).json({
@@ -51,7 +57,7 @@ export const errorHandler = (err, req, res, next) => {
  * @throws missing parameters
  */
 export const checkRequiredParameters = (requiredParameters, parameters) => {
-  if (!requiredParameters.every(aRequiredParameter => Object.keys(parameters).includes(aRequiredParameter)))
+  if (!requiredParameters.every(aRequiredParameter => parameters.hasOwnProperty(aRequiredParameter)))
     throw boom.badRequest(`Missing parameter(s). Required parameters : ${requiredParameters.join(', ')}.`)
 }
 

+ 6 - 0
server/index.js

@@ -6,6 +6,8 @@ import compression from 'compression'
 import serveStatic from 'serve-static'
 import helmet from 'helmet'
 import cors from 'cors'
+import bodyParser from 'body-parser'
+
 import routes from './routes'
 import { errorHandler, formatLog } from './functions'
 import { apiPrefix, imageServedUrl, serverPort, serveClient, imagesPath, logger } from '../config'
@@ -14,6 +16,7 @@ import connectDb from './database'
 const morgan = require('morgan')
 
 const app = express()
+app.enable('trust proxy')
 
 // Activating logging
 app.use(morgan('combined', {
@@ -29,6 +32,9 @@ app.use(helmet())
 // Turn "Cross-origin resource sharing" on to allow remote clients to connect to the API
 app.use(cors())
 
+// Parse JSON body
+app.use(bodyParser.json())
+
 // Serve images. "serve-static" is used because it caches images ("express.static" doesn't)
 app.use(imageServedUrl, serveStatic(imagesPath))
 

+ 87 - 0
server/routes/dataCollect.js

@@ -0,0 +1,87 @@
+'use strict'
+
+import express from 'express'
+import boom from '@hapi/boom'
+import userAgentParser from 'ua-parser-js'
+
+import { TEST_MODE } from '../../config'
+import { COLLECT_DATA } from '../../config.messagesId'
+import DataController from '../database/controllers/Data'
+import { asyncMiddleware, checkRequiredParameters } from '../functions'
+
+const router = express.Router()
+
+/**
+ * @api {post} /dataCollect /dataCollect
+ * @apiVersion 0.1.11
+ * @apiName dataCollect
+ * @apiGroup API
+ *
+ * @apiDescription Collect user's data
+ *
+ * @apiParam {String} uuid The unique user identifier
+ * @apiParam {Object} screen Screen data, `window.screen` @see https://developer.mozilla.org/en-US/docs/Web/API/Screen
+ *
+ * @apiExample Usage example
+ * curl -i -L -H "Content-Type: application/json" -X POST "http://diran.univ-littoral.fr/api/dataCollect" -d {"uuid":"test","screen":{"width":1920,"height":1024}}
+ *
+ * @apiSuccessExample {string} Success response example
+ * HTTP/1.1 200 OK /api/dataCollect
+ * OK
+ *
+ * @apiError (Error 4xx) 400_[1] Missing parameter(s)
+ * @apiErrorExample {json} Missing parameter
+ * HTTP/1.1 400 Bad Request
+ * {
+ *   "message": "Missing parameter(s). Required parameters : uuid, screen."
+ * }
+ *
+ * @apiError (Error 4xx) 400_[2] Invalid query parameter
+ * @apiErrorExample {json} Invalid query parameter(s)
+ * HTTP/1.1 400 Bad Request
+ * {
+ *   "message": "Invalid body parameter(s).",
+ *   "data": [
+ *     "\"uuid\" must be a string.",
+ *     "\"screen\" must be a valid object."
+ *   ]
+ * }
+ *
+ */
+
+router.post('/', asyncMiddleware(async (req, res) => {
+  // Check the request contains all the required body parameters
+  const b = req.body
+  checkRequiredParameters(['uuid', 'screen'], b)
+
+  let errorList = []
+
+  if (typeof b.uuid !== 'string')
+    errorList.push('"uuid" must be a string.')
+
+  if (typeof b.screen !== 'object' || Object.keys(b.screen).length > 30)
+    errorList.push('"screen" must be a valid object.')
+
+  // Check there is no errors with parameters
+  if (errorList.length > 0)
+    throw boom.badRequest('Invalid body parameter(s).', errorList)
+
+  const userAgent = userAgentParser(req.headers['user-agent'])
+
+  // Collected data object
+  const data = {
+    uuid: b.uuid,
+    msgId: COLLECT_DATA,
+    msg: {
+      screen: b.screen,
+      userAgent,
+      ip: req.ip
+    }
+  }
+
+  if (!TEST_MODE) await DataController.add(data)
+
+  res.send({ message: 'OK' })
+}))
+
+export default router

+ 3 - 1
server/routes/index.js

@@ -6,6 +6,7 @@ import listSceneQualities from './listSceneQualities'
 import getImage from './getImage'
 import getImageExtracts from './getImageExtracts'
 import ping from './ping'
+import dataCollect from './dataCollect'
 
 const router = express.Router()
 
@@ -13,6 +14,7 @@ router.use('/listScenes', listScenes)
 router.use('/listSceneQualities', listSceneQualities)
 router.use('/getImage', getImage)
 router.use('/getImageExtracts', getImageExtracts)
-router.get('/ping', ping)
+router.use('/dataCollect', dataCollect)
+router.use('/ping', ping)
 
 export default router

+ 3 - 5
server/routes/listScenes.js

@@ -45,11 +45,9 @@ const router = express.Router()
  * @returns {string[]} the list of files
  * @throws the directory does not exist or is not accessible
  */
-export const getSceneList = () => {
-  return fs.readdir(imagesPath).catch(() => {
-    throw boom.internal(`Can't access the "${path.basename(imagesPath)}" directory. Check it exists and you have read permission on it.`)
-  })
-}
+export const getSceneList = () => fs.readdir(imagesPath).catch(() => {
+  throw boom.internal(`Can't access the "${path.basename(imagesPath)}" directory. Check it exists and you have read permission on it.`)
+})
 
 // Route which returns a list of all available scenes in the `imagesPath` directory
 router.get('/', asyncMiddleware(async (req, res) => res.json({ data: await getSceneList() })))

+ 1 - 1
server/routes/ping.js

@@ -20,6 +20,6 @@ const router = express.Router()
  * pong
  */
 
-router.get('/ping', (req, res) => res.send('pong'))
+router.get('/', (req, res) => res.send('pong'))
 
 export default router

+ 1 - 4
server/webSocket/index.js

@@ -33,10 +33,7 @@ const createWsServer = httpServer => {
   wss.on('listening', () => wsLogger.info(formatLog('The WebSocket server was started')))
   wss.on('error', err => wsLogger.error(formatError(err)))
 
-  wss.on('connection', (ws, req) => {
-    // Unique identifier passed with the request url
-    ws.uuid = req.url.replace('/?uuid=', '')
-
+  wss.on('connection', ws => {
     wsLogger.info(formatLog('New client connected.'))
 
     ws.on('message', data => messageHandler(ws)(data).catch(err => errorHandler(ws)(err)))

+ 2 - 1
server/webSocket/messageHandler.js

@@ -21,8 +21,9 @@ const messageHandler = ws => async data => {
   catch (err) {
     throw new Error('Invalid JSON data.')
   }
+  if (!TEST_MODE && !json.uuid)
+    throw new Error('"uuid" was not provided.')
 
-  json.WS_UNIQUE_UUID = ws.uuid
   await DataController.add(json)
   if (!TEST_MODE) wsLogger.info(formatLog(json, 'message'))
   ws.send('{"message":"ok"}')

+ 2 - 0
src/functions.js

@@ -2,6 +2,8 @@ export const API_PREFIX = '/api'
 export const API_ROUTES = {
   ping: () => `${API_PREFIX}/ping`,
 
+  dataCollect: () => `${API_PREFIX}/dataCollect`,
+
   listScenes: () => `${API_PREFIX}/listScenes`,
 
   listSceneQualities: sceneName => `${API_PREFIX}/listSceneQualities?${new URLSearchParams({ sceneName })}`,

+ 1 - 1
src/mixins/ExperimentBase/index.vue

@@ -34,7 +34,7 @@ export default {
 
   mounted() {
     if (!this.getExperimentProgress({ experimentName: this.experimentName, sceneName: this.sceneName }).experimentName)
-      this.sendMessage({ msgId: experimentMsgId.STARTED })
+      this.sendMessage({ msgId: experimentMsgId.STARTED, experimentName: this.experimentName, sceneName: this.sceneName })
 
     // Check if the experiment is already finished
     if (this.experimentName && this.sceneName && this.isExperimentDone({ experimentName: this.experimentName, sceneName: this.sceneName })) {

+ 18 - 3
src/store/actions.js

@@ -18,7 +18,7 @@ export default {
     commit('resetApp', { gdprConsent, hostConfig, progression })
   },
 
-  async setHostConfig({ state, commit }, { ssl, host, port }) {
+  async setHostConfig({ state, commit, dispatch }, { ssl, host, port }) {
     // Timeout after 1s
     const controller = new AbortController()
     const signal = controller.signal
@@ -39,9 +39,11 @@ export default {
         if (!state.socket.isConnected)
           throw new Error('Could not connect to remote WebSocket server.')
 
+
         // Configuration is valid
         commit('setHostConfig', { ssl, host, port })
         router.push('/experiments')
+        dispatch('collectUserData')
       })
       .catch(err => {
         // Host not reachable or invalid HTTP status code
@@ -61,8 +63,21 @@ export default {
     else throw new Error('Could not connect to WebSocket server. Host is not configured.')
   },
 
-  sendMessage(_, { msgId, msg = undefined }) {
-    Vue.prototype.$socket.send(JSON.stringify({ msgId, msg }))
+  async collectUserData({ state, getters }) {
+    return fetch(getters.getHostURI + API_ROUTES.dataCollect(), {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        uuid: state.uuid,
+        viewport: Object.keys(Object.getPrototypeOf(window.screen)).reduce((acc, x) => ((acc[x] = window.screen[x]), acc), {})
+      })
+    })
+  },
+
+  sendMessage({ state }, { msgId, msg = undefined }) {
+    Vue.prototype.$socket.send(JSON.stringify({ uuid: state.uuid, msgId, msg }))
   },
 
   async loadScenesList({ getters: { isHostConfigured, getHostURI }, commit }) {

+ 4 - 6
test/api/_test_functions.js

@@ -2,11 +2,13 @@
 
 import path from 'path'
 import express from 'express'
+import bodyParser from 'body-parser'
 import WebSocket from 'ws'
 import serveStatic from 'serve-static'
 import routes from '../../server/routes'
 import { apiPrefix, imageServedUrl, imagesPath } from '../../config'
 import connectDb from '../../server/database'
+import { errorHandler } from '../../server/functions'
 import { errorHandler as wsErrorHandler } from '../../server/webSocket'
 import wsMessageHandler from '../../server/webSocket/messageHandler'
 
@@ -29,14 +31,10 @@ export const json = obj => 'JSON DATA : ' + (JSON.stringify(obj, null, 2) || obj
  */
 export const getHttpServer = () => {
   const app = express()
+  app.use(bodyParser.json())
   app.use(imageServedUrl, serveStatic(imagesPath))
   app.use(apiPrefix, routes)
-  app.use((err, req, res, next) => {
-    res.status(err.output.payload.statusCode).json({
-      message: err.message || err.output.payload.message,
-      data: err.data || undefined
-    })
-  })
+  app.use(errorHandler)
   return app
 }
 

+ 41 - 0
test/api/dataCollect.js

@@ -0,0 +1,41 @@
+'use strict'
+
+import test from 'ava'
+import request from 'supertest'
+import { apiPrefix } from '../../config'
+import { json, getHttpServer } from './_test_functions'
+
+// ROUTE /dataCollect
+
+// Before each tests, start a server
+test.beforeEach(async t => (t.context.server = await getHttpServer()))
+
+test('POST /dataCollect - No body', async t => {
+  const res = await request(t.context.server)
+    .post(`${apiPrefix}/dataCollect`)
+
+  t.is(res.status, 400, json(res))
+  t.true(res.body.message.includes('Missing parameter'), json(res.body))
+  t.true(res.body.message.includes('uuid'), json(res.body))
+  t.true(res.body.message.includes('screen'), json(res.body))
+})
+
+test('POST /dataCollect - Invalid body parameters', async t => {
+  const res = await request(t.context.server)
+    .post(`${apiPrefix}/dataCollect`)
+    .send({ uuid: 42, screen: 'not an object' })
+
+  t.is(res.status, 400, json(res))
+  t.true(res.body.message.includes('Invalid body parameter'), json(res.body))
+  t.truthy(res.body.data.find(x => x.includes('"uuid" must be a string.')), json(res.body))
+  t.truthy(res.body.data.find(x => x.includes('"screen" must be a valid object.')), json(res.body))
+})
+
+test('POST /dataCollect - Valid body parameters', async t => {
+  const res = await request(t.context.server)
+    .post(`${apiPrefix}/dataCollect`)
+    .send({ uuid: 'test', screen: { width: 1920, height: 1080 } })
+
+  t.is(res.status, 200, json(res))
+  t.is(res.body.message, 'OK', json(res.body))
+})

+ 61 - 4
yarn.lock

@@ -1828,6 +1828,22 @@ body-parser@1.18.3:
     raw-body "2.3.3"
     type-is "~1.6.16"
 
+body-parser@^1.19.0:
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
+  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+  dependencies:
+    bytes "3.1.0"
+    content-type "~1.0.4"
+    debug "2.6.9"
+    depd "~1.1.2"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    on-finished "~2.3.0"
+    qs "6.7.0"
+    raw-body "2.4.0"
+    type-is "~1.6.17"
+
 bonjour@^3.5.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5"
@@ -2012,6 +2028,11 @@ bytes@3.0.0:
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
   integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
 
+bytes@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
+  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
+
 cacache@^10.0.4:
   version "10.0.4"
   resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460"
@@ -4845,6 +4866,17 @@ http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3:
     setprototypeof "1.1.0"
     statuses ">= 1.4.0 < 2"
 
+http-errors@1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
+  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
 http-parser-js@>=0.4.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.0.tgz#d65edbede84349d0dc30320815a15d39cc3cbbd8"
@@ -4890,7 +4922,7 @@ iconv-lite@0.4.23:
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
+iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -7700,7 +7732,7 @@ qs@6.5.2, qs@~6.5.2:
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
-qs@^6.5.1:
+qs@6.7.0, qs@^6.5.1:
   version "6.7.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
   integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
@@ -7764,6 +7796,16 @@ raw-body@2.3.3:
     iconv-lite "0.4.23"
     unpipe "1.0.0"
 
+raw-body@2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
+  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+  dependencies:
+    bytes "3.1.0"
+    http-errors "1.7.2"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
 rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@@ -8382,6 +8424,11 @@ setprototypeof@1.1.0:
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
   integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
 
+setprototypeof@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
+  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
+
 sha.js@^2.4.0, sha.js@^2.4.8:
   version "2.4.11"
   resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7"
@@ -8732,7 +8779,7 @@ static-extend@^0.1.1:
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
-"statuses@>= 1.4.0 < 2":
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2":
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
@@ -9222,6 +9269,11 @@ to-regex@^3.0.1, to-regex@^3.0.2:
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
+toidentifier@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
+  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
+
 topo@3.x.x:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/topo/-/topo-3.0.3.tgz#d5a67fb2e69307ebeeb08402ec2a2a6f5f7ad95c"
@@ -9314,7 +9366,7 @@ type-fest@^0.4.1:
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.4.1.tgz#8bdf77743385d8a4f13ba95f610f5ccd68c728f8"
   integrity sha512-IwzA/LSfD2vC1/YDYMv/zHP4rDF1usCwllsDpbolT3D4fUepIO7f9K70jjmUewU/LmGUKJcwcVtDCpnKk4BPMw==
 
-type-is@~1.6.16:
+type-is@~1.6.16, type-is@~1.6.17:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
   integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@@ -9327,6 +9379,11 @@ typedarray@^0.0.6:
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
+ua-parser-js@^0.7.19:
+  version "0.7.19"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b"
+  integrity sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==
+
 uc.micro@^1.0.1, uc.micro@^1.0.5:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"