Merge pull request #1479 from umami-software/dev

Merge Dev into Master
This commit is contained in:
Mike Cao 2022-09-05 18:40:21 -07:00 committed by GitHub
commit 9916265150
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
136 changed files with 3180 additions and 2600 deletions

View File

@ -4,7 +4,12 @@
"es2020": true,
"node": true
},
"extends": ["eslint:recommended", "plugin:prettier/recommended", "next"],
"extends": [
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:import/recommended",
"next"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
@ -12,7 +17,27 @@
"ecmaVersion": 11,
"sourceType": "module"
},
"settings": {
"import/resolver": {
"alias": {
"map": [
["assets", "./assets"],
["components", "./components"],
["db", "./db"],
["hooks", "./hooks"],
["lang", "./lang"],
["lib", "./lib"],
["public", "./public"],
["queries", "./queries"],
["store", "./store"],
["styles", "./styles"]
],
"extensions": [".ts", ".js", ".jsx", ".json"]
}
}
},
"rules": {
"no-console": "error",
"react/display-name": "off",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",

View File

@ -7,6 +7,7 @@ on: [push]
env:
DATABASE_TYPE: postgresql
SKIP_DB_CHECK: 1
jobs:
build:

3
.gitignore vendored
View File

@ -35,3 +35,6 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
*.dev.yml

View File

@ -20,7 +20,7 @@ ENV BASE_PATH $BASE_PATH
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
RUN yarn build-docker
# Production image, copy all the files and run next
FROM node:16-alpine AS runner

View File

@ -76,12 +76,12 @@ docker-compose up
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
```bash
docker pull docker.umami.dev/umami-software/umami:postgresql-latest
docker pull docker.umami.is/umami-software/umami:postgresql-latest
```
Or with MySQL support:
```bash
docker pull docker.umami.dev/umami-software/umami:mysql-latest
docker pull docker.umami.is/umami-software/umami:mysql-latest
```
## Getting updates
@ -92,7 +92,6 @@ To get the latest features, simply do a pull, install any new dependencies, and
git pull
yarn install
yarn build
yarn update-db
```
To update the Docker image, simply pull the new images and rebuild:

View File

@ -18,6 +18,10 @@ export const filterOptions = [
),
value: '24hour',
},
{
label: <FormattedMessage id="label.yesterday" defaultMessage="Yesterday" />,
value: '-1day',
},
{
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
value: '1week',

View File

@ -1,10 +1,10 @@
import React from 'react';
import Link from 'next/link';
import classNames from 'classnames';
import Link from 'next/link';
import { safeDecodeURI } from 'next-basics';
import usePageQuery from 'hooks/usePageQuery';
import { safeDecodeURI } from 'lib/url';
import Icon from './Icon';
import External from 'assets/arrow-up-right-from-square.svg';
import Icon from './Icon';
import styles from './FilterLink.module.css';
export default function FilterLink({ id, value, label, externalUrl }) {
@ -25,7 +25,7 @@ export default function FilterLink({ id, value, label, externalUrl }) {
</a>
</Link>
{externalUrl && (
<a href={externalUrl} target="_blank" rel="noreferrer noopener" className={styles.link}>
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
<Icon icon={<External />} className={styles.icon} />
</a>
)}

View File

@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './Loading.module.css';
function Loading({ className }) {
function Loading({ className, overlay = false }) {
return (
<div className={classNames(styles.loading, className)}>
<div className={classNames(styles.loading, { [styles.overlay]: overlay }, className)}>
<div />
<div />
<div />
@ -15,6 +15,7 @@ function Loading({ className }) {
Loading.propTypes = {
className: PropTypes.string,
overlay: PropTypes.bool,
};
export default Loading;

View File

@ -21,6 +21,14 @@
margin: 0;
}
.loading.overlay {
height: 100%;
width: 100%;
z-index: 10;
background: var(--gray400);
opacity: 0.4;
}
.loading div {
width: 10px;
height: 10px;
@ -30,6 +38,10 @@
animation-fill-mode: both;
}
.loading.overlay div {
background: var(--gray900);
}
.loading div + div {
margin-left: 10px;
}

View File

@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { setItem } from 'next-basics';
import ButtonLayout from 'components/layout/ButtonLayout';
import useStore, { checkVersion } from 'store/version';
import { setItem } from 'lib/web';
import { REPO_URL, VERSION_CHECK } from 'lib/constants';
import Button from './Button';
import styles from './UpdateNotice.module.css';

View File

@ -8,6 +8,7 @@ import FormLayout, {
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import Loading from 'components/common/Loading';
import useApi from 'hooks/useApi';
const CONFIRMATION_WORD = 'DELETE';
@ -29,8 +30,11 @@ const validate = ({ confirmation }) => {
export default function DeleteForm({ values, onSave, onClose }) {
const { del } = useApi();
const [message, setMessage] = useState();
const [deleting, setDeleting] = useState(false);
const handleSubmit = async ({ type, id }) => {
setDeleting(true);
const { ok, data } = await del(`/${type}/${id}`);
if (ok) {
@ -39,11 +43,14 @@ export default function DeleteForm({ values, onSave, onClose }) {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
setDeleting(false);
}
};
return (
<FormLayout>
{deleting && <Loading overlay />}
<Formik
initialValues={{ confirmation: '', ...values }}
validate={validate}

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import { setItem } from 'next-basics';
import { useRouter } from 'next/router';
import Button from 'components/common/Button';
import FormLayout, {
@ -11,7 +12,6 @@ import FormLayout, {
} from 'components/layout/FormLayout';
import Icon from 'components/common/Icon';
import useApi from 'hooks/useApi';
import { setItem } from 'lib/web';
import { AUTH_TOKEN } from 'lib/constants';
import { setUser } from 'store/app';
import Logo from 'assets/logo.svg';

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import { Formik, Form, Field, useFormikContext } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
@ -11,10 +11,14 @@ import FormLayout, {
import Checkbox from 'components/common/Checkbox';
import { DOMAIN_REGEX } from 'lib/constants';
import useApi from 'hooks/useApi';
import useFetch from 'hooks/useFetch';
import useUser from 'hooks/useUser';
import styles from './WebsiteEditForm.module.css';
const initialValues = {
name: '',
domain: '',
owner: '',
public: false,
};
@ -33,8 +37,45 @@ const validate = ({ name, domain }) => {
return errors;
};
const OwnerDropDown = ({ user, accounts }) => {
console.info(styles);
const { setFieldValue, values } = useFormikContext();
useEffect(() => {
if (values.user_id != null && values.owner === '') {
setFieldValue('owner', values.user_id.toString());
} else if (user?.user_id && values.owner === '') {
setFieldValue('owner', user.user_id.toString());
}
}, [accounts, setFieldValue, user, values]);
if (user?.is_admin) {
return (
<FormRow>
<label htmlFor="owner">
<FormattedMessage id="label.owner" defaultMessage="Owner" />
</label>
<div>
<Field as="select" name="owner" className={styles.dropdown}>
{accounts?.map(acc => (
<option key={acc.user_id} value={acc.user_id}>
{acc.username}
</option>
))}
</Field>
<FormError name="owner" />
</div>
</FormRow>
);
} else {
return null;
}
};
export default function WebsiteEditForm({ values, onSave, onClose }) {
const { post } = useApi();
const { data: accounts } = useFetch(`/accounts`);
const { user } = useUser();
const [message, setMessage] = useState();
const handleSubmit = async values => {
@ -72,10 +113,18 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
<FormattedMessage id="label.domain" defaultMessage="Domain" />
</label>
<div>
<Field name="domain" type="text" placeholder="example.com" />
<Field
name="domain"
type="text"
placeholder="example.com"
spellcheck="false"
autocapitalize="off"
autocorrect="off"
/>
<FormError name="domain" />
</div>
</FormRow>
<OwnerDropDown accounts={accounts} user={user} />
<FormRow>
<label />
<Field name="enable_share_url">

View File

@ -0,0 +1,5 @@
.dropdown {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}

View File

@ -24,7 +24,8 @@
flex: 1 1;
}
.row > div > input {
.row > div > input,
.row > div > select {
width: 100%;
min-width: 240px;
}

View File

@ -9,7 +9,7 @@ import HamburgerButton from 'components/common/HamburgerButton';
import UpdateNotice from 'components/common/UpdateNotice';
import UserButton from 'components/settings/UserButton';
import { HOMEPAGE_URL } from 'lib/constants';
import useConfig from '/hooks/useConfig';
import useConfig from 'hooks/useConfig';
import useUser from 'hooks/useUser';
import Logo from 'assets/logo.svg';
import styles from './Header.module.css';

View File

@ -1,26 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Page.module.css';
export default class Page extends React.Component {
getSnapshotBeforeUpdate() {
if (window.pageXOffset === 0 && window.pageYOffset === 0) return null;
// Return the scrolled position as the snapshot value
return { x: window.pageXOffset, y: window.pageYOffset };
}
/* eslint-disable no-unused-vars */
componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot !== null) {
// Restore the scrolled position after re-rendering
window.scrollTo(snapshot.x, snapshot.y);
}
}
/* eslint-enable no-unused-vars */
render() {
const { className, children } = this.props;
export default function Page({ className, children }) {
return <div className={classNames(styles.page, className)}>{children}</div>;
}
}

View File

@ -1,8 +1,8 @@
import React from 'react';
import classNames from 'classnames';
import { safeDecodeURI } from 'next-basics';
import Button from 'components/common/Button';
import Times from 'assets/times.svg';
import { safeDecodeURI } from 'lib/url';
import styles from './FilterTags.module.css';
export default function FilterTags({ params, onClick }) {

View File

@ -29,9 +29,9 @@ const MetricCard = ({
: !reverseColors
? styles.negative
: styles.positive
}`}
} ${change >= 0 ? styles.plusSign : ''}`}
>
{changeProps.x.interpolate(x => `${change >= 0 ? '+' : ''}${format(x)}`)}
{changeProps.x.interpolate(x => format(x))}
</animated.span>
)}
</div>

View File

@ -37,3 +37,7 @@
.change.negative {
color: var(--red500);
}
.change.plusSign::before {
content: '+';
}

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import firstBy from 'thenby';
import classNames from 'classnames';
import Link from 'components/common/Link';
@ -14,6 +14,10 @@ import DataTable from './DataTable';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './MetricsTable.module.css';
const messages = defineMessages({
more: { id: 'label.more', defaultMessage: 'More' },
});
export default function MetricsTable({
websiteId,
type,
@ -31,6 +35,7 @@ export default function MetricsTable({
router,
query: { url, referrer, os, browser, device, country },
} = usePageQuery();
const { formatMessage } = useIntl();
const { data, loading, error } = useFetch(
`/website/${websiteId}/metrics`,
@ -80,7 +85,7 @@ export default function MetricsTable({
size="small"
iconRight
>
<FormattedMessage id="label.more" defaultMessage="More" />
{formatMessage(messages.more)}
</Link>
)}
</div>

View File

@ -1,10 +1,10 @@
import { useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import MetricsTable from './MetricsTable';
import { safeDecodeURI } from 'next-basics';
import Tag from 'components/common/Tag';
import FilterButtons from 'components/common/FilterButtons';
import { paramFilter } from 'lib/filters';
import { safeDecodeURI } from 'lib/url';
import FilterButtons from '../common/FilterButtons';
import MetricsTable from './MetricsTable';
const FILTER_COMBINED = 0;
const FILTER_RAW = 1;

View File

@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import Button from 'components/common/Button';
import { sortArrayByMap } from 'lib/array';
import { firstBy } from 'thenby';
import useDashboard, { saveDashboard } from 'store/dashboard';
import styles from './DashboardEdit.module.css';
@ -21,9 +21,13 @@ export default function DashboardEdit({ websites }) {
const { formatMessage } = useIntl();
const [order, setOrder] = useState(websiteOrder || []);
const ordered = useMemo(() => sortArrayByMap(websites, order, 'website_id'), [websites, order]);
console.log({ order, ordered });
const ordered = useMemo(
() =>
websites
.map(website => ({ ...website, order: order.indexOf(website.website_id) }))
.sort(firstBy('order')),
[websites, order],
);
function handleWebsiteDrag({ destination, source }) {
if (!destination || destination.index === source.index) return;
@ -32,7 +36,7 @@ export default function DashboardEdit({ websites }) {
const [removed] = orderedWebsites.splice(source.index, 1);
orderedWebsites.splice(destination.index, 0, removed);
setOrder(orderedWebsites.map(({ website_id }) => website_id));
setOrder(orderedWebsites.map((website) => website?.website_id || 0));
}
function handleSave() {

View File

@ -28,8 +28,6 @@ export default function TestConsole() {
const website = data.find(({ website_id }) => website_id === +websiteId);
const selectedValue = options.find(({ value }) => value === website?.website_id)?.value;
console.log({ websiteId, data, options, website });
function handleSelect(value) {
router.push(`/console/${value}`);
}

View File

@ -6,8 +6,8 @@ import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteList.module.css';
import useDashboard from 'store/dashboard';
import { sortArrayByMap } from 'lib/array';
import { useMemo } from 'react';
import { firstBy } from 'thenby';
const messages = defineMessages({
noWebsites: {
@ -24,10 +24,11 @@ export default function WebsiteList({ websites, showCharts, limit }) {
const { websiteOrder } = useDashboard();
const { formatMessage } = useIntl();
console.log({ websiteOrder });
const ordered = useMemo(
() => sortArrayByMap(websites, websiteOrder, 'website_id'),
() =>
websites
.map(website => ({ ...website, order: websiteOrder.indexOf(website.website_id) || 0 }))
.sort(firstBy('order')),
[websites, websiteOrder],
);

View File

@ -1,11 +1,11 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useRouter } from 'next/router';
import { removeItem } from 'next-basics';
import MenuButton from 'components/common/MenuButton';
import Icon from 'components/common/Icon';
import User from 'assets/user.svg';
import styles from './UserButton.module.css';
import { removeItem } from 'lib/web';
import { AUTH_TOKEN } from 'lib/constants';
import useUser from 'hooks/useUser';

View File

@ -0,0 +1,130 @@
SET allow_experimental_object_type = 1;
-- Create Pageview
CREATE TABLE pageview
(
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
referrer String
)
engine = MergeTree PRIMARY KEY (session_uuid, created_at)
ORDER BY (session_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE pageview_queue (
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
referrer String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'pageview',
kafka_group_name = 'pageview_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW pageview_queue_mv TO pageview AS
SELECT website_id,
session_uuid,
created_at,
url,
referrer
FROM pageview_queue;
-- Create Session
CREATE TABLE session
(
session_uuid UUID,
website_id UInt32,
created_at DateTime('UTC'),
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String)
)
engine = MergeTree PRIMARY KEY (session_uuid, created_at)
ORDER BY (session_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE session_queue (
session_uuid UUID,
website_id UInt32,
created_at DateTime('UTC'),
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String)
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'session',
kafka_group_name = 'session_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW session_queue_mv TO session AS
SELECT session_uuid,
website_id,
created_at,
hostname,
browser,
os,
device,
screen,
language,
country
FROM session_queue;
-- Create event
CREATE TABLE event
(
event_uuid UUID,
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
event_name String,
event_data JSON
)
engine = MergeTree PRIMARY KEY (event_uuid, created_at)
ORDER BY (event_uuid, created_at)
SETTINGS index_granularity = 8192;
CREATE TABLE event_queue (
event_uuid UUID,
website_id UInt32,
session_uuid UUID,
created_at DateTime('UTC'),
url String,
event_name String,
event_data String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka1:19092,kafka2:19093,kafka3:19094', -- input broker list
kafka_topic_list = 'event',
kafka_group_name = 'event_consumer_group',
kafka_format = 'JSONEachRow',
kafka_max_block_size = 1048576,
kafka_skip_broken_messages = 1;
CREATE MATERIALIZED VIEW event_queue_mv TO event AS
SELECT event_uuid,
website_id,
session_uuid,
created_at,
url,
event_name,
event_data
FROM event_queue;

View File

@ -0,0 +1,35 @@
-- DropForeignKey
ALTER TABLE `event` DROP FOREIGN KEY `event_ibfk_2`;
-- DropForeignKey
ALTER TABLE `event` DROP FOREIGN KEY `event_ibfk_1`;
-- DropForeignKey
ALTER TABLE `pageview` DROP FOREIGN KEY `pageview_ibfk_2`;
-- DropForeignKey
ALTER TABLE `pageview` DROP FOREIGN KEY `pageview_ibfk_1`;
-- DropForeignKey
ALTER TABLE `session` DROP FOREIGN KEY `session_ibfk_1`;
-- DropForeignKey
ALTER TABLE `website` DROP FOREIGN KEY `website_ibfk_1`;
-- AddForeignKey
ALTER TABLE `event` ADD CONSTRAINT `event_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `session`(`session_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `event` ADD CONSTRAINT `event_website_id_fkey` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `pageview` ADD CONSTRAINT `pageview_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `session`(`session_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `pageview` ADD CONSTRAINT `pageview_website_id_fkey` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `session` ADD CONSTRAINT `session_website_id_fkey` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `website` ADD CONSTRAINT `website_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `account`(`user_id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -24,8 +24,8 @@ model event {
created_at DateTime? @default(now()) @db.Timestamp(0)
url String @db.VarChar(500)
event_name String @db.VarChar(50)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade, onUpdate: NoAction, map: "event_ibfk_2")
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade, onUpdate: NoAction, map: "event_ibfk_1")
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
event_data event_data?
@@index([created_at])
@ -34,7 +34,7 @@ model event {
}
model event_data {
event_data_id Int @id @default(autoincrement())
event_data_id Int @id @default(autoincrement()) @db.UnsignedInt
event_id Int @unique @db.UnsignedInt
event_data Json
event event @relation(fields: [event_id], references: [event_id])
@ -47,8 +47,8 @@ model pageview {
created_at DateTime? @default(now()) @db.Timestamp(0)
url String @db.VarChar(500)
referrer String? @db.VarChar(500)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade, onUpdate: NoAction, map: "pageview_ibfk_2")
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade, onUpdate: NoAction, map: "pageview_ibfk_1")
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
@@index([created_at])
@@index([session_id])
@ -69,7 +69,7 @@ model session {
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade, onUpdate: NoAction, map: "session_ibfk_1")
website website @relation(fields: [website_id], references: [website_id])
event event[]
pageview pageview[]
@ -85,7 +85,7 @@ model website {
domain String? @db.VarChar(500)
share_id String? @unique() @db.VarChar(64)
created_at DateTime? @default(now()) @db.Timestamp(0)
account account @relation(fields: [user_id], references: [user_id], onDelete: Cascade, onUpdate: NoAction, map: "website_ibfk_1")
account account @relation(fields: [user_id], references: [user_id])
event event[]
pageview pageview[]
session session[]

View File

@ -0,0 +1,35 @@
-- DropForeignKey
ALTER TABLE "event" DROP CONSTRAINT IF EXISTS "event_session_id_fkey";
-- DropForeignKey
ALTER TABLE "event" DROP CONSTRAINT IF EXISTS "event_website_id_fkey";
-- DropForeignKey
ALTER TABLE "pageview" DROP CONSTRAINT IF EXISTS "pageview_session_id_fkey";
-- DropForeignKey
ALTER TABLE "pageview" DROP CONSTRAINT IF EXISTS "pageview_website_id_fkey";
-- DropForeignKey
ALTER TABLE "session" DROP CONSTRAINT IF EXISTS "session_website_id_fkey";
-- DropForeignKey
ALTER TABLE "website" DROP CONSTRAINT IF EXISTS "website_user_id_fkey";
-- AddForeignKey
ALTER TABLE "event" ADD CONSTRAINT "event_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "session"("session_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "event" ADD CONSTRAINT "event_website_id_fkey" FOREIGN KEY ("website_id") REFERENCES "website"("website_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pageview" ADD CONSTRAINT "pageview_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "session"("session_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pageview" ADD CONSTRAINT "pageview_website_id_fkey" FOREIGN KEY ("website_id") REFERENCES "website"("website_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_website_id_fkey" FOREIGN KEY ("website_id") REFERENCES "website"("website_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "website" ADD CONSTRAINT "website_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "account"("user_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -24,8 +24,8 @@ model event {
created_at DateTime? @default(now()) @db.Timestamptz(6)
url String @db.VarChar(500)
event_name String @db.VarChar(50)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade)
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade)
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
event_data event_data?
@@index([created_at])
@ -47,8 +47,8 @@ model pageview {
created_at DateTime? @default(now()) @db.Timestamptz(6)
url String @db.VarChar(500)
referrer String? @db.VarChar(500)
session session @relation(fields: [session_id], references: [session_id], onDelete: Cascade)
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade)
session session @relation(fields: [session_id], references: [session_id])
website website @relation(fields: [website_id], references: [website_id])
@@index([created_at])
@@index([session_id])
@ -69,9 +69,9 @@ model session {
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
website website @relation(fields: [website_id], references: [website_id], onDelete: Cascade)
pageview pageview[]
website website @relation(fields: [website_id], references: [website_id])
event event[]
pageview pageview[]
@@index([created_at])
@@index([website_id])
@ -85,10 +85,10 @@ model website {
domain String? @db.VarChar(500)
share_id String? @unique @db.VarChar(64)
created_at DateTime? @default(now()) @db.Timestamptz(6)
account account @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
account account @relation(fields: [user_id], references: [user_id])
event event[]
pageview pageview[]
session session[]
event event[]
@@index([user_id])
}

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { useRouter } from 'next/router';
import { get, post, put, del, getItem } from 'lib/web';
import { get, post, put, del, getItem } from 'next-basics';
import { AUTH_TOKEN, SHARE_TOKEN_HEADER } from 'lib/constants';
import useStore from 'store/app';

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { get } from 'lib/web';
import { get } from 'next-basics';
import enUS from 'public/intl/country/en-US.json';
const countryNames = {

View File

@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { parseISO } from 'date-fns';
import { getDateRange } from 'lib/date';
import { getItem, setItem } from 'lib/web';
import { getItem, setItem } from 'next-basics';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants';
import useForceUpdate from './useForceUpdate';
import useLocale from './useLocale';

View File

@ -8,7 +8,7 @@ export default function useFetch(url, options = {}, update = []) {
const [loading, setLoading] = useState(false);
const [count, setCount] = useState(0);
const { get } = useApi();
const { params = {}, headers = {}, disabled, delay = 0, interval, onDataLoad } = options;
const { params = {}, headers = {}, disabled = false, delay = 0, interval, onDataLoad } = options;
async function loadData(params) {
try {
@ -29,7 +29,9 @@ export default function useFetch(url, options = {}, update = []) {
onDataLoad?.(data);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
setError(e);
} finally {
setLoading(false);
@ -44,7 +46,7 @@ export default function useFetch(url, options = {}, update = []) {
clearTimeout(id);
};
}
}, [url, !!disabled, count, ...update]);
}, [url, disabled, count, ...update]);
useEffect(() => {
if (interval && !disabled) {
@ -54,7 +56,7 @@ export default function useFetch(url, options = {}, update = []) {
clearInterval(id);
};
}
}, [interval, !!disabled]);
}, [interval, disabled]);
return { ...response, error, loading };
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { get } from 'lib/web';
import { get } from 'next-basics';
import enUS from 'public/intl/language/en-US.json';
const languageNames = {

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { get, setItem } from 'lib/web';
import { get, setItem } from 'next-basics';
import { LOCALE_CONFIG } from 'lib/constants';
import { getDateLocale, getTextDirection } from 'lib/lang';
import useStore, { setLocale } from 'store/app';
@ -48,5 +48,14 @@ export default function useLocale() {
}
}, [locale]);
useEffect(() => {
const url = new URL(window.location.href);
const locale = url.searchParams.get('locale');
if (locale) {
saveLocale(locale);
}
}, []);
return { locale, saveLocale, messages, dir, dateLocale };
}

View File

@ -1,10 +1,11 @@
import { useMemo } from 'react';
import { useRouter } from 'next/router';
import { getQueryString } from 'lib/url';
import { buildUrl } from 'next-basics';
export default function usePageQuery() {
const router = useRouter();
const { pathname, search } = location;
const { asPath } = router;
const query = useMemo(() => {
if (!search) {
@ -23,11 +24,7 @@ export default function usePageQuery() {
}, [search]);
function resolve(params) {
const search = getQueryString({ ...query, ...params });
const { asPath } = router;
return `${asPath.split('?')[0]}${search}`;
return buildUrl(asPath.split('?')[0], { ...query, ...params });
}
return { pathname, query, resolve, router };

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import useStore, { setTheme } from 'store/app';
import { getItem, setItem } from 'lib/web';
import { getItem, setItem } from 'next-basics';
import { THEME_CONFIG } from 'lib/constants';
const selector = state => state.theme;
@ -23,5 +23,14 @@ export default function useTheme() {
document.body.setAttribute('data-theme', theme);
}, [theme]);
useEffect(() => {
const url = new URL(window.location.href);
const theme = url.searchParams.get('theme');
if (['light', 'dark'].includes(theme)) {
saveTheme(theme);
}
}, []);
return [theme, saveTheme];
}

View File

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { getTimezone } from 'lib/date';
import { getItem, setItem } from 'lib/web';
import { getItem, setItem } from 'next-basics';
import { TIMEZONE_CONFIG } from 'lib/constants';
export default function useTimezone() {

View File

@ -62,6 +62,7 @@
"label.username": "Nom d'usuari",
"label.view-details": "Veure els detalls",
"label.websites": "Llocs web",
"label.yesterday": "Ahir",
"message.active-users": "{x} {x, plural, one {visitant actual} other {visitants actuals}}",
"message.confirm-delete": "Segur que vols esborrar {target}?",
"message.confirm-reset": "Segur que vols restablir les estadístiques de {target}?",

View File

@ -62,6 +62,7 @@
"label.username": "Benutzername",
"label.view-details": "Details anzeigen",
"label.websites": "Webseiten",
"label.yesterday": "Gestern",
"message.active-users": "{x} {x, plural, one {aktiver Besucher} other {aktive Besucher}}",
"message.confirm-delete": "Sind Sie sich sicher, {target} zu löschen?",
"message.confirm-reset": "Sind Sie sicher, dass Sie die Statistiken von {target} zurücksetzen wollen?",

View File

@ -62,6 +62,7 @@
"label.username": "Username",
"label.view-details": "View details",
"label.websites": "Websites",
"label.yesterday": "Yesterday",
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
"message.confirm-delete": "Are you sure you want to delete {target}?",
"message.confirm-reset": "Are you sure you want to reset {target}'s statistics?",

View File

@ -62,6 +62,7 @@
"label.username": "Username",
"label.view-details": "View details",
"label.websites": "Websites",
"label.yesterday": "Yesterday",
"message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
"message.confirm-delete": "Are you sure you want to delete {target}?",
"message.confirm-reset": "Are you sure you want to reset {target}'s statistics?",

View File

@ -62,6 +62,7 @@
"label.username": "Nombre de usuario",
"label.view-details": "Ver detalles",
"label.websites": "Sitios",
"label.yesterday": "Ayer",
"message.active-users": "{x} {x, plural, one {activo} other {activos}}",
"message.confirm-delete": "¿Estás seguro(a) de querer eliminar {target}?",
"message.confirm-reset": "¿Seguro que deseas restablecer las estadísticas de {target}?",

View File

@ -5,7 +5,7 @@
"label.administrator": "Administrateur",
"label.all": "Tout",
"label.all-time": "Toutes les données",
"label.all-websites": "Tous les sites web",
"label.all-websites": "Tous les sites",
"label.back": "Retour",
"label.cancel": "Annuler",
"label.change-password": "Changer le mot de passe",
@ -27,7 +27,7 @@
"label.enable-share-url": "Activer l'URL de partage",
"label.invalid": "Invalide",
"label.invalid-domain": "Domaine invalide",
"label.language": "Langage",
"label.language": "Langue",
"label.last-days": "{x} derniers jours",
"label.last-hours": "{x} dernières heures",
"label.logged-in-as": "Connecté en tant que {username}",
@ -52,7 +52,7 @@
"label.share-url": "Partager l'URL",
"label.single-day": "Journée",
"label.theme": "Thème",
"label.this-month": "Ce mois ci",
"label.this-month": "Ce mois",
"label.this-week": "Cette semaine",
"label.this-year": "Cette année",
"label.timezone": "Fuseau horaire",
@ -62,12 +62,13 @@
"label.username": "Nom d'utilisateur",
"label.view-details": "Voir les details",
"label.websites": "Sites",
"label.yesterday": "Hier",
"message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
"message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?",
"message.confirm-reset": "Êtes-vous sûr de vouloir réinistialiser les statistiques de {target} ?",
"message.confirm-reset": "Êtes-vous sûr de vouloir réinitialiser les statistiques de {target} ?",
"message.copied": "Copié !",
"message.delete-warning": "Toutes les données associées seront également supprimées.",
"message.edit-dashboard": "Modifier l'ordre des sites",
"message.edit-dashboard": "Modifier le tableau de bord",
"message.failure": "Un problème est survenu.",
"message.get-share-url": "Obtenir l'URL de partage",
"message.get-tracking-code": "Obtenir le code de suivi",
@ -76,14 +77,14 @@
"message.log.visitor": "Visiteur de {country} utilisant {browser} sur {os} {device}",
"message.new-version-available": "Une nouvelle version de umami {version} est disponible !",
"message.no-data-available": "Pas de données disponibles.",
"message.no-websites-configured": "Vous n'avez configuré aucun site Web.",
"message.no-websites-configured": "Vous n'avez configuré aucun site.",
"message.page-not-found": "Page non trouvée.",
"message.powered-by": "Propulsé par {name}",
"message.reset-warning": "Toutes les statistiques pour ce site seront supprimés, mais votre code de suivi restera intact.",
"message.reset-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.",
"message.save-success": "Enregistré avec succès.",
"message.share-url": "Ceci est l'URL partagée pour {target}.",
"message.toggle-charts": "Afficher/Masquer les graphiques",
"message.track-stats": "Pour suivre les statistiques de {target}, placez le code suivant dans la section {head} de votre site Web.",
"message.track-stats": "Pour suivre les statistiques de {target}, placez le code suivant dans la section {head} de votre site.",
"message.type-delete": "Tapez {delete} dans la case ci-dessous pour confirmer.",
"message.type-reset": "Tapez {reset} dans la case ci-dessous pour confirmer.",
"metrics.actions": "Actions",
@ -99,13 +100,13 @@
"metrics.events": "Événements",
"metrics.filter.combined": "Combiné",
"metrics.filter.raw": "Brut",
"metrics.languages": "Langages",
"metrics.languages": "Langues",
"metrics.operating-systems": "Systèmes d'exploitation",
"metrics.page-views": "Pages vues",
"metrics.pages": "Pages",
"metrics.query-parameters": "Query parameters",
"metrics.query-parameters": "Paramètres d'URL",
"metrics.referrers": "Sources",
"metrics.screens": "Tailles d'écran",
"metrics.screens": "Résolutions d'écran",
"metrics.unique-visitors": "Visiteurs uniques",
"metrics.views": "Vues",
"metrics.visitors": "Visiteurs"

View File

@ -62,6 +62,7 @@
"label.username": "Nome utente",
"label.view-details": "Vedi dettagli",
"label.websites": "Siti web",
"label.yesterday": "Ieri",
"message.active-users": "{x} {x, plural, one {visitatore} other {visitatori}} online",
"message.confirm-delete": "Sei sicuro di voler eliminare {target}?",
"message.confirm-reset": "Sei sicuro di voler azzerare le statistiche di {target}?",

View File

@ -67,7 +67,7 @@
"message.confirm-reset": "您确定要重置 {target} 的数据吗?",
"message.copied": "复制成功!",
"message.delete-warning": "所有相关数据将会被删除。",
"message.edit-dashboard": "Edit dashboard",
"message.edit-dashboard": "编辑仪表板",
"message.failure": "出现错误。",
"message.get-share-url": "获取共享链接",
"message.get-tracking-code": "获取跟踪代码",
@ -103,7 +103,7 @@
"metrics.operating-systems": "操作系统",
"metrics.page-views": "页面浏览量",
"metrics.pages": "网页",
"metrics.query-parameters": "Query parameters",
"metrics.query-parameters": "查询参数",
"metrics.referrers": "来源域名",
"metrics.screens": "屏幕尺寸",
"metrics.unique-visitors": "独立访客",

View File

@ -27,7 +27,7 @@
"label.enable-share-url": "啟用分享連結",
"label.invalid": "無效輸入",
"label.invalid-domain": "無效域名",
"label.language": "Language",
"label.language": "語言",
"label.last-days": "最近 {x} 天",
"label.last-hours": "最近 {x} 小時",
"label.logged-in-as": "用戶名: {username}",
@ -67,7 +67,7 @@
"message.confirm-reset": "您確定要重置 {target} 的數據嗎?",
"message.copied": "複製成功!",
"message.delete-warning": "所有相關數據將會被刪除。",
"message.edit-dashboard": "Edit dashboard",
"message.edit-dashboard": "編輯管理面板",
"message.failure": "出現錯誤。",
"message.get-share-url": "獲得分享連結",
"message.get-tracking-code": "獲得追蹤代碼",
@ -103,7 +103,7 @@
"metrics.operating-systems": "操作系統",
"metrics.page-views": "網頁流量",
"metrics.pages": "網頁",
"metrics.query-parameters": "Query parameters",
"metrics.query-parameters": "查詢參數",
"metrics.referrers": "指入域名",
"metrics.screens": "屏幕尺寸",
"metrics.unique-visitors": "獨立訪客",

View File

@ -1,20 +1,27 @@
import { parseSecureToken, parseToken } from './crypto';
import { SHARE_TOKEN_HEADER } from './constants';
import { parseSecureToken, parseToken, getItem } from 'next-basics';
import { AUTH_TOKEN, SHARE_TOKEN_HEADER } from './constants';
import { getWebsiteById } from 'queries';
import { secret } from './crypto';
export async function getAuthToken(req) {
try {
const token = req.headers.authorization;
return parseSecureToken(token.split(' ')[1]);
return parseSecureToken(token.split(' ')[1], secret());
} catch {
return null;
}
}
export function getAuthHeader() {
const token = getItem(AUTH_TOKEN);
return token ? { authorization: `Bearer ${token}` } : {};
}
export async function isValidToken(token, validation) {
try {
const result = await parseToken(token);
const result = parseToken(token, secret());
if (typeof validation === 'object') {
return !Object.keys(validation).find(key => result[key] !== validation[key]);

201
lib/clickhouse.js Normal file
View File

@ -0,0 +1,201 @@
import { ClickHouse } from 'clickhouse';
import dateFormat from 'dateformat';
import debug from 'debug';
import { FILTER_IGNORED } from 'lib/constants';
import { CLICKHOUSE } from 'lib/db';
export const CLICKHOUSE_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%M:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
const log = debug('umami:clickhouse');
function getClient() {
const {
hostname,
port,
pathname,
username = 'default',
password,
} = new URL(process.env.CLICKHOUSE_URL);
const client = new ClickHouse({
url: hostname,
port: Number(port),
format: 'json',
config: {
database: pathname.replace('/', ''),
},
basicAuth: password ? { username, password } : null,
});
if (process.env.NODE_ENV !== 'production') {
global[CLICKHOUSE] = client;
}
log('Clickhouse initialized');
return client;
}
function getDateStringQuery(data, unit) {
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
}
function getDateQuery(field, unit, timezone) {
if (timezone) {
return `date_trunc('${unit}', ${field}, '${timezone}')`;
}
return `date_trunc('${unit}', ${field})`;
}
function getDateFormat(date) {
return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`;
}
function getBetweenDates(field, start_at, end_at) {
return `${field} between ${getDateFormat(start_at)}
and ${getDateFormat(end_at)}`;
}
function getFilterQuery(table, column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined || filter === FILTER_IGNORED) {
return arr;
}
switch (key) {
case 'url':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'os':
case 'browser':
case 'device':
case 'country':
if (table === 'session') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'event_name':
if (table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'referrer':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
}
break;
case 'domain':
if (table === 'pageview') {
arr.push(`and ${table}.referrer not like $${params.length + 1}`);
arr.push(`and ${table}.referrer not like '/%'`);
params.push(`%://${filter}/%`);
}
break;
case 'query':
if (table === 'pageview') {
arr.push(`and ${table}.url like '%?%'`);
}
}
return arr;
}, []);
return query.join('\n');
}
function parseFilters(table, column, filters = {}, params = [], sessionKey = 'session_id') {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
filters;
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
joinSession:
os || browser || device || country
? `inner join session on ${table}.${sessionKey} = session.${sessionKey}`
: '',
pageviewQuery: getFilterQuery('pageview', column, pageviewFilters, params),
sessionQuery: getFilterQuery('session', column, sessionFilters, params),
eventQuery: getFilterQuery('event', column, eventFilters, params),
};
}
function formatQuery(str, params = []) {
let formattedString = str;
params.forEach((param, i) => {
let replace = param;
if (typeof param === 'string' || param instanceof String) {
replace = `'${replace}'`;
}
formattedString = formattedString.replace(`$${i + 1}`, replace);
});
return formattedString;
}
async function rawQuery(query, params = []) {
let formattedQuery = formatQuery(query, params);
if (process.env.LOG_QUERY) {
log(formattedQuery);
}
return clickhouse.query(formattedQuery).toPromise();
}
async function findUnique(data) {
if (data.length > 1) {
throw `${data.length} records found when expecting 1.`;
}
return data[0] ?? null;
}
async function findFirst(data) {
return data[0] ?? null;
}
// Initialization
const clickhouse = process.env.CLICKHOUSE_URL && (global[CLICKHOUSE] || getClient());
export default {
client: clickhouse,
log,
getDateStringQuery,
getDateQuery,
getDateFormat,
getBetweenDates,
getFilterQuery,
parseFilters,
findUnique,
findFirst,
rawQuery,
};

