diff --git a/.eslintrc.json b/.eslintrc.json index 82f6a122d..9cbbd586a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,6 +3,7 @@ "browser": true, "es2020": true, "node": true, + "jquery": true, "jest": true }, "parser": "@typescript-eslint/parser", @@ -14,6 +15,7 @@ "sourceType": "module" }, "extends": [ + "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "eslint:recommended", "plugin:prettier/recommended", @@ -33,12 +35,14 @@ "react/prop-types": "off", "import/no-anonymous-default-export": "off", "import/no-named-as-default": "off", + "css-modules/no-unused-class": "off", "@next/next/no-img-element": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-empty-interface": "off", - "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }] + "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }], + "@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }] }, "globals": { "React": "writable" diff --git a/cypress/e2e/api.cy.ts b/cypress/e2e/api.cy.ts new file mode 100644 index 000000000..e69b5dff6 --- /dev/null +++ b/cypress/e2e/api.cy.ts @@ -0,0 +1,29 @@ +describe('Website tests', () => { + Cypress.session.clearAllSavedSessions(); + + beforeEach(() => { + cy.login(Cypress.env('umami_user'), Cypress.env('umami_password')); + }); + + //let userId; + + it('creates a user.', () => { + cy.fixture('users').then(data => { + const userPost = data.userPost; + cy.request({ + method: 'POST', + url: '/api/users', + headers: { + 'Content-Type': 'application/json', + Authorization: Cypress.env('authorization'), + }, + body: userPost, + }).then(response => { + //userId = response.body.id; + expect(response.status).to.eq(200); + expect(response.body).to.have.property('username', 'cypress1'); + expect(response.body).to.have.property('role', 'User'); + }); + }); + }); +}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index 5831c81d6..507b1b580 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -1,22 +1,36 @@ describe('Login tests', () => { + beforeEach(() => { + cy.visit('/login'); + }); + it( 'logs user in with correct credentials and logs user out', { defaultCommandTimeout: 10000, }, () => { - cy.visit('/login'); - cy.getDataTest('input-username').find('input').click(); - cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user'), { delay: 50 }); - cy.getDataTest('input-password').find('input').click(); + cy.getDataTest('input-username').find('input').as('inputUsername').click(); + cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 }); + cy.get('@inputUsername').click(); cy.getDataTest('input-password') .find('input') - .type(Cypress.env('umami_password'), { delay: 50 }); + .type(Cypress.env('umami_password'), { delay: 0 }); cy.getDataTest('button-submit').click(); cy.url().should('eq', Cypress.config().baseUrl + '/dashboard'); - cy.getDataTest('button-profile').click(); - cy.getDataTest('item-logout').click(); - cy.url().should('eq', Cypress.config().baseUrl + '/login'); + cy.logout(); }, ); + + it('login with blank inputs or incorrect credentials', () => { + cy.getDataTest('button-submit').click(); + cy.contains(/Required/i).should('be.visible'); + + cy.getDataTest('input-username').find('input').as('inputUsername'); + cy.get('@inputUsername').click(); + cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 }); + cy.get('@inputUsername').click(); + cy.getDataTest('input-password').find('input').type('wrongpassword', { delay: 0 }); + cy.getDataTest('button-submit').click(); + cy.contains(/Incorrect username and\/or password./i).should('be.visible'); + }); }); diff --git a/cypress/e2e/user.cy.ts b/cypress/e2e/user.cy.ts new file mode 100644 index 000000000..9f432f16a --- /dev/null +++ b/cypress/e2e/user.cy.ts @@ -0,0 +1,65 @@ +describe('Website tests', () => { + Cypress.session.clearAllSavedSessions(); + + beforeEach(() => { + cy.login(Cypress.env('umami_user'), Cypress.env('umami_password')); + cy.visit('/settings/users'); + }); + + it('Add a User', () => { + // add user + cy.contains(/Create user/i).should('be.visible'); + cy.getDataTest('button-create-user').click(); + cy.getDataTest('input-username').find('input').as('inputName').click(); + cy.get('@inputName').type('Test-user', { delay: 0 }); + cy.getDataTest('input-password').find('input').as('inputPassword').click(); + cy.get('@inputPassword').type('testPasswordCypress', { delay: 0 }); + cy.getDataTest('dropdown-role').click(); + cy.getDataTest('dropdown-item-user').click(); + cy.getDataTest('button-submit').click(); + cy.get('td[label="Username"]').should('contain.text', 'Test-user'); + cy.get('td[label="Role"]').should('contain.text', 'User'); + }); + + it('Edit a User role and password', () => { + // edit user + cy.get('table tbody tr') + .contains('td', /Test-user/i) + .parent() + .within(() => { + cy.getDataTest('link-button-edit').click(); // Clicks the button inside the row + }); + cy.getDataTest('input-password').find('input').as('inputPassword').click(); + cy.get('@inputPassword').type('newPassword', { delay: 0 }); + cy.getDataTest('dropdown-role').click(); + cy.getDataTest('dropdown-item-viewOnly').click(); + cy.getDataTest('button-submit').click(); + + cy.visit('/settings/users'); + cy.get('table tbody tr') + .contains('td', /Test-user/i) + .parent() + .should('contain.text', 'View only'); + + cy.logout(); + cy.url().should('eq', Cypress.config().baseUrl + '/login'); + cy.getDataTest('input-username').find('input').as('inputUsername').click(); + cy.get('@inputUsername').type('Test-user', { delay: 0 }); + cy.get('@inputUsername').click(); + cy.getDataTest('input-password').find('input').type('newPassword', { delay: 0 }); + cy.getDataTest('button-submit').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/dashboard'); + }); + + it('Delete a website', () => { + // delete user + cy.get('table tbody tr') + .contains('td', /Test-user/i) + .parent() + .within(() => { + cy.getDataTest('button-delete').click(); // Clicks the button inside the row + }); + cy.contains(/Are you sure you want to delete Test-user?/i).should('be.visible'); + cy.getDataTest('button-confirm').click(); + }); +}); diff --git a/cypress/e2e/website.cy.ts b/cypress/e2e/website.cy.ts index b60d8e7a4..2dcd60273 100644 --- a/cypress/e2e/website.cy.ts +++ b/cypress/e2e/website.cy.ts @@ -10,10 +10,10 @@ describe('Website tests', () => { cy.visit('/settings/websites'); cy.getDataTest('button-website-add').click(); cy.contains(/Add website/i).should('be.visible'); - cy.getDataTest('input-name').find('input').click(); - cy.getDataTest('input-name').find('input').type('Add test', { delay: 50 }); + cy.getDataTest('input-name').find('input').as('inputUsername').click(); + cy.getDataTest('input-name').find('input').type('Add test', { delay: 0 }); cy.getDataTest('input-domain').find('input').click(); - cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 50 }); + cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 0 }); cy.getDataTest('button-submit').click(); cy.get('td[label="Name"]').should('contain.text', 'Add test'); cy.get('td[label="Domain"]').should('contain.text', 'addtest.com'); @@ -41,10 +41,10 @@ describe('Website tests', () => { cy.contains(/Details/i).should('be.visible'); cy.getDataTest('input-name').find('input').click(); cy.getDataTest('input-name').find('input').clear(); - cy.getDataTest('input-name').find('input').type('Updated website', { delay: 50 }); + cy.getDataTest('input-name').find('input').type('Updated website', { delay: 0 }); cy.getDataTest('input-domain').find('input').click(); cy.getDataTest('input-domain').find('input').clear(); - cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 50 }); + cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 0 }); cy.getDataTest('button-submit').click({ force: true }); cy.getDataTest('input-name').find('input').should('have.value', 'Updated website'); cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com'); diff --git a/cypress/fixtures/users.json b/cypress/fixtures/users.json new file mode 100644 index 000000000..420a71c39 --- /dev/null +++ b/cypress/fixtures/users.json @@ -0,0 +1,17 @@ +{ + "userGet": { + "name": "cypress", + "email": "password", + "role": "User" + }, + "userPost": { + "username": "cypress1", + "password": "password", + "role": "User" + }, + "userDelete": { + "name": "Charlie", + "email": "charlie@example.com", + "age": 35 + } +} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 2c45142b3..a300b9691 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -5,6 +5,12 @@ Cypress.Commands.add('getDataTest', (value: string) => { return cy.get(`[data-test=${value}]`); }); +Cypress.Commands.add('logout', () => { + cy.getDataTest('button-profile').click(); + cy.getDataTest('item-logout').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/login'); +}); + Cypress.Commands.add('login', (username: string, password: string) => { cy.session([username, password], () => { cy.request({ diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 90cca19b2..e89b24dd8 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -1,4 +1,5 @@ /// +/* global JQuery */ declare namespace Cypress { interface Chainable { @@ -7,6 +8,11 @@ declare namespace Cypress { * @example cy.getDataTest('greeting') */ getDataTest(value: string): Chainable>; + /** + * Custom command to logout through UI. + * @example cy.logout() + */ + logout(): Chainable>; /** * Custom command to login user into the app. * @example cy.login('admin', 'password) diff --git a/next.config.js b/next.config.js index 7a65c4727..590d7121b 100644 --- a/next.config.js +++ b/next.config.js @@ -59,15 +59,29 @@ const trackerHeaders = [ }, ]; +const apiHeaders = [ + { + key: 'Access-Control-Allow-Origin', + value: '*' + }, + { + key: 'Access-Control-Allow-Headers', + value: '*' + }, + { + key: 'Access-Control-Allow-Methods', + value: 'GET, DELETE, POST, PUT' + }, + { + key: 'Access-Control-Max-Age', + value: corsMaxAge || '86400' + }, +]; + const headers = [ { source: '/api/:path*', - headers: [ - { key: 'Access-Control-Allow-Origin', value: '*' }, - { key: 'Access-Control-Allow-Headers', value: '*' }, - { key: 'Access-Control-Allow-Methods', value: 'GET, DELETE, POST, PUT' }, - { key: 'Access-Control-Max-Age', value: corsMaxAge || '86400' }, - ], + headers: apiHeaders }, { source: '/:path*', @@ -89,6 +103,11 @@ if (trackerScriptURL) { } if (collectApiEndpoint) { + headers.push({ + source: collectApiEndpoint, + headers: apiHeaders, + }); + rewrites.push({ source: collectApiEndpoint, destination: '/api/send', diff --git a/package.json b/package.json index 870769364..db146e9a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "2.16.1", + "version": "2.17.0", "description": "A simple, fast, privacy-focused alternative to Google Analytics.", "author": "Umami Software, Inc. ", "license": "MIT", diff --git a/scripts/check-db.js b/scripts/check-db.js index cdfeafa32..ca0fca31c 100644 --- a/scripts/check-db.js +++ b/scripts/check-db.js @@ -82,9 +82,11 @@ async function checkV1Tables() { } async function applyMigration() { - console.log(execSync('prisma migrate deploy').toString()); + if (!process.env.SKIP_DB_MIGRATION) { + console.log(execSync('prisma migrate deploy').toString()); - success('Database is up to date.'); + success('Database is up to date.'); + } } (async () => { diff --git a/src/app/(main)/reports/ReportDeleteButton.tsx b/src/app/(main)/reports/ReportDeleteButton.tsx index efd1da3c7..ca096675f 100644 --- a/src/app/(main)/reports/ReportDeleteButton.tsx +++ b/src/app/(main)/reports/ReportDeleteButton.tsx @@ -39,7 +39,9 @@ export function ReportDeleteButton({ {(close: () => void) => ( {reportName} })} + message={formatMessage(messages.confirmDelete, { + target: {reportName}, + })} isLoading={isPending} error={error} onConfirm={handleConfirm.bind(null, close)} diff --git a/src/app/(main)/settings/teams/TeamLeaveForm.tsx b/src/app/(main)/settings/teams/TeamLeaveForm.tsx index daf464341..389ba4ea7 100644 --- a/src/app/(main)/settings/teams/TeamLeaveForm.tsx +++ b/src/app/(main)/settings/teams/TeamLeaveForm.tsx @@ -34,7 +34,9 @@ export function TeamLeaveForm({ return ( {teamName} })} + message={formatMessage(messages.confirmLeave, { + target: {teamName}, + })} onConfirm={handleConfirm} onClose={onClose} isLoading={isPending} diff --git a/src/app/(main)/settings/users/UserAddButton.tsx b/src/app/(main)/settings/users/UserAddButton.tsx index e1b048420..674771b6e 100644 --- a/src/app/(main)/settings/users/UserAddButton.tsx +++ b/src/app/(main)/settings/users/UserAddButton.tsx @@ -15,7 +15,7 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) { return ( - diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx index baf5949f2..9ef5b30a0 100644 --- a/src/components/common/TypeConfirmationForm.tsx +++ b/src/components/common/TypeConfirmationForm.tsx @@ -35,7 +35,9 @@ export function TypeConfirmationForm({ return (

