diff --git a/packages/core/admin/admin/src/components/AuthenticatedApp/index.js b/packages/core/admin/admin/src/components/AuthenticatedApp/index.js index efddc5765f..a848486928 100644 --- a/packages/core/admin/admin/src/components/AuthenticatedApp/index.js +++ b/packages/core/admin/admin/src/components/AuthenticatedApp/index.js @@ -20,7 +20,7 @@ import { fetchUserRoles, } from './utils/api'; import checkLatestStrapiVersion from './utils/checkLatestStrapiVersion'; -import { getFullName } from '../../utils'; +import { getFullName, hashAdminUserEmail } from '../../utils'; const strapiVersion = packageJSON.version; @@ -31,6 +31,7 @@ const AuthenticatedApp = () => { const userInfo = auth.getUserInfo(); const userName = get(userInfo, 'username') || getFullName(userInfo.firstname, userInfo.lastname); const [userDisplayName, setUserDisplayName] = useState(userName); + const [userId, setUserId] = useState(null); const { showReleaseNotification } = useConfigurations(); const [ { data: appInfos, status }, @@ -71,6 +72,15 @@ const AuthenticatedApp = () => { } }, [userRoles, appInfos]); + useEffect(() => { + const getUserId = async () => { + const userId = await hashAdminUserEmail(userInfo); + setUserId(userId); + }; + + getUserId(); + }, [userInfo]); + // We don't need to wait for the release query to be fetched before rendering the plugins // however, we need the appInfos and the permissions const shouldShowNotDependentQueriesLoader = @@ -81,12 +91,13 @@ const AuthenticatedApp = () => { const appInfosValue = useMemo(() => { return { ...appInfos, + userId, latestStrapiReleaseTag: tag_name, setUserDisplayName, shouldUpdateStrapi, userDisplayName, }; - }, [appInfos, tag_name, shouldUpdateStrapi, userDisplayName]); + }, [appInfos, tag_name, shouldUpdateStrapi, userDisplayName, userId]); if (shouldShowLoader) { return ; diff --git a/packages/core/admin/admin/src/components/AuthenticatedApp/tests/index.test.js b/packages/core/admin/admin/src/components/AuthenticatedApp/tests/index.test.js index d9af9278a1..ff74d49e54 100644 --- a/packages/core/admin/admin/src/components/AuthenticatedApp/tests/index.test.js +++ b/packages/core/admin/admin/src/components/AuthenticatedApp/tests/index.test.js @@ -20,7 +20,9 @@ const strapiVersion = packageJSON.version; jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), - auth: { getUserInfo: () => ({ firstname: 'kai', lastname: 'doe' }) }, + auth: { + getUserInfo: () => ({ firstname: 'kai', lastname: 'doe', email: 'testemail@strapi.io' }), + }, useGuidedTour: jest.fn(() => ({ setGuidedTourVisibility: jest.fn(), })), diff --git a/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js b/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js index 7b1b5a7a6d..7baec1ad2f 100644 --- a/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js +++ b/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js @@ -24,18 +24,13 @@ import { RELATION_ITEM_HEIGHT } from './constants'; import { usePrev } from '../../hooks'; const LinkEllipsis = styled(Link)` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inherit; -`; + display: block; -const BoxEllipsis = styled(Box)` > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - display: inherit; + display: block; } `; @@ -322,7 +317,7 @@ const RelationInput = ({ } style={style} > - + {href ? ( @@ -334,7 +329,7 @@ const RelationInput = ({ )} - + {publicationState && ( diff --git a/packages/core/admin/admin/src/content-manager/components/RelationInput/tests/__snapshots__/RelationInput.test.js.snap b/packages/core/admin/admin/src/content-manager/components/RelationInput/tests/__snapshots__/RelationInput.test.js.snap index 085a24a6bf..604817cee5 100644 --- a/packages/core/admin/admin/src/content-manager/components/RelationInput/tests/__snapshots__/RelationInput.test.js.snap +++ b/packages/core/admin/admin/src/content-manager/components/RelationInput/tests/__snapshots__/RelationInput.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Content-Manager || RelationInput should render and match snapshot 1`] = ` -.c36 { +.c35 { border: 0; -webkit-clip: rect(0 0 0 0); clip: rect(0 0 0 0); @@ -49,7 +49,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = min-width: 0px; } -.c23 { +.c22 { background: #eaf5ff; padding-top: 4px; padding-right: 8px; @@ -60,16 +60,16 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = border: 1px solid #b8e1ff; } -.c26 { +.c25 { padding-left: 16px; } -.c28 { +.c27 { color: #666687; width: 12px; } -.c31 { +.c30 { background: #eafbe7; padding-top: 4px; padding-right: 8px; @@ -80,7 +80,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = border: 1px solid #c6f0c2; } -.c34 { +.c33 { padding-top: 8px; } @@ -165,20 +165,20 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = color: #4945ff; } -.c22 { +.c21 { font-size: 0.875rem; line-height: 1.43; color: #32324d; } -.c25 { +.c24 { font-size: 0.875rem; line-height: 1.43; font-weight: 600; color: #006096; } -.c30 { +.c29 { font-size: 0.875rem; line-height: 1.43; display: block; @@ -188,14 +188,14 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = color: #4945ff; } -.c33 { +.c32 { font-size: 0.875rem; line-height: 1.43; font-weight: 600; color: #2f6846; } -.c35 { +.c34 { font-size: 0.75rem; line-height: 1.33; color: #666687; @@ -210,7 +210,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = margin-top: 4px; } -.c29 path { +.c28 path { fill: #666687; } @@ -300,15 +300,15 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = width: 0.5625rem; } -.c24 .c5 { +.c23 .c5 { color: #0c75af; } -.c32 .c5 { +.c31 .c5 { color: #328048; } -.c20 { +.c19 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -324,31 +324,31 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = outline: none; } -.c20 svg path { +.c19 svg path { -webkit-transition: fill 150ms ease-out; transition: fill 150ms ease-out; fill: currentColor; } -.c20 svg { +.c19 svg { font-size: 0.625rem; } -.c20 .c5 { +.c19 .c5 { -webkit-transition: color 150ms ease-out; transition: color 150ms ease-out; color: currentColor; } -.c20:hover { +.c19:hover { color: #7b79ff; } -.c20:active { +.c19:active { color: #271fe0; } -.c20:after { +.c19:after { -webkit-transition-property: all; transition-property: all; -webkit-transition-duration: 0.2s; @@ -363,11 +363,11 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = border: 2px solid transparent; } -.c20:focus-visible { +.c19:focus-visible { outline: none; } -.c20:focus-visible:after { +.c19:focus-visible:after { border-radius: 8px; content: ''; position: absolute; @@ -407,26 +407,23 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = bottom: 0; } -.c21 { +.c20 { + display: block; +} + +.c20 > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - display: inherit; + display: block; } -.c19 > span { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inherit; -} - -.c27 svg path { +.c26 svg path { fill: #8e8ea9; } -.c27:hover svg path, -.c27:focus svg path { +.c26:hover svg path, +.c26:focus svg path { fill: #666687; } @@ -585,18 +582,18 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] = class="c16 c17" >
Draft

