Merge pull request #3294 from umami-software/analytics

v2.17.0
This commit is contained in:
Mike Cao 2025-03-07 21:23:42 -08:00 committed by GitHub
commit 36c4645e5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 395 additions and 114 deletions

View file

@ -3,6 +3,7 @@
"browser": true, "browser": true,
"es2020": true, "es2020": true,
"node": true, "node": true,
"jquery": true,
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
@ -14,6 +15,7 @@
"sourceType": "module" "sourceType": "module"
}, },
"extends": [ "extends": [
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"eslint:recommended", "eslint:recommended",
"plugin:prettier/recommended", "plugin:prettier/recommended",
@ -33,12 +35,14 @@
"react/prop-types": "off", "react/prop-types": "off",
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off",
"import/no-named-as-default": "off", "import/no-named-as-default": "off",
"css-modules/no-unused-class": "off",
"@next/next/no-img-element": "off", "@next/next/no-img-element": "off",
"@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-empty-interface": "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": { "globals": {
"React": "writable" "React": "writable"

29
cypress/e2e/api.cy.ts Normal file
View file

@ -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');
});
});
});
});

View file

@ -1,22 +1,36 @@
describe('Login tests', () => { describe('Login tests', () => {
beforeEach(() => {
cy.visit('/login');
});
it( it(
'logs user in with correct credentials and logs user out', 'logs user in with correct credentials and logs user out',
{ {
defaultCommandTimeout: 10000, defaultCommandTimeout: 10000,
}, },
() => { () => {
cy.visit('/login'); cy.getDataTest('input-username').find('input').as('inputUsername').click();
cy.getDataTest('input-username').find('input').click(); cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user'), { delay: 50 }); cy.get('@inputUsername').click();
cy.getDataTest('input-password').find('input').click();
cy.getDataTest('input-password') cy.getDataTest('input-password')
.find('input') .find('input')
.type(Cypress.env('umami_password'), { delay: 50 }); .type(Cypress.env('umami_password'), { delay: 0 });
cy.getDataTest('button-submit').click(); cy.getDataTest('button-submit').click();
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard'); cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
cy.getDataTest('button-profile').click(); cy.logout();
cy.getDataTest('item-logout').click();
cy.url().should('eq', Cypress.config().baseUrl + '/login');
}, },
); );
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');
});
}); });

65
cypress/e2e/user.cy.ts Normal file
View file

@ -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();
});
});

View file

@ -10,10 +10,10 @@ describe('Website tests', () => {
cy.visit('/settings/websites'); cy.visit('/settings/websites');
cy.getDataTest('button-website-add').click(); cy.getDataTest('button-website-add').click();
cy.contains(/Add website/i).should('be.visible'); cy.contains(/Add website/i).should('be.visible');
cy.getDataTest('input-name').find('input').click(); cy.getDataTest('input-name').find('input').as('inputUsername').click();
cy.getDataTest('input-name').find('input').type('Add test', { delay: 50 }); cy.getDataTest('input-name').find('input').type('Add test', { delay: 0 });
cy.getDataTest('input-domain').find('input').click(); 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.getDataTest('button-submit').click();
cy.get('td[label="Name"]').should('contain.text', 'Add test'); cy.get('td[label="Name"]').should('contain.text', 'Add test');
cy.get('td[label="Domain"]').should('contain.text', 'addtest.com'); 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.contains(/Details/i).should('be.visible');
cy.getDataTest('input-name').find('input').click(); cy.getDataTest('input-name').find('input').click();
cy.getDataTest('input-name').find('input').clear(); 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').click();
cy.getDataTest('input-domain').find('input').clear(); 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('button-submit').click({ force: true });
cy.getDataTest('input-name').find('input').should('have.value', 'Updated website'); cy.getDataTest('input-name').find('input').should('have.value', 'Updated website');
cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com'); cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com');

View file

@ -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
}
}

View file