View File

@ -67,35 +67,6 @@ export const EVENT_COLORS = [
'#ffec16',
];
export const RELATIONAL = 'relational';
export const POSTGRESQL = 'postgresql';
export const MYSQL = 'mysql';
export const CLICKHOUSE = 'clickhouse';
export const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
export const POSTGRESQL_DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD',
month: 'YYYY-MM-01',
year: 'YYYY-01-01',
};
export const CLICKHOUSE_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%M:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
export const FILTER_IGNORED = Symbol.for('filter-ignored');
export const DOMAIN_REGEX =
@ -106,6 +77,7 @@ export const LAPTOP_SCREEN_WIDTH = 1024;
export const MOBILE_SCREEN_WIDTH = 479;
export const URL_LENGTH = 500;
export const EVENT_NAME_LENGTH = 50;
export const DESKTOP_OS = [
'Windows 3.11',

View File

@ -1,74 +1,19 @@
import crypto from 'crypto';
import { v4, v5, validate } from 'uuid';
import bcrypt from 'bcryptjs';
import { JWT, JWE, JWK } from 'jose';
import { v4, v5 } from 'uuid';
import { startOfMonth } from 'date-fns';
const SALT_ROUNDS = 10;
const KEY = JWK.asKey(Buffer.from(secret()));
const ROTATING_SALT = hash(startOfMonth(new Date()).toUTCString());
const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function hash(...args) {
return crypto.createHash('sha512').update(args.join('')).digest('hex');
}
import { hash } from 'next-basics';
export function secret() {
return hash(process.env.HASH_SALT || process.env.DATABASE_URL);
}
export function salt() {
return v5(hash(secret(), ROTATING_SALT), v5.DNS);
const ROTATING_SALT = hash(startOfMonth(new Date()).toUTCString());
return hash([secret(), ROTATING_SALT]);
}
export function uuid(...args) {
if (!args.length) return v4();
return v5(args.join(''), salt());
}
export function isValidUuid(s) {
return validate(s);
}
export function getRandomChars(n) {
let s = '';
for (let i = 0; i < n; i++) {
s += CHARS[Math.floor(Math.random() * CHARS.length)];
}
return s;
}
export function hashPassword(password) {
return bcrypt.hashSync(password, SALT_ROUNDS);
}
export function checkPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}
export async function createToken(payload) {
return JWT.sign(payload, KEY);
}
export async function parseToken(token) {
try {
return JWT.verify(token, KEY);
} catch {
return null;
}
}
export async function createSecureToken(payload) {
return JWE.encrypt(await createToken(payload), KEY);
}
export async function parseSecureToken(token) {
try {
const result = await JWE.decrypt(token, KEY);
return parseToken(result.toString());
} catch {
return null;
}
return v5(hash([...args, salt()]), v5.DNS);
}