this is a description @@ -764,7 +761,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =

({ uuid, telemetryProperties, + deviceId, }), - [uuid, telemetryProperties] + [uuid, telemetryProperties, deviceId] ); if (isLoading) { diff --git a/packages/core/admin/admin/src/utils/index.js b/packages/core/admin/admin/src/utils/index.js index 3601c6cff2..0dddd94e0e 100644 --- a/packages/core/admin/admin/src/utils/index.js +++ b/packages/core/admin/admin/src/utils/index.js @@ -7,3 +7,4 @@ export { default as sortLinks } from './sortLinks'; export { default as getExistingActions } from './getExistingActions'; export { default as getRequestUrl } from './getRequestUrl'; export { default as getFullName } from './getFullName'; +export { default as hashAdminUserEmail } from './uniqueAdminHash'; diff --git a/packages/core/admin/admin/src/utils/tests/uniqueAdminHash.test.js b/packages/core/admin/admin/src/utils/tests/uniqueAdminHash.test.js new file mode 100644 index 0000000000..e305835b37 --- /dev/null +++ b/packages/core/admin/admin/src/utils/tests/uniqueAdminHash.test.js @@ -0,0 +1,49 @@ +import crypto from 'crypto'; +import { TextEncoder } from 'util'; +import hashAdminUserEmail, { utils } from '../uniqueAdminHash'; + +const testHashValue = '8544bf5b5389959462912699664f03ed664a4b6d24f03b13bdbc362efc147873'; + +describe('Creating admin user email hash in admin', () => { + afterAll(() => { + Object.defineProperty(global.self, 'crypto', { + value: undefined, + }); + Object.defineProperty(global.self, 'TextEncoder', { + value: undefined, + }); + }); + + it('should return empty string if no payload provided', async () => { + const testHash = await hashAdminUserEmail(); + + expect(testHash).toBe(null); + }); + + it('should return hash using crypto subtle', async () => { + Object.defineProperty(global.self, 'crypto', { + value: { + subtle: { + digest: jest.fn((type, message) => crypto.createHash('sha256').update(message).digest()), + }, + }, + configurable: true, + }); + + Object.defineProperty(global.self, 'TextEncoder', { + value: TextEncoder, + configurable: true, + }); + + const payload = { + email: 'testemail@strapi.io', + }; + + const spy = jest.spyOn(utils, 'digestMessage'); + + const testHash = await hashAdminUserEmail(payload); + + expect(spy).toHaveBeenCalled(); + expect(testHash).toBe(testHashValue); + }); +}); diff --git a/packages/core/admin/admin/src/utils/uniqueAdminHash.js b/packages/core/admin/admin/src/utils/uniqueAdminHash.js new file mode 100644 index 0000000000..c60168bf02 --- /dev/null +++ b/packages/core/admin/admin/src/utils/uniqueAdminHash.js @@ -0,0 +1,22 @@ +export const utils = { + bufferToHex(buffer) { + return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join(''); + }, + async digestMessage(message) { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); + + return this.bufferToHex(hashBuffer); + }, +}; + +export default async function hashAdminUserEmail(payload) { + if (!payload) { + return null; + } + try { + return await utils.digestMessage(payload.email); + } catch (error) { + return null; + } +} diff --git a/packages/core/admin/server/controllers/__tests__/admin.test.js b/packages/core/admin/server/controllers/__tests__/admin.test.js index 31576059a4..2144268755 100644 --- a/packages/core/admin/server/controllers/__tests__/admin.test.js +++ b/packages/core/admin/server/controllers/__tests__/admin.test.js @@ -69,6 +69,7 @@ describe('Admin Controller', () => { 'info.dependencies': { dependency: '1.0.0', }, + uuid: 'testuuid', environment: 'development', }[key] || value) ), @@ -85,12 +86,14 @@ describe('Admin Controller', () => { ['autoReload', false], ['info.strapi', null], ['info.dependencies', {}], + ['uuid', null], ]); expect(result.data).toBeDefined(); expect(result.data).toStrictEqual({ currentEnvironment: 'development', autoReload: false, strapiVersion: '1.0.0', + projectId: 'testuuid', dependencies: { dependency: '1.0.0', }, diff --git a/packages/core/admin/server/controllers/admin.js b/packages/core/admin/server/controllers/admin.js index 165ee80535..980e513aca 100644 --- a/packages/core/admin/server/controllers/admin.js +++ b/packages/core/admin/server/controllers/admin.js @@ -110,6 +110,7 @@ module.exports = { const autoReload = strapi.config.get('autoReload', false); const strapiVersion = strapi.config.get('info.strapi', null); const dependencies = strapi.config.get('info.dependencies', {}); + const projectId = strapi.config.get('uuid', null); const nodeVersion = process.version; const communityEdition = !strapi.EE; const useYarn = await exists(path.join(process.cwd(), 'yarn.lock')); @@ -120,6 +121,7 @@ module.exports = { autoReload, strapiVersion, dependencies, + projectId, nodeVersion, communityEdition, useYarn, diff --git a/packages/core/admin/server/services/__tests__/metrics.test.js b/packages/core/admin/server/services/__tests__/metrics.test.js index 024532e35d..3b010f1626 100644 --- a/packages/core/admin/server/services/__tests__/metrics.test.js +++ b/packages/core/admin/server/services/__tests__/metrics.test.js @@ -19,7 +19,12 @@ describe('Metrics', () => { await metricsService.sendDidInviteUser(); - expect(send).toHaveBeenCalledWith('didInviteUser', { numberOfRoles: 3, numberOfUsers: 2 }); + expect(send).toHaveBeenCalledWith('didInviteUser', { + groupProperties: { + numberOfRoles: 3, + numberOfUsers: 2, + }, + }); expect(countUsers).toHaveBeenCalledWith(); expect(countRoles).toHaveBeenCalledWith(); }); @@ -52,7 +57,9 @@ describe('Metrics', () => { expect(getLanguagesInUse).toHaveBeenCalledWith(); expect(send).toHaveBeenCalledWith('didChangeInterfaceLanguage', { - languagesInUse: ['en', 'fr', 'en'], + userProperties: { + languagesInUse: ['en', 'fr', 'en'], + }, }); }); }); diff --git a/packages/core/admin/server/services/metrics.js b/packages/core/admin/server/services/metrics.js index 18cbeb892e..678ad83e0b 100644 --- a/packages/core/admin/server/services/metrics.js +++ b/packages/core/admin/server/services/metrics.js @@ -5,7 +5,9 @@ const { getService } = require('../utils'); const sendDidInviteUser = async () => { const numberOfUsers = await getService('user').count(); const numberOfRoles = await getService('role').count(); - strapi.telemetry.send('didInviteUser', { numberOfRoles, numberOfUsers }); + strapi.telemetry.send('didInviteUser', { + groupProperties: { numberOfRoles, numberOfUsers }, + }); }; const sendDidUpdateRolePermissions = async () => { @@ -14,7 +16,8 @@ const sendDidUpdateRolePermissions = async () => { const sendDidChangeInterfaceLanguage = async () => { const languagesInUse = await getService('user').getLanguagesInUse(); - strapi.telemetry.send('didChangeInterfaceLanguage', { languagesInUse }); + // This event is anonymous + strapi.telemetry.send('didChangeInterfaceLanguage', { userProperties: { languagesInUse } }); }; module.exports = { diff --git a/packages/core/content-manager/server/controllers/__tests__/single-types.test.js b/packages/core/content-manager/server/controllers/__tests__/single-types.test.js index 09be52864c..ad86b55477 100644 --- a/packages/core/content-manager/server/controllers/__tests__/single-types.test.js +++ b/packages/core/content-manager/server/controllers/__tests__/single-types.test.js @@ -85,6 +85,7 @@ describe('Single Types', () => { }, user: { id: 1, + email: 'someTestEmailString', }, }; @@ -180,7 +181,9 @@ describe('Single Types', () => { ); expect(sendTelemetry).toHaveBeenCalledWith('didCreateFirstContentTypeEntry', { - model: modelUid, + eventProperties: { + model: modelUid, + }, }); }); diff --git a/packages/core/content-manager/server/controllers/collection-types.js b/packages/core/content-manager/server/controllers/collection-types.js index a902e6c94a..9f942c9d89 100644 --- a/packages/core/content-manager/server/controllers/collection-types.js +++ b/packages/core/content-manager/server/controllers/collection-types.js @@ -85,7 +85,9 @@ module.exports = { ctx.body = await permissionChecker.sanitizeOutput(entity); if (totalEntries === 0) { - strapi.telemetry.send('didCreateFirstContentTypeEntry', { model }); + strapi.telemetry.send('didCreateFirstContentTypeEntry', { + eventProperties: { model }, + }); } }, diff --git a/packages/core/content-manager/server/controllers/single-types.js b/packages/core/content-manager/server/controllers/single-types.js index f718dea5d7..d635748a78 100644 --- a/packages/core/content-manager/server/controllers/single-types.js +++ b/packages/core/content-manager/server/controllers/single-types.js @@ -73,7 +73,9 @@ module.exports = { const newEntity = await entityManager.create(sanitizedBody, model, { params: query }); ctx.body = await permissionChecker.sanitizeOutput(newEntity); - await strapi.telemetry.send('didCreateFirstContentTypeEntry', { model }); + await strapi.telemetry.send('didCreateFirstContentTypeEntry', { + eventProperties: { model }, + }); return; } diff --git a/packages/core/content-manager/server/services/__tests__/metrics.test.js b/packages/core/content-manager/server/services/__tests__/metrics.test.js index f71c52a03c..b215e15006 100644 --- a/packages/core/content-manager/server/services/__tests__/metrics.test.js +++ b/packages/core/content-manager/server/services/__tests__/metrics.test.js @@ -66,14 +66,15 @@ describe('metrics', () => { global.strapi = { telemetry: { send } }; metricsService = metricsServiceLoader({ strapi }); const [containsRelationalFields, displayedFields, displayedRelationalFields] = expectedResult; - await metricsService.sendDidConfigureListView(contentType, { layouts: { list } }); expect(send).toHaveBeenCalledTimes(1); expect(send).toHaveBeenCalledWith('didConfigureListView', { - displayedFields, - containsRelationalFields, - displayedRelationalFields, + eventProperties: { + containsRelationalFields, + displayedFields, + displayedRelationalFields, + }, }); }); }); diff --git a/packages/core/content-manager/server/services/metrics.js b/packages/core/content-manager/server/services/metrics.js index 3499646c65..ac553e89ad 100644 --- a/packages/core/content-manager/server/services/metrics.js +++ b/packages/core/content-manager/server/services/metrics.js @@ -13,11 +13,11 @@ module.exports = ({ strapi }) => { ).length; const data = { - containsRelationalFields: !!displayedRelationalFields, + eventProperties: { containsRelationalFields: !!displayedRelationalFields }, }; - if (data.containsRelationalFields) { - Object.assign(data, { + if (data.eventProperties.containsRelationalFields) { + Object.assign(data.eventProperties, { displayedFields, displayedRelationalFields, }); diff --git a/packages/core/content-type-builder/server/controllers/content-types.js b/packages/core/content-type-builder/server/controllers/content-types.js index 7969374c01..2f4e66fbd4 100644 --- a/packages/core/content-type-builder/server/controllers/content-types.js +++ b/packages/core/content-type-builder/server/controllers/content-types.js @@ -64,15 +64,17 @@ module.exports = { components: body.components, }); - const metricsProperties = { - kind: contentType.kind, - hasDraftAndPublish: hasDraftAndPublish(contentType.schema), + const metricsPayload = { + eventProperties: { + kind: contentType.kind, + hasDraftAndPublish: hasDraftAndPublish(contentType.schema), + }, }; if (_.isEmpty(strapi.api)) { - await strapi.telemetry.send('didCreateFirstContentType', metricsProperties); + await strapi.telemetry.send('didCreateFirstContentType', metricsPayload); } else { - await strapi.telemetry.send('didCreateContentType', metricsProperties); + await strapi.telemetry.send('didCreateContentType', metricsPayload); } setImmediate(() => strapi.reload()); @@ -80,7 +82,9 @@ module.exports = { ctx.send({ data: { uid: contentType.uid } }, 201); } catch (error) { strapi.log.error(error); - await strapi.telemetry.send('didNotCreateContentType', { error: error.message }); + await strapi.telemetry.send('didNotCreateContentType', { + eventProperties: { error: error.message }, + }); ctx.send({ error: error.message }, 400); } }, diff --git a/packages/core/database/lib/entity-manager/regular-relations.js b/packages/core/database/lib/entity-manager/regular-relations.js index a4b60e3b33..2fd3bcdd89 100644 --- a/packages/core/database/lib/entity-manager/regular-relations.js +++ b/packages/core/database/lib/entity-manager/regular-relations.js @@ -198,60 +198,42 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction return; } - // Handle databases that don't support window function ROW_NUMBER - if (!strapi.db.dialect.supportsWindowFunctions()) { - await cleanOrderColumnsForOldDatabases({ id, attribute, db, inverseRelIds, transaction: trx }); - return; - } - - const { joinTable } = attribute; - const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable; - const update = []; - const updateBinding = []; - const select = ['??']; - const selectBinding = ['id']; - const where = []; - const whereBinding = []; - - if (hasOrderColumn(attribute) && id) { - update.push('?? = b.src_order'); - updateBinding.push(orderColumnName); - select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS src_order'); - selectBinding.push(joinColumn.name, orderColumnName); - where.push('?? = ?'); - whereBinding.push(joinColumn.name, id); - } - - if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) { - update.push('?? = b.inv_order'); - updateBinding.push(inverseOrderColumnName); - select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS inv_order'); - selectBinding.push(inverseJoinColumn.name, inverseOrderColumnName); - where.push(`?? IN (${inverseRelIds.map(() => '?').join(', ')})`); - whereBinding.push(inverseJoinColumn.name, ...inverseRelIds); - } - - // raw query as knex doesn't allow updating from a subquery - // https://github.com/knex/knex/issues/2504 switch (strapi.db.dialect.client) { case 'mysql': - await db.connection - .raw( - `UPDATE - ?? as a, - ( - SELECT ${select.join(', ')} - FROM ?? - WHERE ${where.join(' OR ')} - ) AS b - SET ${update.join(', ')} - WHERE b.id = a.id`, - [joinTable.name, ...selectBinding, joinTable.name, ...whereBinding, ...updateBinding] - ) - .transacting(trx); + await cleanOrderColumnsForInnoDB({ id, attribute, db, inverseRelIds, transaction: trx }); break; default: { + const { joinTable } = attribute; + const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable; + const update = []; + const updateBinding = []; + const select = ['??']; + const selectBinding = ['id']; + const where = []; + const whereBinding = []; + + if (hasOrderColumn(attribute) && id) { + update.push('?? = b.src_order'); + updateBinding.push(orderColumnName); + select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS src_order'); + selectBinding.push(joinColumn.name, orderColumnName); + where.push('?? = ?'); + whereBinding.push(joinColumn.name, id); + } + + if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) { + update.push('?? = b.inv_order'); + updateBinding.push(inverseOrderColumnName); + select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS inv_order'); + selectBinding.push(inverseJoinColumn.name, inverseOrderColumnName); + where.push(`?? IN (${inverseRelIds.map(() => '?').join(', ')})`); + whereBinding.push(inverseJoinColumn.name, ...inverseRelIds); + } + const joinTableName = addSchema(joinTable.name); + + // raw query as knex doesn't allow updating from a subquery + // https://github.com/knex/knex/issues/2504 await db.connection .raw( `UPDATE ?? as a @@ -265,24 +247,29 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction [joinTableName, ...updateBinding, ...selectBinding, joinTableName, ...whereBinding] ) .transacting(trx); + + /* + `UPDATE :joinTable: as a + SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order + FROM ( + SELECT + id, + ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order, + ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order + FROM :joinTable: + WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds) + ) AS b + WHERE b.id = a.id`, + */ } - /* - `UPDATE :joinTable: as a - SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order - FROM ( - SELECT - id, - ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order, - ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order - FROM :joinTable: - WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds) - ) AS b - WHERE b.id = a.id`, - */ } }; -const cleanOrderColumnsForOldDatabases = async ({ +/* + * Ensure that orders are following a 1, 2, 3 sequence, without gap. + * The use of a temporary table instead of a window function makes the query compatible with MySQL 5 and prevents some deadlocks to happen in innoDB databases + */ +const cleanOrderColumnsForInnoDB = async ({ id, attribute, db, @@ -319,6 +306,9 @@ const cleanOrderColumnsForOldDatabases = async ({ } ) .transacting(trx); + + // raw query as knex doesn't allow updating from a subquery + // https://github.com/knex/knex/issues/2504 await db.connection .raw( `UPDATE ?? as a, (SELECT * FROM ??) AS b diff --git a/packages/core/helper-plugin/lib/src/hooks/useTracking/index.js b/packages/core/helper-plugin/lib/src/hooks/useTracking/index.js index fec43d533d..37c29b8c1e 100644 --- a/packages/core/helper-plugin/lib/src/hooks/useTracking/index.js +++ b/packages/core/helper-plugin/lib/src/hooks/useTracking/index.js @@ -5,22 +5,31 @@ import useAppInfos from '../useAppInfos'; const useTracking = () => { const trackRef = useRef(); - const { uuid, telemetryProperties } = useContext(TrackingContext); + const { uuid, telemetryProperties, deviceId } = useContext(TrackingContext); const appInfo = useAppInfos(); + const userId = appInfo?.userId; trackRef.current = async (event, properties) => { if (uuid && !window.strapi.telemetryDisabled) { try { - await axios.post('https://analytics.strapi.io/track', { - event, - properties: { - ...telemetryProperties, - ...properties, - projectType: window.strapi.projectType, - environment: appInfo.currentEnvironment, + await axios.post( + 'https://analytics.strapi.io/api/v2/track', + { + event, + userId, + deviceId, + eventProperties: { ...properties }, + userProperties: {}, + groupProperties: { + ...telemetryProperties, + projectId: uuid, + projectType: window.strapi.projectType, + }, }, - uuid, - }); + { + headers: { 'Content-Type': 'application/json' }, + } + ); } catch (err) { // Silent } diff --git a/packages/core/helper-plugin/lib/src/hooks/useTracking/tests/index.test.js b/packages/core/helper-plugin/lib/src/hooks/useTracking/tests/index.test.js index 26e73eb764..c9a21fb27e 100644 --- a/packages/core/helper-plugin/lib/src/hooks/useTracking/tests/index.test.js +++ b/packages/core/helper-plugin/lib/src/hooks/useTracking/tests/index.test.js @@ -25,6 +25,7 @@ function setup(props) { telemetryProperties: { nestedProperty: true, }, + deviceId: 'someTestDeviceId', ...props, }} > @@ -45,21 +46,33 @@ describe('useTracking', () => { test('Call trackUsage() with all attributes', async () => { useAppInfos.mockReturnValue({ currentEnvironment: 'testing', + userId: 'someTestUserId', }); const { result } = await setup(); result.current.trackUsage('event', { trackingProperty: true }); - expect(axios.post).toBeCalledWith(expect.any(String), { - event: 'event', - uuid: 1, - properties: expect.objectContaining({ - environment: 'testing', - nestedProperty: true, - trackingProperty: true, - }), - }); + expect(axios.post).toBeCalledWith( + expect.any(String), + { + userId: 'someTestUserId', + deviceId: 'someTestDeviceId', + event: 'event', + eventProperties: { + trackingProperty: true, + }, + groupProperties: { + nestedProperty: true, + projectId: 1, + projectType: 'Community', + }, + userProperties: {}, + }, + { + headers: { 'Content-Type': 'application/json' }, + } + ); }); test('Do not track if it has been disabled', async () => { diff --git a/packages/core/strapi/lib/Strapi.js b/packages/core/strapi/lib/Strapi.js index 47847dfe39..c1085e847b 100644 --- a/packages/core/strapi/lib/Strapi.js +++ b/packages/core/strapi/lib/Strapi.js @@ -242,11 +242,14 @@ class Strapi { sendStartupTelemetry() { // Emit started event. // do not await to avoid slower startup + // This event is anonymous this.telemetry.send('didStartServer', { - database: strapi.config.get('database.connection.client'), - plugins: Object.keys(strapi.plugins), - // TODO: to add back - // providers: this.config.installedProviders, + groupProperties: { + database: strapi.config.get('database.connection.client'), + plugins: Object.keys(strapi.plugins), + // TODO: to add back + // providers: this.config.installedProviders, + }, }); } diff --git a/packages/core/strapi/lib/commands/opt-in-telemetry.js b/packages/core/strapi/lib/commands/opt-in-telemetry.js index 37ceea831b..f562de6512 100644 --- a/packages/core/strapi/lib/commands/opt-in-telemetry.js +++ b/packages/core/strapi/lib/commands/opt-in-telemetry.js @@ -53,12 +53,12 @@ const generateNewPackageJSON = (packageObj) => { const sendEvent = async (uuid) => { try { - await fetch('https://analytics.strapi.io/track', { + await fetch('https://analytics.strapi.io/api/v2/track', { method: 'POST', body: JSON.stringify({ event: 'didOptInTelemetry', - uuid, deviceId: machineID(), + groupProperties: { projectId: uuid }, }), headers: { 'Content-Type': 'application/json' }, }); diff --git a/packages/core/strapi/lib/commands/opt-out-telemetry.js b/packages/core/strapi/lib/commands/opt-out-telemetry.js index 6240786b79..3ec7dc1f24 100644 --- a/packages/core/strapi/lib/commands/opt-out-telemetry.js +++ b/packages/core/strapi/lib/commands/opt-out-telemetry.js @@ -28,12 +28,12 @@ const writePackageJSON = async (path, file, spacing) => { const sendEvent = async (uuid) => { try { - await fetch('https://analytics.strapi.io/track', { + await fetch('https://analytics.strapi.io/api/v2/track', { method: 'POST', body: JSON.stringify({ event: 'didOptOutTelemetry', - uuid, deviceId: machineID(), + groupProperties: { projectId: uuid }, }), headers: { 'Content-Type': 'application/json' }, }); diff --git a/packages/core/strapi/lib/services/metrics/__tests__/admin-user-hash.test.js b/packages/core/strapi/lib/services/metrics/__tests__/admin-user-hash.test.js new file mode 100644 index 0000000000..eb56742c32 --- /dev/null +++ b/packages/core/strapi/lib/services/metrics/__tests__/admin-user-hash.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const crypto = require('crypto'); +const { generateAdminUserHash } = require('../admin-user-hash'); +const createContext = require('../../../../../../../test/helpers/create-context'); + +describe('user email hash', () => { + test('should create a hash from admin user email', () => { + const state = { + user: { + email: 'testemail@strapi.io', + }, + }; + + const ctx = createContext({}, { state }); + + global.strapi = { + requestContext: { + get: jest.fn(() => ctx), + }, + }; + + const hash = crypto.createHash('sha256').update('testemail@strapi.io').digest('hex'); + + const userId = generateAdminUserHash(); + expect(userId).toBe(hash); + }); + + test('should return empty string if user is not available on ctx', () => { + const ctx = createContext({}, {}); + + global.strapi = { + requestContext: { + get: jest.fn(() => ctx), + }, + }; + + const userId = generateAdminUserHash(); + expect(userId).toBe(''); + }); +}); diff --git a/packages/core/strapi/lib/services/metrics/__tests__/index.test.js b/packages/core/strapi/lib/services/metrics/__tests__/index.test.js index 03d36c34f0..93c7721ba9 100644 --- a/packages/core/strapi/lib/services/metrics/__tests__/index.test.js +++ b/packages/core/strapi/lib/services/metrics/__tests__/index.test.js @@ -29,6 +29,9 @@ describe('metrics', () => { root: process.cwd(), }, }, + requestContext: { + get: jest.fn(() => ({})), + }, }); metricsInstance.register(); @@ -60,6 +63,9 @@ describe('metrics', () => { root: process.cwd(), }, }, + requestContext: { + get: jest.fn(() => ({})), + }, }); metricsInstance.register(); @@ -89,18 +95,21 @@ describe('metrics', () => { root: process.cwd(), }, }, + requestContext: { + get: jest.fn(() => ({})), + }, }); send('someEvent'); expect(fetch).toHaveBeenCalled(); - expect(fetch.mock.calls[0][0]).toBe('https://analytics.strapi.io/track'); + expect(fetch.mock.calls[0][0]).toBe('https://analytics.strapi.io/api/v2/track'); expect(fetch.mock.calls[0][1].method).toBe('POST'); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ event: 'someEvent', - uuid: 'test', - properties: { + groupProperties: { projectType: 'Community', + projectId: 'test', }, }); @@ -128,6 +137,9 @@ describe('metrics', () => { root: process.cwd(), }, }, + requestContext: { + get: jest.fn(() => ({})), + }, }); send('someEvent'); diff --git a/packages/core/strapi/lib/services/metrics/admin-user-hash.js b/packages/core/strapi/lib/services/metrics/admin-user-hash.js new file mode 100644 index 0000000000..e0ce672216 --- /dev/null +++ b/packages/core/strapi/lib/services/metrics/admin-user-hash.js @@ -0,0 +1,15 @@ +'use strict'; + +const crypto = require('crypto'); + +const generateAdminUserHash = () => { + const ctx = strapi?.requestContext?.get(); + if (!ctx?.state?.user) { + return ''; + } + return crypto.createHash('sha256').update(ctx.state.user.email).digest('hex'); +}; + +module.exports = { + generateAdminUserHash, +}; diff --git a/packages/core/strapi/lib/services/metrics/index.js b/packages/core/strapi/lib/services/metrics/index.js index 73cba26b27..908f2c98d8 100644 --- a/packages/core/strapi/lib/services/metrics/index.js +++ b/packages/core/strapi/lib/services/metrics/index.js @@ -55,10 +55,12 @@ const createTelemetryInstance = (strapi) => { return sendEvent( 'didCheckLicense', { - licenseInfo: { - ...ee.licenseInfo, - projectHash: hashProject(strapi), - dependencyHash: hashDep(strapi), + groupProperties: { + licenseInfo: { + ...ee.licenseInfo, + projectHash: hashProject(strapi), + dependencyHash: hashDep(strapi), + }, }, }, { diff --git a/packages/core/strapi/lib/services/metrics/middleware.js b/packages/core/strapi/lib/services/metrics/middleware.js index 7d9e7047e8..3868ff8205 100644 --- a/packages/core/strapi/lib/services/metrics/middleware.js +++ b/packages/core/strapi/lib/services/metrics/middleware.js @@ -19,7 +19,7 @@ const createMiddleware = ({ sendEvent }) => { // Send max. 1000 events per day. if (_state.counter < 1000) { - sendEvent('didReceiveRequest', { url: ctx.request.url }); + sendEvent('didReceiveRequest', { eventProperties: { url: ctx.request.url } }); // Increase counter. _state.counter += 1; diff --git a/packages/core/strapi/lib/services/metrics/sender.js b/packages/core/strapi/lib/services/metrics/sender.js index d1314207e7..13a2f50d9a 100644 --- a/packages/core/strapi/lib/services/metrics/sender.js +++ b/packages/core/strapi/lib/services/metrics/sender.js @@ -10,7 +10,7 @@ const { isUsingTypeScriptSync } = require('@strapi/typescript-utils'); const { env } = require('@strapi/utils'); const ee = require('../../utils/ee'); const machineID = require('../../utils/machine-id'); -const stringifyDeep = require('./stringify-deep'); +const { generateAdminUserHash } = require('./admin-user-hash'); const defaultQueryOpts = { timeout: 1000, @@ -42,41 +42,49 @@ module.exports = (strapi) => { const serverRootPath = strapi.dirs.app.root; const adminRootPath = path.join(strapi.dirs.app.root, 'src', 'admin'); - const anonymousMetadata = { + const anonymousUserProperties = { environment: strapi.config.environment, os: os.type(), osPlatform: os.platform(), osArch: os.arch(), osRelease: os.release(), nodeVersion: process.versions.node, + }; + + const anonymousGroupProperties = { docker: process.env.DOCKER || isDocker(), isCI: ciEnv.isCI, version: strapi.config.get('info.strapi'), projectType: isEE ? 'Enterprise' : 'Community', useTypescriptOnServer: isUsingTypeScriptSync(serverRootPath), useTypescriptOnAdmin: isUsingTypeScriptSync(adminRootPath), + projectId: uuid, isHostedOnStrapiCloud: env('STRAPI_HOSTING', null) === 'strapi.cloud', }; - addPackageJsonStrapiMetadata(anonymousMetadata, strapi); + addPackageJsonStrapiMetadata(anonymousGroupProperties, strapi); return async (event, payload = {}, opts = {}) => { + const userId = generateAdminUserHash(); + const reqParams = { method: 'POST', body: JSON.stringify({ event, - uuid, + userId, deviceId, - properties: stringifyDeep({ - ...payload, - ...anonymousMetadata, - }), + eventProperties: payload.eventProperties, + userProperties: userId ? { ...anonymousUserProperties, ...payload.userProperties } : {}, + groupProperties: { + ...anonymousGroupProperties, + ...payload.groupProperties, + }, }), ..._.merge({}, defaultQueryOpts, opts), }; try { - const res = await fetch(`${ANALYTICS_URI}/track`, reqParams); + const res = await fetch(`${ANALYTICS_URI}/api/v2/track`, reqParams); return res.ok; } catch (err) { return false; diff --git a/packages/core/strapi/lib/utils/success.js b/packages/core/strapi/lib/utils/success.js index 231442f0fb..ddd30c4af8 100644 --- a/packages/core/strapi/lib/utils/success.js +++ b/packages/core/strapi/lib/utils/success.js @@ -17,7 +17,7 @@ try { process.env.npm_config_global === 'true' || JSON.parse(process.env.npm_config_argv).original.includes('global') ) { - fetch('https://analytics.strapi.io/track', { + fetch('https://analytics.strapi.io/api/v2/track', { method: 'POST', body: JSON.stringify({ event: 'didInstallStrapi', diff --git a/packages/core/upload/server/controllers/admin-folder-file.js b/packages/core/upload/server/controllers/admin-folder-file.js index d3597e88bc..0e22d3ee07 100644 --- a/packages/core/upload/server/controllers/admin-folder-file.js +++ b/packages/core/upload/server/controllers/admin-folder-file.js @@ -40,10 +40,12 @@ module.exports = { if (deletedFiles.length + deletedFolders.length > 1) { strapi.telemetry.send('didBulkDeleteMediaLibraryElements', { - rootFolderNumber: deletedFolders.length, - rootAssetNumber: deletedFiles.length, - totalFolderNumber, - totalAssetNumber: totalFileNumber + deletedFiles.length, + eventProperties: { + rootFolderNumber: deletedFolders.length, + rootAssetNumber: deletedFiles.length, + totalFolderNumber, + totalAssetNumber: totalFileNumber + deletedFiles.length, + }, }); } @@ -229,10 +231,12 @@ module.exports = { }); strapi.telemetry.send('didBulkMoveMediaLibraryElements', { - rootFolderNumber: updatedFolders.length, - rootAssetNumber: updatedFiles.length, - totalFolderNumber, - totalAssetNumber: totalFileNumber + updatedFiles.length, + eventProperties: { + rootFolderNumber: updatedFolders.length, + rootAssetNumber: updatedFiles.length, + totalFolderNumber, + totalAssetNumber: totalFileNumber + updatedFiles.length, + }, }); ctx.body = { diff --git a/packages/core/upload/server/services/metrics.js b/packages/core/upload/server/services/metrics.js index c20ca46165..a0f4976871 100644 --- a/packages/core/upload/server/services/metrics.js +++ b/packages/core/upload/server/services/metrics.js @@ -91,7 +91,9 @@ module.exports = ({ strapi }) => ({ async sendMetrics() { const metrics = await this.computeMetrics(); - strapi.telemetry.send('didSendUploadPropertiesOnceAWeek', metrics); + strapi.telemetry.send('didSendUploadPropertiesOnceAWeek', { + groupProperties: { metrics }, + }); const metricsInfoStored = await getMetricsStoreValue(); await setMetricsStoreValue({ ...metricsInfoStored, lastWeeklyUpdate: new Date().getTime() }); diff --git a/packages/core/upload/server/services/upload.js b/packages/core/upload/server/services/upload.js index 484b98d910..9370772ea9 100644 --- a/packages/core/upload/server/services/upload.js +++ b/packages/core/upload/server/services/upload.js @@ -340,6 +340,7 @@ module.exports = ({ strapi }) => ({ if (user) { fileValues[UPDATED_BY_ATTRIBUTE] = user.id; } + sendMediaMetrics(fileValues); const res = await strapi.entityService.update(FILE_MODEL_UID, id, { data: fileValues }); @@ -355,6 +356,7 @@ module.exports = ({ strapi }) => ({ fileValues[UPDATED_BY_ATTRIBUTE] = user.id; fileValues[CREATED_BY_ATTRIBUTE] = user.id; } + sendMediaMetrics(fileValues); const res = await strapi.query(FILE_MODEL_UID).create({ data: fileValues }); diff --git a/packages/generators/app/lib/utils/usage.js b/packages/generators/app/lib/utils/usage.js index 27266b8db1..0d8a1a076e 100644 --- a/packages/generators/app/lib/utils/usage.js +++ b/packages/generators/app/lib/utils/usage.js @@ -53,33 +53,46 @@ function captureStderr(name, error) { return captureError(name); } -const getProperties = (scope, error) => ({ - error: typeof error === 'string' ? error : error && error.message, - os: os.type(), - osPlatform: os.platform(), - osArch: os.arch(), - osRelease: os.release(), - version: scope.strapiVersion, - nodeVersion: process.versions.node, - docker: scope.docker, - useYarn: scope.useYarn, - useTypescriptOnServer: scope.useTypescript, - useTypescriptOnAdmin: scope.useTypescript, - isHostedOnStrapiCloud: process.env.STRAPI_HOSTING === 'strapi.cloud', - noRun: (scope.runQuickstartApp !== true).toString(), -}); +const getProperties = (scope, error) => { + const eventProperties = { + error: typeof error === 'string' ? error : error && error.message, + }; + const userProperties = { + os: os.type(), + osPlatform: os.platform(), + osArch: os.arch(), + osRelease: os.release(), + nodeVersion: process.versions.node, + }; + const groupProperties = { + version: scope.strapiVersion, + docker: scope.docker, + useYarn: scope.useYarn, + useTypescriptOnServer: scope.useTypescript, + useTypescriptOnAdmin: scope.useTypescript, + isHostedOnStrapiCloud: process.env.STRAPI_HOSTING === 'strapi.cloud', + noRun: (scope.runQuickstartApp !== true).toString(), + projectId: scope.uuid, + }; -function trackEvent(event, body) { + return { + eventProperties, + userProperties, + groupProperties: addPackageJsonStrapiMetadata(groupProperties, scope), + }; +}; + +function trackEvent(event, payload) { if (process.env.NODE_ENV === 'test') { return; } try { - return fetch('https://analytics.strapi.io/track', { + return fetch('https://analytics.strapi.io/api/v2/track', { method: 'POST', body: JSON.stringify({ event, - ...body, + ...payload, }), timeout: 1000, headers: { 'Content-Type': 'application/json' }, @@ -91,14 +104,12 @@ function trackEvent(event, body) { } function trackError({ scope, error }) { - const { uuid } = scope; const properties = getProperties(scope, error); try { return trackEvent('didNotCreateProject', { - uuid, deviceId: scope.deviceId, - properties: addPackageJsonStrapiMetadata(properties, scope), + ...properties, }); } catch (err) { /** ignore errors */ @@ -107,14 +118,12 @@ function trackError({ scope, error }) { } function trackUsage({ event, scope, error }) { - const { uuid } = scope; const properties = getProperties(scope, error); try { return trackEvent(event, { - uuid, deviceId: scope.deviceId, - properties: addPackageJsonStrapiMetadata(properties, scope), + ...properties, }); } catch (err) { /** ignore errors */ diff --git a/packages/plugins/i18n/server/services/__tests__/metrics.test.js b/packages/plugins/i18n/server/services/__tests__/metrics.test.js index 341e921ee7..092a0317ea 100644 --- a/packages/plugins/i18n/server/services/__tests__/metrics.test.js +++ b/packages/plugins/i18n/server/services/__tests__/metrics.test.js @@ -44,7 +44,9 @@ describe('Metrics', () => { await sendDidInitializeEvent(); expect(strapi.telemetry.send).toHaveBeenCalledWith('didInitializeI18n', { - numberOfContentTypes: 1, + groupProperties: { + numberOfContentTypes: 1, + }, }); }); @@ -88,7 +90,9 @@ describe('Metrics', () => { await sendDidUpdateI18nLocalesEvent(); expect(strapi.telemetry.send).toHaveBeenCalledWith('didUpdateI18nLocales', { - numberOfLocales: 3, + groupProperties: { + numberOfLocales: 3, + }, }); }); }); diff --git a/packages/plugins/i18n/server/services/metrics.js b/packages/plugins/i18n/server/services/metrics.js index fabb2f8722..5cb77c5064 100644 --- a/packages/plugins/i18n/server/services/metrics.js +++ b/packages/plugins/i18n/server/services/metrics.js @@ -11,13 +11,15 @@ const sendDidInitializeEvent = async () => { 0 )(strapi.contentTypes); - await strapi.telemetry.send('didInitializeI18n', { numberOfContentTypes }); + await strapi.telemetry.send('didInitializeI18n', { groupProperties: { numberOfContentTypes } }); }; const sendDidUpdateI18nLocalesEvent = async () => { const numberOfLocales = await getService('locales').count(); - await strapi.telemetry.send('didUpdateI18nLocales', { numberOfLocales }); + await strapi.telemetry.send('didUpdateI18nLocales', { + groupProperties: { numberOfLocales }, + }); }; module.exports = () => ({