- {formatMessage(messages.actionConfirmation, { confirmation: {confirmationValue} })} + {formatMessage(messages.actionConfirmation, { + confirmation: {confirmationValue}, + })}

value === confirmationValue }}> diff --git a/src/components/metrics/ReferrersTable.tsx b/src/components/metrics/ReferrersTable.tsx index db40a6177..4d5a87c36 100644 --- a/src/components/metrics/ReferrersTable.tsx +++ b/src/components/metrics/ReferrersTable.tsx @@ -69,11 +69,10 @@ export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) { if (!groups[domain]) { groups[domain] = 0; } - groups[domain] += y; - } else { - groups._other += y; + groups[domain] += +y; } } + groups._other += +y; } return Object.keys(groups) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a64210eca..3eddefdcf 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -397,6 +397,14 @@ export const PAID_AD_PARAMS = [ 'epik=', 'ttclid=', 'scid=', + 'aid=', + 'pc_id=', + 'ad_id=', + 'rdt_cid=', + 'ob_click_id=', + 'utm_medium=cpc', + 'utm_medium=paid', + 'utm_medium=paid_social', ]; export const GROUPED_DOMAINS = [ diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index a4ff3a526..d22bad091 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,5 +1,4 @@ import crypto from 'crypto'; -import { startOfHour, startOfMonth } from 'date-fns'; import prand from 'pure-rand'; import { v4, v5 } from 'uuid'; @@ -77,20 +76,8 @@ export function secret() { return hash(process.env.APP_SECRET || process.env.DATABASE_URL); } -export function salt() { - const ROTATING_SALT = hash(startOfMonth(new Date()).toUTCString()); - - return hash(secret(), ROTATING_SALT); -} - -export function visitSalt() { - const ROTATING_SALT = hash(startOfHour(new Date()).toUTCString()); - - return hash(secret(), ROTATING_SALT); -} - export function uuid(...args: any) { if (!args.length) return v4(); - return v5(hash(...args, salt()), v5.DNS); + return v5(hash(...args, secret()), v5.DNS); } diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 9d9fd7db1..da2ca8a1b 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -148,7 +148,7 @@ export async function getClientInfo(request: Request, payload: Record SESSION_COLUMNS.includes(key)); + const joinSession = Object.keys(filters).find(key => + ['referrer', ...SESSION_COLUMNS].includes(key), + ); return { joinSession: diff --git a/src/lib/request.ts b/src/lib/request.ts index 9d32f89b3..374f1cbc1 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,4 +1,4 @@ -import { ZodObject } from 'zod'; +import { z, ZodSchema } from 'zod'; import { FILTER_COLUMNS } from '@/lib/constants'; import { badRequest, unauthorized } from '@/lib/response'; import { getAllowedUnits, getMinimumUnit } from '@/lib/date'; @@ -15,7 +15,7 @@ export async function getJsonBody(request: Request) { export async function parseRequest( request: Request, - schema?: ZodObject, + schema?: ZodSchema, options?: { skipAuth: boolean }, ): Promise { const url = new URL(request.url); @@ -24,12 +24,21 @@ export async function parseRequest( let error: () => void | undefined; let auth = null; + const getErrorMessages = (error: z.ZodError) => { + return Object.entries(error.format()) + .map(([key, value]) => { + const messages = (value as any)._errors; + return messages ? `${key}: ${messages.join(', ')}` : null; + }) + .filter(Boolean); + }; + if (schema) { const isGet = request.method === 'GET'; const result = schema.safeParse(isGet ? query : body); if (!result.success) { - error = () => badRequest(result.error); + error = () => badRequest(getErrorMessages(result.error)); } else if (isGet) { query = result.data; } else { diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 8df7be9fa..4e2b3e4a3 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -36,6 +36,8 @@ export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']); +export const anyObjectParam = z.object({}).passthrough(); + export const urlOrPathParam = z.string().refine( value => { try { diff --git a/src/queries/sql/events/saveEvent.ts b/src/queries/sql/events/saveEvent.ts index 65ee1175b..148b03f33 100644 --- a/src/queries/sql/events/saveEvent.ts +++ b/src/queries/sql/events/saveEvent.ts @@ -29,6 +29,7 @@ export async function saveEvent(args: { subdivision2?: string; city?: string; tag?: string; + createdAt?: Date; }) { return runQuery({ [PRISMA]: () => relationalQuery(args), @@ -49,6 +50,7 @@ async function relationalQuery(data: { eventName?: string; eventData?: any; tag?: string; + createdAt?: Date; }) { const { websiteId, @@ -63,6 +65,7 @@ async function relationalQuery(data: { eventData, pageTitle, tag, + createdAt, } = data; const websiteEventId = uuid(); @@ -81,6 +84,7 @@ async function relationalQuery(data: { eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, tag, + createdAt, }, }); @@ -92,6 +96,7 @@ async function relationalQuery(data: { urlPath: urlPath?.substring(0, URL_LENGTH), eventName: eventName?.substring(0, EVENT_NAME_LENGTH), eventData, + createdAt, }); } @@ -121,6 +126,7 @@ async function clickhouseQuery(data: { subdivision2?: string; city?: string; tag?: string; + createdAt?: Date; }) { const { websiteId, @@ -139,12 +145,12 @@ async function clickhouseQuery(data: { subdivision2, city, tag, + createdAt, ...args } = data; const { insert, getUTCString } = clickhouse; const { sendMessage } = kafka; const eventId = uuid(); - const createdAt = getUTCString(); const message = { ...args, @@ -170,7 +176,7 @@ async function clickhouseQuery(data: { event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, tag: tag, - created_at: createdAt, + created_at: getUTCString(createdAt), }; if (kafka.enabled) { diff --git a/src/queries/sql/events/saveEventData.ts b/src/queries/sql/events/saveEventData.ts index 7c158da40..16a5cab10 100644 --- a/src/queries/sql/events/saveEventData.ts +++ b/src/queries/sql/events/saveEventData.ts @@ -15,7 +15,7 @@ export async function saveEventData(data: { urlPath?: string; eventName?: string; eventData: DynamicData; - createdAt?: string; + createdAt?: Date; }) { return runQuery({ [PRISMA]: () => relationalQuery(data), @@ -27,8 +27,9 @@ async function relationalQuery(data: { websiteId: string; eventId: string; eventData: DynamicData; + createdAt?: Date; }): Promise { - const { websiteId, eventId, eventData } = data; + const { websiteId, eventId, eventData, createdAt } = data; const jsonKeys = flattenJSON(eventData); @@ -42,6 +43,7 @@ async function relationalQuery(data: { numberValue: a.dataType === DATA_TYPE.number ? a.value : null, dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null, dataType: a.dataType, + createdAt, })); return prisma.client.eventData.createMany({ @@ -56,7 +58,7 @@ async function clickhouseQuery(data: { urlPath?: string; eventName?: string; eventData: DynamicData; - createdAt?: string; + createdAt?: Date; }) { const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data; @@ -77,7 +79,7 @@ async function clickhouseQuery(data: { string_value: getStringValue(value, dataType), number_value: dataType === DATA_TYPE.number ? value : null, date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null, - created_at: createdAt, + created_at: getUTCString(createdAt), }; }); diff --git a/src/queries/sql/sessions/saveSessionData.ts b/src/queries/sql/sessions/saveSessionData.ts index 35f0c7126..a060e9a84 100644 --- a/src/queries/sql/sessions/saveSessionData.ts +++ b/src/queries/sql/sessions/saveSessionData.ts @@ -11,6 +11,7 @@ export async function saveSessionData(data: { websiteId: string; sessionId: string; sessionData: DynamicData; + createdAt?: Date; }) { return runQuery({ [PRISMA]: () => relationalQuery(data), @@ -22,9 +23,10 @@ export async function relationalQuery(data: { websiteId: string; sessionId: string; sessionData: DynamicData; + createdAt?: Date; }) { const { client } = prisma; - const { websiteId, sessionId, sessionData } = data; + const { websiteId, sessionId, sessionData, createdAt } = data; const jsonKeys = flattenJSON(sessionData); @@ -37,6 +39,7 @@ export async function relationalQuery(data: { numberValue: a.dataType === DATA_TYPE.number ? a.value : null, dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null, dataType: a.dataType, + createdAt, })); const existing = await client.sessionData.findMany({ @@ -77,12 +80,12 @@ async function clickhouseQuery(data: { websiteId: string; sessionId: string; sessionData: DynamicData; + createdAt?: Date; }) { - const { websiteId, sessionId, sessionData } = data; + const { websiteId, sessionId, sessionData, createdAt } = data; const { insert, getUTCString } = clickhouse; const { sendMessage } = kafka; - const createdAt = getUTCString(); const jsonKeys = flattenJSON(sessionData); @@ -95,7 +98,7 @@ async function clickhouseQuery(data: { string_value: getStringValue(value, dataType), number_value: dataType === DATA_TYPE.number ? value : null, date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null, - created_at: createdAt, + created_at: getUTCString(createdAt), }; }); diff --git a/src/tracker/index.js b/src/tracker/index.js index dbd47b7c6..c423a66b5 100644 --- a/src/tracker/index.js +++ b/src/tracker/index.js @@ -1,11 +1,12 @@ (window => { const { screen: { width, height }, - navigator: { language }, + navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt }, location, document, history, top, + doNotTrack, } = window; const { hostname, href, origin } = location; const { currentScript, referrer } = document; @@ -21,6 +22,7 @@ const hostUrl = attr(_data + 'host-url'); const tag = attr(_data + 'tag'); const autoTrack = attr(_data + 'auto-track') !== _false; + const dnt = attr(_data + 'do-not-track') === _true; const excludeSearch = attr(_data + 'exclude-search') === _true; const excludeHash = attr(_data + 'exclude-hash') === _true; const domain = attr(_data + 'domains') || ''; @@ -46,6 +48,11 @@ tag: tag ? tag : undefined, }); + const hasDoNotTrack = () => { + const dnt = doNotTrack || ndnt || msdnt; + return dnt === 1 || dnt === '1' || dnt === 'yes'; + }; + /* Event handlers */ const handlePush = (state, title, url) => { @@ -182,7 +189,8 @@ disabled || !website || (localStorage && localStorage.getItem('umami.disabled')) || - (domain && !domains.includes(hostname)); + (domain && !domains.includes(hostname)) || + (dnt && hasDoNotTrack()); const send = async (payload, type = 'event') => { if (trackingDisabled()) return;