View File

@ -7,6 +7,8 @@ import {
addYears,
subHours,
subDays,
subMonths,
subYears,
startOfMinute,
startOfHour,
startOfDay,
@ -39,7 +41,7 @@ export function getDateRange(value, locale = 'en-US') {
const now = new Date();
const dateLocale = getDateLocale(locale);
const match = value.match(/^(?<num>[0-9]+)(?<unit>hour|day|week|month|year)$/);
const match = value.match(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/);
if (!match) return;
@ -78,6 +80,39 @@ export function getDateRange(value, locale = 'en-US') {
}
}
if (+num === -1) {
switch (unit) {
case 'day':
return {
startDate: subDays(startOfDay(now), 1),
endDate: subDays(endOfDay(now), 1),
unit: 'hour',
value,
};
case 'week':
return {
startDate: subDays(startOfWeek(now, { locale: dateLocale }), 7),
endDate: subDays(endOfWeek(now, { locale: dateLocale }), 1),
unit: 'day',
value,
};
case 'month':
return {
startDate: subMonths(startOfMonth(now), 1),
endDate: subMonths(endOfMonth(now), 1),
unit: 'day',
value,
};
case 'year':
return {
startDate: subYears(startOfYear(now), 1),
endDate: subYears(endOfYear(now), 1),
unit: 'month',
value,
};
}
}
switch (unit) {
case 'day':
return {

301
lib/db.js
View File

@ -1,83 +1,18 @@
import { PrismaClient } from '@prisma/client';
import { ClickHouse } from 'clickhouse';
import chalk from 'chalk';
import {
MYSQL,
MYSQL_DATE_FORMATS,
POSTGRESQL,
POSTGRESQL_DATE_FORMATS,
CLICKHOUSE,
RELATIONAL,
FILTER_IGNORED,
} from 'lib/constants';
import moment from 'moment-timezone';
import { CLICKHOUSE_DATE_FORMATS } from './constants';
export const PRISMA = 'prisma';
export const POSTGRESQL = 'postgresql';
export const MYSQL = 'mysql';
export const CLICKHOUSE = 'clickhouse';
export const KAFKA = 'kafka';
export const KAFKA_PRODUCER = 'kafka-producer';
export const REDIS = 'redis';
// Fixes issue with converting bigint values
BigInt.prototype.toJSON = function () {
return Number(this);
};
const options = {
log: [
{
emit: 'event',
level: 'query',
},
],
};
function logQuery(e) {
console.log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
function getPrismaClient(options) {
const prisma = new PrismaClient(options);
if (process.env.LOG_QUERY) {
prisma.$on('query', logQuery);
}
return prisma;
}
function getClickhouseClient() {
if (!process.env.ANALYTICS_URL) {
return null;
}
const url = new URL(process.env.ANALYTICS_URL);
const database = url.pathname.replace('/', '');
return new ClickHouse({
url: url.hostname,
port: Number(url.port),
basicAuth: url.password
? {
username: url.username || 'default',
password: url.password,
}
: null,
format: 'json',
config: {
database,
},
});
}
const prisma = global.prisma || getPrismaClient(options);
const clickhouse = global.clickhouse || getClickhouseClient();
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
global.clickhouse = clickhouse;
}
export { prisma, clickhouse };
export function getDatabase() {
const type =
process.env.DATABASE_TYPE ||
(process.env.DATABASE_URL && process.env.DATABASE_URL.split(':')[0]);
export function getDatabaseType(url = process.env.DATABASE_URL) {
const type = process.env.DATABASE_TYPE || (url && url.split(':')[0]);
if (type === 'postgres') {
return POSTGRESQL;
@ -86,220 +21,18 @@ export function getDatabase() {
return type;
}
export function getAnalyticsDatabase() {
const type =
process.env.ANALYTICS_TYPE ||
(process.env.ANALYTICS_URL && process.env.ANALYTICS_URL.split(':')[0]);
if (type === 'postgres') {
return POSTGRESQL;
}
if (!type) {
return getDatabase();
}
return type;
}
export function getDateStringQueryClickhouse(data, unit) {
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
}
export function getDateQuery(field, unit, timezone) {
const db = getDatabase();
if (db === POSTGRESQL) {
if (timezone) {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
return `to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
if (db === MYSQL) {
if (timezone) {
const tz = moment.tz(timezone).format('Z');
return `date_format(convert_tz(${field},'+00:00','${tz}'), '${MYSQL_DATE_FORMATS[unit]}')`;
}
return `date_format(${field}, '${MYSQL_DATE_FORMATS[unit]}')`;
}
}
export function getDateQueryClickhouse(field, unit, timezone) {
if (timezone) {
return `date_trunc('${unit}', ${field},'${timezone}')`;
}
return `date_trunc('${unit}', ${field})`;
}
export function getDateFormatClickhouse(date) {
return `parseDateTimeBestEffort('${date.toUTCString()}')`;
}
export function getBetweenDatesClickhouse(field, start_at, end_at) {
return `${field} between ${getDateFormatClickhouse(start_at)}
and ${getDateFormatClickhouse(end_at)}`;
}
export function getTimestampInterval(field) {
const db = getDatabase();
if (db === POSTGRESQL) {
return `floor(extract(epoch from max(${field}) - min(${field})))`;
}
if (db === MYSQL) {
return `floor(unix_timestamp(max(${field})) - unix_timestamp(min(${field})))`;
}
}
export function getFilterQuery(table, column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined || filter === FILTER_IGNORED) {
return arr;
}
switch (key) {
case 'url':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'os':
case 'browser':
case 'device':
case 'country':
if (table === 'session') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'event_name':
if (table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'referrer':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
}
break;
case 'domain':
if (table === 'pageview') {
arr.push(`and ${table}.referrer not like $${params.length + 1}`);
arr.push(`and ${table}.referrer not like '/%'`);
params.push(`%://${filter}/%`);
}
break;
case 'query':
if (table === 'pageview') {
arr.push(`and ${table}.url like '%?%'`);
}
}
return arr;
}, []);
return query.join('\n');
}
export function parseFilters(table, column, filters = {}, params = [], sessionKey = 'session_id') {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
filters;
console.log({ table, column, filters, params });
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
joinSession:
os || browser || device || country
? `inner join session on ${table}.${sessionKey} = session.${sessionKey}`
: '',
pageviewQuery: getFilterQuery('pageview', column, pageviewFilters, params),
sessionQuery: getFilterQuery('session', column, sessionFilters, params),
eventQuery: getFilterQuery('event', column, eventFilters, params),
};
}
export function replaceQueryClickhouse(string, params = []) {
let formattedString = string;
params.forEach((a, i) => {
let replace = a;
if (typeof a === 'string' || a instanceof String) {
replace = `'${replace}'`;
}
formattedString = formattedString.replace(`$${i + 1}`, replace);
});
return formattedString;
}
export async function runQuery(query) {
return query.catch(e => {
throw e;
});
}
export async function rawQuery(query, params = []) {
const db = getDatabase();
if (db !== POSTGRESQL && db !== MYSQL) {
return Promise.reject(new Error('Unknown database.'));
}
const sql = db === MYSQL ? query.replace(/\$[0-9]+/g, '?') : query;
return runQuery(prisma.$queryRawUnsafe.apply(prisma, [sql, ...params]));
}
export async function rawQueryClickhouse(query, params = [], debug = false) {
let formattedQuery = replaceQueryClickhouse(query, params);
if (debug || process.env.LOG_QUERY) {
console.log(formattedQuery);
}
return clickhouse.query(formattedQuery).toPromise();
}
export async function findUnique(data) {
if (data.length > 1) {
throw `${data.length} records found when expecting 1.`;
}
return data[0] ?? null;
}
export async function runAnalyticsQuery(queries) {
const db = getAnalyticsDatabase();
export async function runQuery(queries) {
const db = getDatabaseType(process.env.CLICKHOUSE_URL || process.env.DATABASE_URL);
if (db === POSTGRESQL || db === MYSQL) {
return queries[RELATIONAL]();
return queries[PRISMA]();
}
if (db === CLICKHOUSE) {
if (queries[KAFKA]) {
return queries[KAFKA]();
}
return queries[CLICKHOUSE]();
}
}

View File

@ -1,5 +1,3 @@
import { removeWWW } from './url';
export const urlFilter = data => {
const isValidUrl = url => {
return url !== '' && url !== null && !url.startsWith('#');
@ -9,7 +7,7 @@ export const urlFilter = data => {
try {
const { pathname, search } = new URL(url, location.origin);
if (search.startsWith('?/')) {
if (search.startsWith('?')) {
return `${pathname}${search}`;
}
@ -49,7 +47,7 @@ export const refFilter = data => {
try {
const url = new URL(x);
id = removeWWW(url.hostname) || url.href;
id = url.hostname.replace(/www\./, '') || url.href;
} catch {
id = '';
}
@ -94,11 +92,7 @@ export const paramFilter = data => {
return obj;
}, {});
const d = Object.keys(map).flatMap(key =>
return Object.keys(map).flatMap(key =>
Object.keys(map[key]).map(n => ({ x: `${key}=${n}`, p: key, v: n, y: map[key][n] })),
);
console.log({ map, d });
return d;
};

View File

@ -74,7 +74,7 @@ export function stringToColor(str) {
let color = '#';
for (let i = 0; i < 3; i++) {
let value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substr(-2);
color += ('00' + value.toString(16)).substring(-2);
}
return color;
}

84
lib/kafka.js Normal file
View File

@ -0,0 +1,84 @@
import { Kafka, logLevel } from 'kafkajs';
import dateFormat from 'dateformat';
import debug from 'debug';
import { KAFKA, KAFKA_PRODUCER } from 'lib/db';
const log = debug('umami:kafka');
function getClient() {
const { username, password } = new URL(process.env.KAFKA_URL);
const brokers = process.env.KAFKA_BROKER.split(',');
const ssl =
username && password
? {
ssl: true,
sasl: {
mechanism: 'plain',
username,
password,
},
}
: {};
const client = new Kafka({
clientId: 'umami',
brokers: brokers,
connectionTimeout: 3000,
logLevel: logLevel.ERROR,
...ssl,
});
if (process.env.NODE_ENV !== 'production') {
global[KAFKA] = client;
}
return client;
}
async function getProducer() {
const producer = kafka.producer();
await producer.connect();
if (process.env.NODE_ENV !== 'production') {
global[KAFKA_PRODUCER] = producer;
}
return producer;
}
function getDateFormat(date) {
return dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss');
}
async function sendMessage(params, topic) {
await producer.send({
topic,
messages: [
{
value: JSON.stringify(params),
},
],
acks: 0,
});
}
// Initialization
let kafka;
let producer;
(async () => {
kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (global[KAFKA] || getClient());
if (kafka) {
producer = global[KAFKA_PRODUCER] || (await getProducer());
}
})();
export default {
client: kafka,
producer: producer,
log,
getDateFormat,
sendMessage,
};

View File

@ -1,19 +1,7 @@
import { createMiddleware, unauthorized, badRequest, serverError } from 'next-basics';
import cors from 'cors';
import { getSession } from './session';
import { getAuthToken } from './auth';
import { unauthorized, badRequest, serverError } from './response';
export function createMiddleware(middleware) {
return (req, res) =>
new Promise((resolve, reject) => {
middleware(req, res, result => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}
export const useCors = createMiddleware(cors());
@ -23,7 +11,9 @@ export const useSession = createMiddleware(async (req, res, next) => {
try {
session = await getSession(req);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return serverError(res, e.message);
}

199
lib/prisma.js Normal file
View File

@ -0,0 +1,199 @@
import { PrismaClient } from '@prisma/client';
import chalk from 'chalk';
import moment from 'moment-timezone';
import debug from 'debug';
import { PRISMA, MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
import { FILTER_IGNORED } from 'lib/constants';
const MYSQL_DATE_FORMATS = {
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
month: '%Y-%m-01',
year: '%Y-01-01',
};
const POSTGRESQL_DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD',
month: 'YYYY-MM-01',
year: 'YYYY-01-01',
};
const log = debug('umami:prisma');
const PRISMA_OPTIONS = {
log: [
{
emit: 'event',
level: 'query',
},
],
};
function logQuery(e) {
log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
function getClient(options) {
const prisma = new PrismaClient(options);
if (process.env.LOG_QUERY) {
prisma.$on('query', logQuery);
}
if (process.env.NODE_ENV !== 'production') {
global[PRISMA] = prisma;
}
log('Prisma initialized');
return prisma;
}
function getDateQuery(field, unit, timezone) {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
if (timezone) {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
return `to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
if (db === MYSQL) {
if (timezone) {
const tz = moment.tz(timezone).format('Z');
return `date_format(convert_tz(${field},'+00:00','${tz}'), '${MYSQL_DATE_FORMATS[unit]}')`;
}
return `date_format(${field}, '${MYSQL_DATE_FORMATS[unit]}')`;
}
}
function getTimestampInterval(field) {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db === POSTGRESQL) {
return `floor(extract(epoch from max(${field}) - min(${field})))`;
}
if (db === MYSQL) {
return `floor(unix_timestamp(max(${field})) - unix_timestamp(min(${field})))`;
}
}
function getFilterQuery(table, column, filters = {}, params = []) {
const query = Object.keys(filters).reduce((arr, key) => {
const filter = filters[key];
if (filter === undefined || filter === FILTER_IGNORED) {
return arr;
}
switch (key) {
case 'url':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'os':
case 'browser':
case 'device':
case 'country':
if (table === 'session') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'event_name':
if (table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(filter));
}
break;
case 'referrer':
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.referrer like $${params.length + 1}`);
params.push(`%${decodeURIComponent(filter)}%`);
}
break;
case 'domain':
if (table === 'pageview') {
arr.push(`and ${table}.referrer not like $${params.length + 1}`);
arr.push(`and ${table}.referrer not like '/%'`);
params.push(`%://${filter}/%`);
}
break;
case 'query':
if (table === 'pageview') {
arr.push(`and ${table}.url like '%?%'`);
}
}
return arr;
}, []);
return query.join('\n');
}
function parseFilters(table, column, filters = {}, params = [], sessionKey = 'session_id') {
const { domain, url, event_url, referrer, os, browser, device, country, event_name, query } =
filters;
const pageviewFilters = { domain, url, referrer, query };
const sessionFilters = { os, browser, device, country };
const eventFilters = { url: event_url, event_name };
return {
pageviewFilters,
sessionFilters,
eventFilters,
event: { event_name },
joinSession:
os || browser || device || country
? `inner join session on ${table}.${sessionKey} = session.${sessionKey}`
: '',
pageviewQuery: getFilterQuery('pageview', column, pageviewFilters, params),
sessionQuery: getFilterQuery('session', column, sessionFilters, params),
eventQuery: getFilterQuery('event', column, eventFilters, params),
};
}
async function rawQuery(query, params = []) {
const db = getDatabaseType(process.env.DATABASE_URL);
if (db !== POSTGRESQL && db !== MYSQL) {
return Promise.reject(new Error('Unknown database.'));
}
const sql = db === MYSQL ? query.replace(/\$[0-9]+/g, '?') : query;
return prisma.$queryRawUnsafe.apply(prisma, [sql, ...params]);
}
async function transaction(queries) {
return prisma.$transaction(queries);
}
// Initialization
const prisma = global[PRISMA] || getClient(PRISMA_OPTIONS);
export default {
client: prisma,
log,
getDateQuery,
getTimestampInterval,
getFilterQuery,
parseFilters,
rawQuery,
transaction,
};

60
lib/redis.js Normal file
View File

@ -0,0 +1,60 @@
import Redis from 'ioredis';
import { startOfMonth } from 'date-fns';
import debug from 'debug';
import { getSessions, getAllWebsites } from 'queries';
import { REDIS } from 'lib/db';
const log = debug('umami:redis');
const INITIALIZED = 'redis:initialized';
export const DELETED = 'deleted';
function getClient() {
if (!process.env.REDIS_URL) {
return null;
}
const redis = new Redis(process.env.REDIS_URL);
if (process.env.NODE_ENV !== 'production') {
global[REDIS] = redis;
}
log('Redis initialized');
return redis;
}
async function stageData() {
const sessions = await getSessions([], startOfMonth(new Date()));
const websites = await getAllWebsites();
const sessionUuids = sessions.map(a => {
return { key: `session:${a.session_uuid}`, value: 1 };
});
const websiteIds = websites.map(a => {
return { key: `website:${a.website_uuid}`, value: Number(a.website_id) };
});
await addRedis(sessionUuids);
await addRedis(websiteIds);
await redis.set(INITIALIZED, 1);
}
async function addRedis(ids) {
for (let i = 0; i < ids.length; i++) {
const { key, value } = ids[i];
await redis.set(key, value);
}
}
// Initialization
const redis = process.env.REDIS_URL && (global[REDIS] || getClient());
(async () => {
if (redis && !(await redis.get(INITIALIZED))) {
await stageData();
}
})();
export default { client: redis, stageData, log };

View File

@ -1,43 +0,0 @@
export function ok(res, data = {}) {
return json(res, data);
}
export function json(res, data = {}) {
return res.status(200).json(data);
}
export function send(res, data, type = 'text/plain') {
res.setHeader('Content-Type', type);
return res.status(200).send(data);
}
export function redirect(res, url) {
res.setHeader('Location', url);
return res.status(303).end();
}
export function badRequest(res, msg = '400 Bad Request') {
return res.status(400).end(msg);
}
export function unauthorized(res, msg = '401 Unauthorized') {
return res.status(401).end(msg);
}
export function forbidden(res, msg = '403 Forbidden') {
return res.status(403).end(msg);
}
export function notFound(res, msg = '404 Not Found') {
return res.status(404).end(msg);
}
export function methodNotAllowed(res, msg = '405 Method Not Allowed') {
res.status(405).end(msg);
}
export function serverError(res, msg = '500 Internal Server Error') {
res.status(500).end(msg);
}

View File

@ -1,6 +1,9 @@
import { getWebsiteByUuid, getSessionByUuid, createSession } from 'queries';
import { getJsonBody, getClientInfo } from 'lib/request';
import { uuid, isValidUuid, parseToken } from 'lib/crypto';
import { parseToken } from 'next-basics';
import { validate } from 'uuid';
import { uuid } from 'lib/crypto';
import redis, { DELETED } from 'lib/redis';
import { getClientInfo, getJsonBody } from 'lib/request';
import { createSession, getSessionByUuid, getWebsiteByUuid } from 'queries';
export async function getSession(req) {
const { payload } = getJsonBody(req);
@ -9,7 +12,6 @@ export async function getSession(req) {
throw new Error('Invalid request');
}
const { website: website_uuid, hostname, screen, language } = payload;
const cache = req.headers['x-umami-cache'];
if (cache) {
@ -20,26 +22,52 @@ export async function getSession(req) {
}
}
if (!isValidUuid(website_uuid)) {
throw new Error(`Invalid website: ${website_uuid}`);
const { website: website_uuid, hostname, screen, language } = payload;
if (!validate(website_uuid)) {
return null;
}
let websiteId = null;
// Check if website exists
if (redis.client) {
websiteId = await redis.client.get(`website:${website_uuid}`);
}
// Check database if redis does not have
if (!websiteId) {
const website = await getWebsiteByUuid(website_uuid);
websiteId = website ? website.website_id : null;
}
if (!websiteId || websiteId === DELETED) {
throw new Error(`Website not found: ${website_uuid}`);
}
const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload);
const website = await getWebsiteByUuid(website_uuid);
const session_uuid = uuid(websiteId, hostname, ip, userAgent);
if (!website) {
throw new Error(`Website not found: ${website_uuid}`);
let sessionCreated = false;
let sessionId = null;
let session = null;
// Check if session exists
if (redis.client) {
sessionCreated = !!(await redis.client.get(`session:${session_uuid}`));
}
const { website_id } = website;
const session_uuid = uuid(website_id, hostname, ip, userAgent);
// Check database if redis does not have
if (!sessionCreated) {
session = await getSessionByUuid(session_uuid);
sessionCreated = !!session;
sessionId = session ? session.session_id : null;
}
let session = await getSessionByUuid(session_uuid);
if (!session) {
if (!sessionCreated) {
try {
session = await createSession(website_id, {
session = await createSession(websiteId, {
session_uuid,
hostname,
browser,
@ -50,21 +78,17 @@ export async function getSession(req) {
device,
});
if (!session) {
return null;
}
sessionId = session ? session.session_id : null;
} catch (e) {
if (!e.message.includes('Unique constraint')) {
if (!e.message.toLowerCase().includes('unique constraint')) {
throw e;
}
}
}
const { session_id } = session;
return {
website_id,
session_id,
website_id: websiteId,
session_id: sessionId,
session_uuid,
};
}

View File

@ -1,35 +0,0 @@
export function removeTrailingSlash(url) {
return url && url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url;
}
export function removeWWW(url) {
return url && url.length > 1 && url.startsWith('www.') ? url.slice(4) : url;
}
export function getQueryString(params = {}) {
const map = Object.keys(params).reduce((arr, key) => {
if (params[key] !== undefined) {
return arr.concat(`${key}=${encodeURIComponent(params[key])}`);
}
return arr;
}, []);
if (map.length) {
return `?${map.join('&')}`;
}
return '';
}
export function makeUrl(url, params) {
return `${url}${getQueryString(params)}`;
}
export function safeDecodeURI(s) {
try {
return decodeURI(s);
} catch (e) {
console.error(e);
}
return s;
}

View File

@ -1,78 +0,0 @@
import { makeUrl } from './url';
export const apiRequest = (method, url, body, headers) => {
return fetch(url, {
method,
cache: 'no-cache',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...headers,
},
body,
}).then(res => {
if (res.ok) {
return res.json().then(data => ({ ok: res.ok, status: res.status, data }));
}
return res.text().then(data => ({ ok: res.ok, status: res.status, res: res, data }));
});
};
export const get = (url, params, headers) =>
apiRequest('get', makeUrl(url, params), undefined, headers);
export const del = (url, params, headers) =>
apiRequest('delete', makeUrl(url, params), undefined, headers);
export const post = (url, params, headers) =>
apiRequest('post', url, JSON.stringify(params), headers);
export const put = (url, params, headers) =>
apiRequest('put', url, JSON.stringify(params), headers);
export const hook = (_this, method, callback) => {
const orig = _this[method];
return (...args) => {
callback.apply(null, args);
return orig.apply(_this, args);
};
};
export const doNotTrack = () => {
const { doNotTrack, navigator, external } = window;
const msTrackProtection = 'msTrackingProtectionEnabled';
const msTracking = () => {
return external && msTrackProtection in external && external[msTrackProtection]();
};
const dnt = doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack || msTracking();
return dnt == '1' || dnt === 'yes';
};
export const setItem = (key, data, session) => {
if (typeof window !== 'undefined' && data) {
(session ? sessionStorage : localStorage).setItem(key, JSON.stringify(data));
}
};
export const getItem = (key, session) => {
if (typeof window !== 'undefined') {
const value = (session ? sessionStorage : localStorage).getItem(key);
if (value !== 'undefined') {
return JSON.parse(value);
}
}
};
export const removeItem = (key, session) => {
if (typeof window !== 'undefined') {
(session ? sessionStorage : localStorage).removeItem(key);
}
};

View File

@ -1,2 +1,2 @@
[functions]
included_files = ["public/geo/*.mmdb"]
included_files = ["node_modules/.geo/**"]

View File

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "1.37.0",
"version": "1.38.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
"author": "Mike Cao <mike@mikecao.com>",
"license": "MIT",
@ -11,8 +11,9 @@
},
"scripts": {
"dev": "next dev",
"build": "npm-run-all build-tracker build-geo build-db build-app",
"start": "npm-run-all check-db start-next",
"build": "npm-run-all build-db check-db build-tracker build-geo build-app",
"start": "start-next",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
"start-docker": "npm-run-all check-db update-tracker start-server",
"start-env": "node scripts/start-env.js",
"start-server": "node server.js",
@ -56,8 +57,7 @@
},
"dependencies": {
"@fontsource/inter": "4.5.7",
"@prisma/client": "4.1.1",
"bcryptjs": "^2.4.3",
"@prisma/client": "4.3.1",
"chalk": "^4.1.1",
"chart.js": "^2.9.4",
"classnames": "^2.3.1",
@ -67,21 +67,25 @@
"cross-spawn": "^7.0.3",
"date-fns": "^2.23.0",
"date-fns-tz": "^1.1.4",
"dateformat": "^5.0.3",
"debug": "^4.3.4",
"del": "^6.0.0",
"detect-browser": "^5.2.0",
"dotenv": "^10.0.0",
"formik": "^2.2.9",
"fs-extra": "^10.0.1",
"immer": "^9.0.12",
"ioredis": "^5.2.3",
"ipaddr.js": "^2.0.1",
"is-ci": "^3.0.1",
"is-docker": "^3.0.0",
"is-localhost-ip": "^1.4.0",
"isbot": "^3.4.5",
"jose": "2.0.5",
"kafkajs": "^2.1.0",
"maxmind": "^4.3.6",
"moment-timezone": "^0.5.35",
"next": "^12.2.4",
"next": "^12.2.5",
"next-basics": "^0.7.0",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"prop-types": "^15.7.2",
@ -94,7 +98,7 @@
"react-tooltip": "^4.2.21",
"react-use-measure": "^2.0.4",
"react-window": "^1.8.6",
"request-ip": "^2.1.3",
"request-ip": "^3.3.0",
"semver": "^7.3.6",
"thenby": "^1.3.4",
"timezone-support": "^2.0.2",
@ -110,6 +114,8 @@
"eslint": "^7.32.0",
"eslint-config-next": "^12.2.4",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.0.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "^7.0.0",
@ -120,7 +126,7 @@
"postcss-preset-env": "7.4.3",
"postcss-rtlcss": "^3.6.1",
"prettier": "^2.6.2",
"prisma": "4.1.1",
"prisma": "4.3.1",
"prompts": "2.4.2",
"rollup": "^2.70.1",
"rollup-plugin-terser": "^7.0.2",

View File

@ -1,6 +1,6 @@
import { getAccountById, deleteAccount } from 'queries';
import { useAuth } from 'lib/middleware';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
export default async (req, res) => {
await useAuth(req, res);

View File

@ -1,7 +1,6 @@
import { ok, unauthorized, methodNotAllowed, badRequest, hashPassword } from 'next-basics';
import { getAccountById, getAccountByUsername, updateAccount, createAccount } from 'queries';
import { useAuth } from 'lib/middleware';
import { hashPassword } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed, badRequest } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);

View File

@ -1,7 +1,13 @@
import { getAccountById, updateAccount } from 'queries';
import { useAuth } from 'lib/middleware';
import { badRequest, methodNotAllowed, ok, unauthorized } from 'lib/response';
import { checkPassword, hashPassword } from 'lib/crypto';
import {
badRequest,
methodNotAllowed,
ok,
unauthorized,
checkPassword,
hashPassword,
} from 'next-basics';
export default async (req, res) => {
await useAuth(req, res);

View File

@ -1,6 +1,6 @@
import { getAccounts } from 'queries';
import { useAuth } from 'lib/middleware';
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
import { ok, unauthorized, methodNotAllowed } from 'next-basics';
export default async (req, res) => {
await useAuth(req, res);

View File

@ -1,6 +1,6 @@
import { checkPassword, createSecureToken } from 'lib/crypto';
import { ok, unauthorized, badRequest, checkPassword, createSecureToken } from 'next-basics';
import { getAccountByUsername } from 'queries/admin/account/getAccountByUsername';
import { ok, unauthorized, badRequest } from 'lib/response';
import { secret } from 'lib/crypto';
export default async (req, res) => {
const { username, password } = req.body;
@ -11,10 +11,10 @@ export default async (req, res) => {
const account = await getAccountByUsername(username);
if (account && (await checkPassword(password, account.password))) {
if (account && checkPassword(password, account.password)) {
const { user_id, username, is_admin } = account;
const user = { user_id, username, is_admin };
const token = await createSecureToken(user);
const token = createSecureToken(user, secret());
return ok(res, { token, user });
}

View File

@ -1,5 +1,5 @@
import { useAuth } from 'lib/middleware';
import { ok, unauthorized } from 'lib/response';
import { ok, unauthorized } from 'next-basics';
export default async (req, res) => {
await useAuth(req, res);

View File

@ -1,18 +1,17 @@
const { Resolver } = require('dns').promises;
import isbot from 'isbot';
import ipaddr from 'ipaddr.js';
import { createToken, unauthorized, send, badRequest, forbidden } from 'next-basics';
import { savePageView, saveEvent } from 'queries';
import { useCors, useSession } from 'lib/middleware';
import { getJsonBody, getIpAddress } from 'lib/request';
import { ok, send, badRequest, forbidden } from 'lib/response';
import { createToken } from 'lib/crypto';
import { removeTrailingSlash } from 'lib/url';
import { secret, uuid } from 'lib/crypto';
export default async (req, res) => {
await useCors(req, res);
if (isbot(req.headers['user-agent'])) {
return ok(res);
if (isbot(req.headers['user-agent']) && !process.env.DISABLE_BOT_CHECK) {
return unauthorized(res);
}
const ignoreIps = process.env.IGNORE_IP;
@ -68,18 +67,27 @@ export default async (req, res) => {
let { url, referrer, event_name, event_data } = payload;
if (process.env.REMOVE_TRAILING_SLASH) {
url = removeTrailingSlash(url);
url = url.replace(/\/$/, '');
}
const event_uuid = uuid();
if (type === 'pageview') {
await savePageView(website_id, { session_id, session_uuid, url, referrer });
} else if (type === 'event') {
await saveEvent(website_id, { session_id, session_uuid, url, event_name, event_data });
await saveEvent(website_id, {
event_uuid,
session_id,
session_uuid,
url,
event_name,
event_data,
});
} else {
return badRequest(res);
}
const token = await createToken({ website_id, session_id });
const token = createToken({ website_id, session_id, session_uuid }, secret());
return send(res, token);
};

View File

@ -1,4 +1,4 @@
import { ok, methodNotAllowed } from 'lib/response';
import { ok, methodNotAllowed } from 'next-basics';
export default async (req, res) => {
if (req.method === 'GET') {

5
pages/api/heartbeat.js Normal file
View File

@ -0,0 +1,5 @@
import { ok } from 'next-basics';
export default async (req, res) => {
return ok(res);
};

View File

@ -1,8 +1,8 @@
import { subMinutes } from 'date-fns';
import { ok, methodNotAllowed, createToken } from 'next-basics';
import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed } from 'lib/response';
import { getUserWebsites, getRealtimeData } from 'queries';
import { createToken } from 'lib/crypto';
import { secret } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
@ -12,7 +12,7 @@ export default async (req, res) => {
const websites = await getUserWebsites(user_id);
const ids = websites.map(({ website_id }) => website_id);
const token = await createToken({ websites: ids });
const token = createToken({ websites: ids }, secret());
const data = await getRealtimeData(ids, subMinutes(new Date(), 30));
return ok(res, {

View File

@ -1,8 +1,8 @@
import { ok, methodNotAllowed, badRequest, parseToken } from 'next-basics';
import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, badRequest } from 'lib/response';
import { getRealtimeData } from 'queries';
import { parseToken } from 'lib/crypto';
import { SHARE_TOKEN_HEADER } from 'lib/constants';
import { secret } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
@ -16,7 +16,7 @@ export default async (req, res) => {
return badRequest(res);
}
const { websites } = await parseToken(token);
const { websites } = parseToken(token, secret());
const data = await getRealtimeData(websites, new Date(+start_at));

View File

@ -1,6 +1,6 @@
import { getWebsiteByShareId } from 'queries';
import { ok, notFound, methodNotAllowed } from 'lib/response';
import { createToken } from 'lib/crypto';
import { ok, notFound, methodNotAllowed, createToken } from 'next-basics';
import { secret } from 'lib/crypto';
export default async (req, res) => {
const { id } = req.query;
@ -10,7 +10,7 @@ export default async (req, res) => {
if (website) {
const websiteId = website.website_id;
const token = await createToken({ website_id: websiteId });
const token = createToken({ website_id: websiteId }, secret());
return ok(res, { websiteId, token });
}

View File

@ -1,4 +1,4 @@
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
import { getActiveVisitors } from 'queries';

View File

@ -1,6 +1,6 @@
import moment from 'moment-timezone';
import { getEventMetrics } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';

View File

@ -1,5 +1,5 @@
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { deleteWebsite, getWebsiteById } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';

View File

@ -1,5 +1,5 @@
import { getPageviewMetrics, getSessionMetrics, getWebsiteById } from 'queries';
import { ok, methodNotAllowed, unauthorized, badRequest } from 'lib/response';
import { ok, methodNotAllowed, unauthorized, badRequest } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';
import { FILTER_IGNORED } from 'lib/constants';

View File

@ -1,6 +1,6 @@
import moment from 'moment-timezone';
import { getPageviewStats } from 'queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';

View File

@ -1,5 +1,5 @@
import { resetWebsite } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
export default async (req, res) => {

View File

@ -1,5 +1,5 @@
import { getWebsiteStats } from 'queries';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { allowQuery } from 'lib/auth';
import { useCors } from 'lib/middleware';

View File

@ -1,7 +1,7 @@
import { ok, unauthorized, methodNotAllowed, getRandomChars } from 'next-basics';
import { updateWebsite, createWebsite, getWebsiteById } from 'queries';
import { useAuth } from 'lib/middleware';
import { uuid, getRandomChars } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
import { uuid } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
@ -10,7 +10,8 @@ export default async (req, res) => {
const { website_id, enable_share_url } = req.body;
if (req.method === 'POST') {
const { name, domain } = req.body;
const { name, domain, owner } = req.body;
const website_owner = parseInt(owner);
if (website_id) {
const website = await getWebsiteById(website_id);
@ -27,13 +28,13 @@ export default async (req, res) => {
share_id = null;
}
await updateWebsite(website_id, { name, domain, share_id });
await updateWebsite(website_id, { name, domain, share_id, user_id: website_owner });
return ok(res);
} else {
const website_uuid = uuid();
const share_id = enable_share_url ? getRandomChars(8) : null;
const website = await createWebsite(user_id, { website_uuid, name, domain, share_id });
const website = await createWebsite(website_owner, { website_uuid, name, domain, share_id });
return ok(res, website);
}

View File

@ -1,6 +1,6 @@
import { getAllWebsites, getUserWebsites } from 'queries';
import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, unauthorized } from 'lib/response';
import { ok, methodNotAllowed, unauthorized } from 'next-basics';
export default async (req, res) => {
await useAuth(req, res);

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { removeItem } from 'lib/web';
import { removeItem } from 'next-basics';
import { AUTH_TOKEN } from 'lib/constants';
import { setUser } from 'store/app';

View File

@ -397,6 +397,12 @@
"value": "Llocs web"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Ahir"
}
],
"message.active-users": [
{
"type": 1,

View File

@ -397,6 +397,12 @@
"value": "Webseiten"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Gestern"
}
],
"message.active-users": [
{
"type": 1,
@ -474,7 +480,7 @@
"message.edit-dashboard": [
{
"type": 0,
"value": "Edit dashboard"
"value": "Dashboard bearbeiten"
}
],
"message.failure": [
@ -770,7 +776,7 @@
"metrics.query-parameters": [
{
"type": 0,
"value": "Query parameters"
"value": "Abfrageparameter"
}
],
"metrics.referrers": [

View File

@ -397,6 +397,12 @@
"value": "Websites"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Yesterday"
}
],
"message.active-users": [
{
"type": 1,

View File

@ -397,6 +397,12 @@
"value": "Websites"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Yesterday"
}
],
"message.active-users": [
{
"type": 1,

View File

@ -397,6 +397,12 @@
"value": "Sitios"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Ayer"
}
],
"message.active-users": [
{
"type": 1,

View File

@ -38,7 +38,7 @@
"label.all-websites": [
{
"type": 0,
"value": "Tous les sites web"
"value": "Tous les sites"
}
],
"label.back": [
@ -170,7 +170,7 @@
"label.language": [
{
"type": 0,
"value": "Langage"
"value": "Langue"
}
],
"label.last-days": [
@ -332,7 +332,7 @@
"label.this-month": [
{
"type": 0,
"value": "Ce mois ci"
"value": "Ce mois"
}
],
"label.this-week": [
@ -389,6 +389,12 @@
"value": "Sites"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Hier"
}
],
"message.active-users": [
{
"type": 1,
@ -444,7 +450,7 @@
"message.confirm-reset": [
{
"type": 0,
"value": "Êtes-vous sûr de vouloir réinistialiser les statistiques de "
"value": "Êtes-vous sûr de vouloir réinitialiser les statistiques de "
},
{
"type": 1,
@ -470,7 +476,7 @@
"message.edit-dashboard": [
{
"type": 0,
"value": "Edit dashboard"
"value": "Modifier le tableau de bord"
}
],
"message.failure": [
@ -560,7 +566,7 @@
"message.no-websites-configured": [
{
"type": 0,
"value": "Vous n'avez configuré aucun site Web."
"value": "Vous n'avez configuré aucun site."
}
],
"message.page-not-found": [
@ -582,7 +588,7 @@
"message.reset-warning": [
{
"type": 0,
"value": "Toutes les statistiques pour ce site seront supprimés, mais votre code de suivi restera intact."
"value": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact."
}
],
"message.save-success": [
@ -630,7 +636,7 @@
},
{
"type": 0,
"value": " de votre site Web."
"value": " de votre site."
}
],
"message.type-delete": [
@ -742,7 +748,7 @@
"metrics.languages": [
{
"type": 0,
"value": "Langages"
"value": "Langues"
}
],
"metrics.operating-systems": [
@ -766,7 +772,7 @@
"metrics.query-parameters": [
{
"type": 0,
"value": "Query parameters"
"value": "Paramètres d'URL"
}
],
"metrics.referrers": [
@ -778,7 +784,7 @@
"metrics.screens": [
{
"type": 0,
"value": "Tailles d'écran"
"value": "Résolutions d'écran"
}
],
"metrics.unique-visitors": [

View File

@ -397,6 +397,12 @@
"value": "Siti web"
}
],
"label.yesterday": [
{
"type": 0,
"value": "Ieri"
}
],
"message.active-users": [
{
"type": 1,

View File

@ -454,7 +454,7 @@
"message.edit-dashboard": [
{
"type": 0,
"value": "Edit dashboard"
"value": "编辑仪表板"
}
],
"message.failure": [
@ -758,7 +758,7 @@
"metrics.query-parameters": [
{
"type": 0,
"value": "Query parameters"
"value": "查询参数"
}
],
"metrics.referrers": [

View File

@ -170,7 +170,7 @@
"label.language": [
{
"type": 0,
"value": "Language"
"value": "語言"
}
],
"label.last-days": [
@ -454,7 +454,7 @@
"message.edit-dashboard": [
{
"type": 0,
"value": "Edit dashboard"
"value": "編輯管理面板"
}
],
"message.failure": [
@ -754,7 +754,7 @@
"metrics.query-parameters": [
{
"type": 0,
"value": "Query parameters"
"value": "查詢參數"
}
],
"metrics.referrers": [

View File

@ -1,9 +1,7 @@
import { prisma, runQuery } from 'lib/db';
import prisma from 'lib/prisma';
export async function createAccount(data) {
return runQuery(
prisma.account.create({
return prisma.client.account.create({
data,
}),
);
});
}

View File

@ -1,11 +1,50 @@
import { prisma, runQuery } from 'lib/db';
import prisma from 'lib/prisma';
import redis, { DELETED } from 'lib/redis';
export async function deleteAccount(user_id) {
return runQuery(
prisma.account.delete({
const { client } = prisma;
const websites = await client.website.findMany({
where: { user_id },
select: { website_uuid: true },
});
let websiteUuids = [];
if (websites.length > 0) {
websiteUuids = websites.map(a => a.website_uuid);
}
return client
.$transaction([
client.pageview.deleteMany({
where: { session: { website: { user_id } } },
}),
client.event_data.deleteMany({
where: { event: { session: { website: { user_id } } } },
}),
client.event.deleteMany({
where: { session: { website: { user_id } } },
}),
client.session.deleteMany({
where: { website: { user_id } },
}),
client.website.deleteMany({
where: { user_id },
}),
client.account.delete({
where: {
user_id,
},
}),
);
])
.then(async res => {
if (redis.client) {
for (let i = 0; i < websiteUuids.length; i++) {
await redis.client.set(`website:${websiteUuids[i]}`, DELETED);
}
}
return res;
});
}

Some files were not shown because too many files have changed in this diff Show More