@ -5,6 +5,12 @@ Cypress.Commands.add('getDataTest', (value: string) => {
return cy.get(`[data-test=${value}]`); 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) => { Cypress.Commands.add('login', (username: string, password: string) => {
cy.session([username, password], () => { cy.session([username, password], () => {
cy.request({ cy.request({

View file

@ -1,4 +1,5 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
/* global JQuery */
declare namespace Cypress { declare namespace Cypress {
interface Chainable { interface Chainable {
@ -7,6 +8,11 @@ declare namespace Cypress {
* @example cy.getDataTest('greeting') * @example cy.getDataTest('greeting')
*/ */
getDataTest(value: string): Chainable<JQuery<HTMLElement>>; getDataTest(value: string): Chainable<JQuery<HTMLElement>>;
/**
* Custom command to logout through UI.
* @example cy.logout()
*/
logout(): Chainable<JQuery<HTMLElement>>;
/** /**
* Custom command to login user into the app. * Custom command to login user into the app.
* @example cy.login('admin', 'password) * @example cy.login('admin', 'password)

View file

@ -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 = [ const headers = [
{ {
source: '/api/:path*', source: '/api/:path*',
headers: [ headers: 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' },
],
}, },
{ {
source: '/:path*', source: '/:path*',
@ -89,6 +103,11 @@ if (trackerScriptURL) {
} }
if (collectApiEndpoint) { if (collectApiEndpoint) {
headers.push({
source: collectApiEndpoint,
headers: apiHeaders,
});
rewrites.push({ rewrites.push({
source: collectApiEndpoint, source: collectApiEndpoint,
destination: '/api/send', destination: '/api/send',

View file

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "2.16.1", "version": "2.17.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.", "description": "A simple, fast, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",

View file

@ -82,9 +82,11 @@ async function checkV1Tables() {
} }
async function applyMigration() { 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 () => { (async () => {

View file

@ -39,7 +39,9 @@ export function ReportDeleteButton({
<Modal title={formatMessage(labels.deleteReport)}> <Modal title={formatMessage(labels.deleteReport)}>
{(close: () => void) => ( {(close: () => void) => (
<ConfirmationForm <ConfirmationForm
message={formatMessage(messages.confirmDelete, { target: <b>{reportName}</b> })} message={formatMessage(messages.confirmDelete, {
target: <b key={messages.confirmDelete.id}>{reportName}</b>,
})}
isLoading={isPending} isLoading={isPending}
error={error} error={error}
onConfirm={handleConfirm.bind(null, close)} onConfirm={handleConfirm.bind(null, close)}

View file

@ -34,7 +34,9 @@ export function TeamLeaveForm({
return ( return (
<ConfirmationForm <ConfirmationForm
buttonLabel={formatMessage(labels.leave)} buttonLabel={formatMessage(labels.leave)}
message={formatMessage(messages.confirmLeave, { target: <b>{teamName}</b> })} message={formatMessage(messages.confirmLeave, {
target: <b key={messages.confirmLeave.id}>{teamName}</b>,
})}
onConfirm={handleConfirm} onConfirm={handleConfirm}
onClose={onClose} onClose={onClose}
isLoading={isPending} isLoading={isPending}

View file

@ -15,7 +15,7 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) {
return ( return (
<ModalTrigger> <ModalTrigger>
<Button variant="primary"> <Button data-test="button-create-user" variant="primary">
<Icon> <Icon>
<Icons.Plus /> <Icons.Plus />
</Icon> </Icon>

View file

@ -12,6 +12,7 @@ import {
} from 'react-basics'; } from 'react-basics';
import { useApi, useMessages } from '@/components/hooks'; import { useApi, useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { messages } from '@/components/messages';
export function UserAddForm({ onSave, onClose }) { export function UserAddForm({ onSave, onClose }) {
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
@ -44,26 +45,43 @@ export function UserAddForm({ onSave, onClose }) {
return ( return (
<Form onSubmit={handleSubmit} error={error}> <Form onSubmit={handleSubmit} error={error}>
<FormRow label={formatMessage(labels.username)}> <FormRow label={formatMessage(labels.username)}>
<FormInput name="username" rules={{ required: formatMessage(labels.required) }}> <FormInput
data-test="input-username"
name="username"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="new-username" /> <TextField autoComplete="new-username" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.password)}> <FormRow label={formatMessage(labels.password)}>
<FormInput name="password" rules={{ required: formatMessage(labels.required) }}> <FormInput
data-test="input-password"
name="password"
rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
}}
>
<PasswordField autoComplete="new-password" /> <PasswordField autoComplete="new-password" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.role)}> <FormRow label={formatMessage(labels.role)}>
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}> <FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
<Dropdown renderValue={renderValue}> <Dropdown data-test="dropdown-role" renderValue={renderValue}>
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item> <Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item> {formatMessage(labels.viewOnly)}
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item> </Item>
<Item data-test="dropdown-item-user" key={ROLES.user}>
{formatMessage(labels.user)}
</Item>
<Item data-test="dropdown-item-admin" key={ROLES.admin}>
{formatMessage(labels.admin)}
</Item>
</Dropdown> </Dropdown>
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormButtons flex> <FormButtons flex>
<SubmitButton variant="primary" disabled={false}> <SubmitButton data-test="button-submit" variant="primary" disabled={false}>
{formatMessage(labels.save)} {formatMessage(labels.save)}
</SubmitButton> </SubmitButton>
<Button disabled={isPending} onClick={onClose}> <Button disabled={isPending} onClick={onClose}>

View file

@ -16,7 +16,7 @@ export function UserDeleteButton({
return ( return (
<ModalTrigger disabled={userId === user?.id}> <ModalTrigger disabled={userId === user?.id}>
<Button disabled={userId === user?.id} variant="quiet"> <Button data-test="button-delete" disabled={userId === user?.id} variant="quiet">
<Icon> <Icon>
<Icons.Trash /> <Icons.Trash />
</Icon> </Icon>

View file

@ -19,7 +19,9 @@ export function UserDeleteForm({ userId, username, onSave, onClose }) {
return ( return (
<ConfirmationForm <ConfirmationForm
message={formatMessage(messages.confirmDelete, { target: <b>{username}</b> })} message={formatMessage(messages.confirmDelete, {
target: <b key={messages.confirmDelete.id}>{username}</b>,
})}
onConfirm={handleConfirm} onConfirm={handleConfirm}
onClose={onClose} onClose={onClose}
buttonLabel={formatMessage(labels.delete)} buttonLabel={formatMessage(labels.delete)}

View file

@ -44,7 +44,7 @@ export function UsersTable({
<> <>
<UserDeleteButton userId={id} username={username} /> <UserDeleteButton userId={id} username={username} />
<LinkButton href={`/settings/users/${id}`}> <LinkButton href={`/settings/users/${id}`}>
<Icon> <Icon data-test="link-button-edit">
<Icons.Edit /> <Icons.Edit />
</Icon> </Icon>
<Text>{formatMessage(labels.edit)}</Text> <Text>{formatMessage(labels.edit)}</Text>

View file

@ -62,6 +62,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.password)}> <FormRow label={formatMessage(labels.password)}>
<FormInput <FormInput
data-test="input-password"
name="password" name="password"
rules={{ rules={{
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) }, minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
@ -73,16 +74,24 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
{user.id !== login.id && ( {user.id !== login.id && (
<FormRow label={formatMessage(labels.role)}> <FormRow label={formatMessage(labels.role)}>
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}> <FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
<Dropdown renderValue={renderValue}> <Dropdown data-test="dropdown-role" renderValue={renderValue}>
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item> <Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item> {formatMessage(labels.viewOnly)}
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item> </Item>
<Item data-test="dropdown-item-user" key={ROLES.user}>
{formatMessage(labels.user)}
</Item>
<Item data-test="dropdown-item-admin" key={ROLES.admin}>
{formatMessage(labels.admin)}
</Item>
</Dropdown> </Dropdown>
</FormInput> </FormInput>
</FormRow> </FormRow>
)} )}
<FormButtons> <FormButtons>
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton> <SubmitButton data-test="button-submit" variant="primary">
{formatMessage(labels.save)}
</SubmitButton>
</FormButtons> </FormButtons>
</Form> </Form>
); );

View file

@ -43,7 +43,9 @@ export function TeamMemberRemoveButton({
<Modal title={formatMessage(labels.removeMember)}> <Modal title={formatMessage(labels.removeMember)}>
{(close: () => void) => ( {(close: () => void) => (
<ConfirmationForm <ConfirmationForm
message={formatMessage(messages.confirmRemove, { target: <b>{userName}</b> })} message={formatMessage(messages.confirmRemove, {
target: <b key={messages.confirmRemove.id}>{userName}</b>,
})}
isLoading={isPending} isLoading={isPending}
error={error} error={error}
onConfirm={handleConfirm.bind(null, close)} onConfirm={handleConfirm.bind(null, close)}

View file

@ -71,9 +71,10 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
if (__type === TYPE_EVENT) { if (__type === TYPE_EVENT) {
return formatMessage(messages.eventLog, { return formatMessage(messages.eventLog, {
event: <b>{eventName || formatMessage(labels.unknown)}</b>, event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>,
url: ( url: (
<a <a
key="a"
href={`//${website?.domain}${url}`} href={`//${website?.domain}${url}`}
className={styles.link} className={styles.link}
target="_blank" target="_blank"
@ -100,10 +101,10 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
if (__type === TYPE_SESSION) { if (__type === TYPE_SESSION) {
return formatMessage(messages.visitorLog, { return formatMessage(messages.visitorLog, {
country: <b>{countryNames[country] || formatMessage(labels.unknown)}</b>, country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>,
browser: <b>{BROWSERS[browser]}</b>, browser: <b key="browser">{BROWSERS[browser]}</b>,
os: <b>{OS_NAMES[os] || os}</b>, os: <b key="os">{OS_NAMES[os] || os}</b>,
device: <b>{formatMessage(labels[device] || labels.unknown)}</b>, device: <b key="device">{formatMessage(labels[device] || labels.unknown)}</b>,
}); });
} }
}; };

View file

@ -62,10 +62,10 @@ export function SessionsWeekly({ websiteId }: { websiteId: string }) {
<div className={styles.header}> <div className={styles.header}>
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })} {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
</div> </div>
{day?.map((hour: number) => { {day?.map((hour: number, j) => {
const pct = hour / max; const pct = hour / max;
return ( return (
<div key={hour} className={classNames(styles.cell)}> <div key={j} className={classNames(styles.cell)}>
{hour > 0 && ( {hour > 0 && (
<TooltipPopup <TooltipPopup
label={`${formatMessage(labels.visitors)}: ${hour}`} label={`${formatMessage(labels.visitors)}: ${hour}`}

View file

@ -26,7 +26,7 @@ export async function POST(request: Request) {
const user = await getUserByUsername(username, { includePassword: true }); const user = await getUserByUsername(username, { includePassword: true });
if (!user || !checkPassword(password, user.password)) { if (!user || !checkPassword(password, user.password)) {
return unauthorized(); return unauthorized('message.incorrect-username-password');
} }
const { id, role, createdAt } = user; const { id, role, createdAt } = user;

View file

@ -0,0 +1,39 @@
import { z } from 'zod';
import * as send from '@/app/api/send/route';
import { parseRequest } from '@/lib/request';
import { json, serverError } from '@/lib/response';
const schema = z.array(z.object({}).passthrough());
export async function POST(request: Request) {
try {
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (error) {
return error();
}
const errors = [];
let index = 0;
for (const data of body) {
const newRequest = new Request(request, { body: JSON.stringify(data) });
const response = await send.POST(newRequest);
if (!response.ok) {
errors.push({ index, response: await response.json() });
}
index++;
}
return json({
size: body.length,
processed: body.length - errors.length,
errors: errors.length,
details: errors,
});
} catch (e) {
return serverError(e);
}
}

View file

@ -9,8 +9,8 @@ export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
...reportParms, ...reportParms,
steps: z.coerce.number().min(3).max(7), steps: z.coerce.number().min(3).max(7),
startStep: z.string(), startStep: z.string().optional(),
endStep: z.string(), endStep: z.string().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -1,31 +1,34 @@
import { z } from 'zod'; import { z } from 'zod';
import { isbot } from 'isbot'; import { isbot } from 'isbot';
import { createToken, parseToken } from '@/lib/jwt'; import { startOfHour, startOfMonth } from 'date-fns';
import clickhouse from '@/lib/clickhouse'; import clickhouse from '@/lib/clickhouse';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, forbidden, serverError } from '@/lib/response'; import { badRequest, json, forbidden, serverError } from '@/lib/response';
import { fetchSession, fetchWebsite } from '@/lib/load'; import { fetchSession, fetchWebsite } from '@/lib/load';
import { getClientInfo, hasBlockedIp } from '@/lib/detect'; import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { secret, uuid, visitSalt } from '@/lib/crypto'; import { createToken, parseToken } from '@/lib/jwt';
import { COLLECTION_TYPE, DOMAIN_REGEX } from '@/lib/constants'; import { secret, uuid, hash } from '@/lib/crypto';
import { COLLECTION_TYPE } from '@/lib/constants';
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
import { createSession, saveEvent, saveSessionData } from '@/queries'; import { createSession, saveEvent, saveSessionData } from '@/queries';
import { urlOrPathParam } from '@/lib/schema';
const schema = z.object({ const schema = z.object({
type: z.enum(['event', 'identify']), type: z.enum(['event', 'identify']),
payload: z.object({ payload: z.object({
website: z.string().uuid(), website: z.string().uuid(),
data: z.object({}).passthrough().optional(), data: anyObjectParam.optional(),
hostname: z.string().regex(DOMAIN_REGEX).max(100).optional(), hostname: z.string().max(100).optional(),
language: z.string().max(35).optional(), language: z.string().max(35).optional(),
referrer: urlOrPathParam.optional(), referrer: urlOrPathParam.optional(),
screen: z.string().max(11).optional(), screen: z.string().max(11).optional(),
title: z.string().optional(), title: z.string().optional(),
url: urlOrPathParam, url: urlOrPathParam.optional(),
name: z.string().max(50).optional(), name: z.string().max(50).optional(),
tag: z.string().max(50).optional(), tag: z.string().max(50).optional(),
ip: z.string().ip().optional(), ip: z.string().ip().optional(),
userAgent: z.string().optional(), userAgent: z.string().optional(),
timestamp: z.coerce.number().int().optional(),
}), }),
}); });
@ -55,6 +58,7 @@ export async function POST(request: Request) {
data, data,
title, title,
tag, tag,
timestamp,
} = payload; } = payload;
// Cache check // Cache check
@ -87,7 +91,13 @@ export async function POST(request: Request) {
return forbidden(); return forbidden();
} }
const sessionId = uuid(websiteId, ip, userAgent); const createdAt = timestamp ? new Date(timestamp * 1000) : new Date();
const now = Math.floor(new Date().getTime() / 1000);
const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
const visitSalt = hash(startOfHour(createdAt).toUTCString());
const sessionId = uuid(websiteId, ip, userAgent, sessionSalt);
// Find session // Find session
if (!clickhouse.enabled && !cache?.sessionId) { if (!clickhouse.enabled && !cache?.sessionId) {
@ -119,13 +129,12 @@ export async function POST(request: Request) {
} }
// Visit info // Visit info
const now = Math.floor(new Date().getTime() / 1000); let visitId = cache?.visitId || uuid(sessionId, visitSalt);
let visitId = cache?.visitId || uuid(sessionId, visitSalt());
let iat = cache?.iat || now; let iat = cache?.iat || now;
// Expire visit after 30 minutes // Expire visit after 30 minutes
if (now - iat > 1800) { if (!timestamp && now - iat > 1800) {
visitId = uuid(sessionId, visitSalt()); visitId = uuid(sessionId, visitSalt);
iat = now; iat = now;
} }
@ -160,12 +169,12 @@ export async function POST(request: Request) {
websiteId, websiteId,
sessionId, sessionId,
visitId, visitId,
urlPath, urlPath: safeDecodeURI(urlPath),
urlQuery, urlQuery,
referrerPath, referrerPath: safeDecodeURI(referrerPath),
referrerQuery, referrerQuery,
referrerDomain, referrerDomain,
pageTitle: title, pageTitle: safeDecodeURIComponent(title),
eventName: name, eventName: name,
eventData: data, eventData: data,
hostname: hostname || urlDomain, hostname: hostname || urlDomain,
@ -179,6 +188,7 @@ export async function POST(request: Request) {
subdivision2, subdivision2,
city, city,
tag, tag,
createdAt,
}); });
} }
@ -191,12 +201,13 @@ export async function POST(request: Request) {
websiteId, websiteId,
sessionId, sessionId,
sessionData: data, sessionData: data,
createdAt,
}); });
} }
const token = createToken({ websiteId, sessionId, visitId, iat }, secret()); const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
return json({ cache: token }); return json({ cache: token, sessionId, visitId });
} catch (e) { } catch (e) {
return serverError(e); return serverError(e);
} }

View file

@ -1,9 +1,8 @@
import { z } from 'zod'; import { canDeleteUser, canUpdateUser, canViewUser, hashPassword } from '@/lib/auth';
import { canUpdateUser, canViewUser, canDeleteUser } from '@/lib/auth';
import { getUser, getUserByUsername, updateUser, deleteUser } from '@/queries';
import { json, unauthorized, badRequest, ok } from '@/lib/response';
import { hashPassword } from '@/lib/auth';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, unauthorized } from '@/lib/response';
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries';
import { z } from 'zod';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const { auth, error } = await parseRequest(request); const { auth, error } = await parseRequest(request);
@ -26,7 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({ const schema = z.object({
username: z.string().max(255), username: z.string().max(255),
password: z.string().max(255), password: z.string().max(255).optional(),
role: z.string().regex(/admin|user|view-only/i), role: z.string().regex(/admin|user|view-only/i),
}); });

View file

@ -8,6 +8,7 @@ import { createUser, getUserByUsername } from '@/queries';
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
id: z.string().uuid().optional(),
username: z.string().max(255), username: z.string().max(255),
password: z.string(), password: z.string(),
role: z.string().regex(/admin|user|view-only/i), role: z.string().regex(/admin|user|view-only/i),
@ -23,7 +24,7 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const { username, password, role } = body; const { id, username, password, role } = body;
const existingUser = await getUserByUsername(username, { showDeleted: true }); const existingUser = await getUserByUsername(username, { showDeleted: true });
@ -32,7 +33,7 @@ export async function POST(request: Request) {
} }
const user = await createUser({ const user = await createUser({
id: uuid(), id: id || uuid(),
username, username,
password: hashPassword(password), password: hashPassword(password),
role: role ?? ROLES.user, role: role ?? ROLES.user,

View file

@ -23,7 +23,7 @@ export default function ({ children }) {
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</head> </head>
<body> <body suppressHydrationWarning>
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>

View file

@ -16,7 +16,7 @@ import Logo from '@/assets/logo.svg';
import styles from './LoginForm.module.css'; import styles from './LoginForm.module.css';
export function LoginForm() { export function LoginForm() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels, getMessage } = useMessages();
const router = useRouter(); const router = useRouter();
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({ const { mutate, error, isPending } = useMutation({
@ -40,7 +40,7 @@ export function LoginForm() {
<Logo /> <Logo />
</Icon> </Icon>
<div className={styles.title}>umami</div> <div className={styles.title}>umami</div>
<Form className={styles.form} onSubmit={handleSubmit} error={error}> <Form className={styles.form} onSubmit={handleSubmit} error={getMessage(error)}>
<FormRow label={formatMessage(labels.username)}> <FormRow label={formatMessage(labels.username)}>
<FormInput <FormInput
data-test="input-username" data-test="input-username"

View file

@ -27,7 +27,12 @@ export function ConfirmationForm({
<Form error={error}> <Form error={error}>
<p>{message}</p> <p>{message}</p>
<FormButtons flex> <FormButtons flex>
<LoadingButton isLoading={isLoading} onClick={onConfirm} variant={buttonVariant}> <LoadingButton
data-test="button-confirm"
isLoading={isLoading}
onClick={onConfirm}
variant={buttonVariant}
>
{buttonLabel || formatMessage(labels.ok)} {buttonLabel || formatMessage(labels.ok)}
</LoadingButton> </LoadingButton>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button> <Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>

View file

@ -35,7 +35,9 @@ export function TypeConfirmationForm({
return ( return (
<Form onSubmit={onConfirm} error={error}> <Form onSubmit={onConfirm} error={error}>
<p> <p>
{formatMessage(messages.actionConfirmation, { confirmation: <b>{confirmationValue}</b> })} {formatMessage(messages.actionConfirmation, {
confirmation: <b key={messages.actionConfirmation.id}>{confirmationValue}</b>,
})}
</p> </p>
<FormRow label={formatMessage(labels.confirm)}> <FormRow label={formatMessage(labels.confirm)}>
<FormInput name="confirm" rules={{ validate: value => value === confirmationValue }}> <FormInput name="confirm" rules={{ validate: value => value === confirmationValue }}>

View file

@ -69,11 +69,10 @@ export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) {
if (!groups[domain]) { if (!groups[domain]) {
groups[domain] = 0; groups[domain] = 0;
} }
groups[domain] += y; groups[domain] += +y;
} else {
groups._other += y;
} }
} }
groups._other += +y;
} }
return Object.keys(groups) return Object.keys(groups)

View file

@ -397,6 +397,14 @@ export const PAID_AD_PARAMS = [
'epik=', 'epik=',
'ttclid=', 'ttclid=',
'scid=', '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 = [ export const GROUPED_DOMAINS = [

View file

@ -1,5 +1,4 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { startOfHour, startOfMonth } from 'date-fns';
import prand from 'pure-rand'; import prand from 'pure-rand';
import { v4, v5 } from 'uuid'; import { v4, v5 } from 'uuid';
@ -77,20 +76,8 @@ export function secret() {
return hash(process.env.APP_SECRET || process.env.DATABASE_URL); 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) { export function uuid(...args: any) {
if (!args.length) return v4(); if (!args.length) return v4();
return v5(hash(...args, salt()), v5.DNS); return v5(hash(...args, secret()), v5.DNS);
} }

View file

@ -192,7 +192,9 @@ async function parseFilters(
options: QueryOptions = {}, options: QueryOptions = {},
) { ) {
const website = await fetchWebsite(websiteId); const website = await fetchWebsite(websiteId);
const joinSession = Object.keys(filters).find(key => SESSION_COLUMNS.includes(key)); const joinSession = Object.keys(filters).find(key =>
['referrer', ...SESSION_COLUMNS].includes(key),
);
return { return {
joinSession: joinSession:

View file

@ -1,4 +1,4 @@
import { ZodObject } from 'zod'; import { z, ZodSchema } from 'zod';
import { FILTER_COLUMNS } from '@/lib/constants'; import { FILTER_COLUMNS } from '@/lib/constants';
import { badRequest, unauthorized } from '@/lib/response'; import { badRequest, unauthorized } from '@/lib/response';
import { getAllowedUnits, getMinimumUnit } from '@/lib/date'; import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
@ -15,7 +15,7 @@ export async function getJsonBody(request: Request) {
export async function parseRequest( export async function parseRequest(
request: Request, request: Request,
schema?: ZodObject<any>, schema?: ZodSchema,
options?: { skipAuth: boolean }, options?: { skipAuth: boolean },
): Promise<any> { ): Promise<any> {
const url = new URL(request.url); const url = new URL(request.url);
@ -24,12 +24,21 @@ export async function parseRequest(
let error: () => void | undefined; let error: () => void | undefined;
let auth = null; 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) { if (schema) {
const isGet = request.method === 'GET'; const isGet = request.method === 'GET';
const result = schema.safeParse(isGet ? query : body); const result = schema.safeParse(isGet ? query : body);
if (!result.success) { if (!result.success) {
error = () => badRequest(result.error); error = () => badRequest(getErrorMessages(result.error));
} else if (isGet) { } else if (isGet) {
query = result.data; query = result.data;
} else { } else {

View file

@ -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 roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']);
export const anyObjectParam = z.object({}).passthrough();
export const urlOrPathParam = z.string().refine( export const urlOrPathParam = z.string().refine(
value => { value => {
try { try {

View file

@ -29,6 +29,7 @@ export async function saveEvent(args: {
subdivision2?: string; subdivision2?: string;
city?: string; city?: string;
tag?: string; tag?: string;
createdAt?: Date;
}) { }) {
return runQuery({ return runQuery({
[PRISMA]: () => relationalQuery(args), [PRISMA]: () => relationalQuery(args),
@ -49,6 +50,7 @@ async function relationalQuery(data: {
eventName?: string; eventName?: string;
eventData?: any; eventData?: any;
tag?: string; tag?: string;
createdAt?: Date;
}) { }) {
const { const {
websiteId, websiteId,
@ -63,6 +65,7 @@ async function relationalQuery(data: {
eventData, eventData,
pageTitle, pageTitle,
tag, tag,
createdAt,
} = data; } = data;
const websiteEventId = uuid(); const websiteEventId = uuid();
@ -81,6 +84,7 @@ async function relationalQuery(data: {
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
tag, tag,
createdAt,
}, },
}); });
@ -92,6 +96,7 @@ async function relationalQuery(data: {
urlPath: urlPath?.substring(0, URL_LENGTH), urlPath: urlPath?.substring(0, URL_LENGTH),
eventName: eventName?.substring(0, EVENT_NAME_LENGTH), eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
eventData, eventData,
createdAt,
}); });
} }
@ -121,6 +126,7 @@ async function clickhouseQuery(data: {
subdivision2?: string; subdivision2?: string;
city?: string; city?: string;
tag?: string; tag?: string;
createdAt?: Date;
}) { }) {
const { const {
websiteId, websiteId,
@ -139,12 +145,12 @@ async function clickhouseQuery(data: {
subdivision2, subdivision2,
city, city,
tag, tag,
createdAt,
...args ...args
} = data; } = data;
const { insert, getUTCString } = clickhouse; const { insert, getUTCString } = clickhouse;
const { sendMessage } = kafka; const { sendMessage } = kafka;
const eventId = uuid(); const eventId = uuid();
const createdAt = getUTCString();
const message = { const message = {
...args, ...args,
@ -170,7 +176,7 @@ async function clickhouseQuery(data: {
event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
tag: tag, tag: tag,
created_at: createdAt, created_at: getUTCString(createdAt),
}; };
if (kafka.enabled) { if (kafka.enabled) {

View file

@ -15,7 +15,7 @@ export async function saveEventData(data: {
urlPath?: string; urlPath?: string;
eventName?: string; eventName?: string;
eventData: DynamicData; eventData: DynamicData;
createdAt?: string; createdAt?: Date;
}) { }) {
return runQuery({ return runQuery({
[PRISMA]: () => relationalQuery(data), [PRISMA]: () => relationalQuery(data),
@ -27,8 +27,9 @@ async function relationalQuery(data: {
websiteId: string; websiteId: string;
eventId: string; eventId: string;
eventData: DynamicData; eventData: DynamicData;
createdAt?: Date;
}): Promise<Prisma.BatchPayload> { }): Promise<Prisma.BatchPayload> {
const { websiteId, eventId, eventData } = data; const { websiteId, eventId, eventData, createdAt } = data;
const jsonKeys = flattenJSON(eventData); const jsonKeys = flattenJSON(eventData);
@ -42,6 +43,7 @@ async function relationalQuery(data: {
numberValue: a.dataType === DATA_TYPE.number ? a.value : null, numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null, dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
dataType: a.dataType, dataType: a.dataType,
createdAt,
})); }));
return prisma.client.eventData.createMany({ return prisma.client.eventData.createMany({
@ -56,7 +58,7 @@ async function clickhouseQuery(data: {
urlPath?: string; urlPath?: string;
eventName?: string; eventName?: string;
eventData: DynamicData; eventData: DynamicData;
createdAt?: string; createdAt?: Date;
}) { }) {
const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data; const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data;
@ -77,7 +79,7 @@ async function clickhouseQuery(data: {
string_value: getStringValue(value, dataType), string_value: getStringValue(value, dataType),
number_value: dataType === DATA_TYPE.number ? value : null, number_value: dataType === DATA_TYPE.number ? value : null,
date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null, date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
created_at: createdAt, created_at: getUTCString(createdAt),
}; };
}); });

View file

@ -11,6 +11,7 @@ export async function saveSessionData(data: {
websiteId: string; websiteId: string;
sessionId: string; sessionId: string;
sessionData: DynamicData; sessionData: DynamicData;
createdAt?: Date;
}) { }) {
return runQuery({ return runQuery({
[PRISMA]: () => relationalQuery(data), [PRISMA]: () => relationalQuery(data),
@ -22,9 +23,10 @@ export async function relationalQuery(data: {
websiteId: string; websiteId: string;
sessionId: string; sessionId: string;
sessionData: DynamicData; sessionData: DynamicData;
createdAt?: Date;
}) { }) {
const { client } = prisma; const { client } = prisma;
const { websiteId, sessionId, sessionData } = data; const { websiteId, sessionId, sessionData, createdAt } = data;
const jsonKeys = flattenJSON(sessionData); const jsonKeys = flattenJSON(sessionData);
@ -37,6 +39,7 @@ export async function relationalQuery(data: {
numberValue: a.dataType === DATA_TYPE.number ? a.value : null, numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null, dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
dataType: a.dataType, dataType: a.dataType,
createdAt,
})); }));
const existing = await client.sessionData.findMany({ const existing = await client.sessionData.findMany({
@ -77,12 +80,12 @@ async function clickhouseQuery(data: {
websiteId: string; websiteId: string;
sessionId: string; sessionId: string;
sessionData: DynamicData; sessionData: DynamicData;
createdAt?: Date;
}) { }) {
const { websiteId, sessionId, sessionData } = data; const { websiteId, sessionId, sessionData, createdAt } = data;
const { insert, getUTCString } = clickhouse; const { insert, getUTCString } = clickhouse;
const { sendMessage } = kafka; const { sendMessage } = kafka;
const createdAt = getUTCString();
const jsonKeys = flattenJSON(sessionData); const jsonKeys = flattenJSON(sessionData);
@ -95,7 +98,7 @@ async function clickhouseQuery(data: {
string_value: getStringValue(value, dataType), string_value: getStringValue(value, dataType),
number_value: dataType === DATA_TYPE.number ? value : null, number_value: dataType === DATA_TYPE.number ? value : null,
date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null, date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
created_at: createdAt, created_at: getUTCString(createdAt),
}; };
}); });

View file

@ -1,11 +1,12 @@
(window => { (window => {
const { const {
screen: { width, height }, screen: { width, height },
navigator: { language }, navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt },
location, location,
document, document,
history, history,
top, top,
doNotTrack,
} = window; } = window;
const { hostname, href, origin } = location; const { hostname, href, origin } = location;
const { currentScript, referrer } = document; const { currentScript, referrer } = document;
@ -21,6 +22,7 @@
const hostUrl = attr(_data + 'host-url'); const hostUrl = attr(_data + 'host-url');
const tag = attr(_data + 'tag'); const tag = attr(_data + 'tag');
const autoTrack = attr(_data + 'auto-track') !== _false; const autoTrack = attr(_data + 'auto-track') !== _false;
const dnt = attr(_data + 'do-not-track') === _true;
const excludeSearch = attr(_data + 'exclude-search') === _true; const excludeSearch = attr(_data + 'exclude-search') === _true;
const excludeHash = attr(_data + 'exclude-hash') === _true; const excludeHash = attr(_data + 'exclude-hash') === _true;
const domain = attr(_data + 'domains') || ''; const domain = attr(_data + 'domains') || '';
@ -46,6 +48,11 @@
tag: tag ? tag : undefined, tag: tag ? tag : undefined,
}); });
const hasDoNotTrack = () => {
const dnt = doNotTrack || ndnt || msdnt;
return dnt === 1 || dnt === '1' || dnt === 'yes';
};
/* Event handlers */ /* Event handlers */
const handlePush = (state, title, url) => { const handlePush = (state, title, url) => {
@ -182,7 +189,8 @@
disabled || disabled ||
!website || !website ||
(localStorage && localStorage.getItem('umami.disabled')) || (localStorage && localStorage.getItem('umami.disabled')) ||
(domain && !domains.includes(hostname)); (domain && !domains.includes(hostname)) ||
(dnt && hasDoNotTrack());
const send = async (payload, type = 'event') => { const send = async (payload, type = 'event') => {
if (trackingDisabled()) return; if (trackingDisabled()) return;