mirror of
https://github.com/kremalicious/umami.git
synced 2024-11-15 17:55:08 +01:00
commit
9916265150
@ -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",
|
||||
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -7,6 +7,7 @@ on: [push]
|
||||
|
||||
env:
|
||||
DATABASE_TYPE: postgresql
|
||||
SKIP_DB_CHECK: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -35,3 +35,6 @@ yarn-error.log*
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
*.dev.yml
|
||||
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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}
|
||||
|
@ -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';
|
||||
|
@ -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">
|
||||
|
5
components/forms/WebsiteEditForm.module.css
Normal file
5
components/forms/WebsiteEditForm.module.css
Normal file
@ -0,0 +1,5 @@
|
||||
.dropdown {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
@ -24,7 +24,8 @@
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
.row > div > input {
|
||||
.row > div > input,
|
||||
.row > div > select {
|
||||
width: 100%;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
return <div className={classNames(styles.page, className)}>{children}</div>;
|
||||
}
|
||||
export default function Page({ className, children }) {
|
||||
return <div className={classNames(styles.page, className)}>{children}</div>;
|
||||
}
|
||||
|
@ -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 }) {
|
||||
|
@ -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>
|
||||
|
@ -37,3 +37,7 @@
|
||||
.change.negative {
|
||||
color: var(--red500);
|
||||
}
|
||||
|
||||
.change.plusSign::before {
|
||||
content: '+';
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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() {
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
@ -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],
|
||||
);
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
130
db/clickhouse/migrations/01_init/migration.sql
Normal file
130
db/clickhouse/migrations/01_init/migration.sql
Normal 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;
|
35
db/mysql/migrations/03_remove_cascade_delete/migration.sql
Normal file
35
db/mysql/migrations/03_remove_cascade_delete/migration.sql
Normal 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;
|
@ -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[]
|
||||
|
@ -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;
|
@ -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])
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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';
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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 };
|
||||
|
@ -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];
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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}?",
|
||||
|
@ -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?",
|
||||
|
@ -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?",
|
||||
|
@ -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?",
|
||||
|
@ -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}?",
|
||||
|
@ -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"
|
||||
|
@ -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}?",
|
||||
|
@ -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": "独立访客",
|
||||
|
@ -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": "獨立訪客",
|
||||
|
15
lib/auth.js
15
lib/auth.js
@ -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
201
lib/clickhouse.js
Normal 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,
|
||||
};
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
|
37
lib/date.js
37
lib/date.js
@ -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
301
lib/db.js
@ -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]();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
84
lib/kafka.js
Normal 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,
|
||||
};
|
@ -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
199
lib/prisma.js
Normal 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
60
lib/redis.js
Normal 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 };
|
@ -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);
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
35
lib/url.js
35
lib/url.js
@ -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;
|
||||
}
|
78
lib/web.js
78
lib/web.js
@ -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);
|
||||
}
|
||||
};
|
@ -1,2 +1,2 @@
|
||||
[functions]
|
||||
included_files = ["public/geo/*.mmdb"]
|
||||
included_files = ["node_modules/.geo/**"]
|
||||
|
24
package.json
24
package.json
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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
5
pages/api/heartbeat.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { ok } from 'next-basics';
|
||||
|
||||
export default async (req, res) => {
|
||||
return ok(res);
|
||||
};
|
@ -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, {
|
||||
|
@ -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));
|
||||
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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';
|
||||
|
||||
|
@ -397,6 +397,12 @@
|
||||
"value": "Llocs web"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ahir"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 1,
|
||||
|
@ -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": [
|
||||
|
@ -397,6 +397,12 @@
|
||||
"value": "Websites"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Yesterday"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 1,
|
||||
|
@ -397,6 +397,12 @@
|
||||
"value": "Websites"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Yesterday"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 1,
|
||||
|
@ -397,6 +397,12 @@
|
||||
"value": "Sitios"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ayer"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 1,
|
||||
|
@ -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": [
|
||||
|
@ -397,6 +397,12 @@
|
||||
"value": "Siti web"
|
||||
}
|
||||
],
|
||||
"label.yesterday": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Ieri"
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
{
|
||||
"type": 1,
|
||||
|
@ -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": [
|
||||
|
@ -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": [
|
||||
|
@ -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({
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return prisma.client.account.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
@ -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({
|
||||
where: {
|
||||
user_id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
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
Loading…
Reference in New Issue
Block a user