Initial commit.

This commit is contained in:
Mike Cao 2020-07-17 01:03:38 -07:00
commit f7f0c05e12
27 changed files with 13028 additions and 0 deletions

21
.eslintrc.json Normal file
View File

@ -0,0 +1,21 @@
{
"env": {
"browser": true,
"es2020": true
},
"extends": ["eslint:recommended", "plugin:react/recommended", "prettier", "prettier/react"],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 11,
"sourceType": "module"
},
"plugins": ["react"],
"rules": {
"react/react-in-jsx-scope": "off"
},
"globals": {
"React": "writable"
}
}

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
.idea
*.iml
.env
.env*.local
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.development.local
.env.test.local
.env.production.local

7
.prettierrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}

17
.stylelintrc.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": [
"stylelint-config-recommended",
"stylelint-config-css-modules",
"stylelint-config-prettier"
],
"rules": {
"no-descending-specificity": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global", "horizontal", "vertical"]
}
]
},
"ignoreFiles": ["**/*.js"]
}

1
README.md Normal file
View File

@ -0,0 +1 @@
umami - deliciously simple web stats

5
components/footer.js Normal file
View File

@ -0,0 +1,5 @@
import React from 'react';
export default function Footer() {
return <footer className="container">Footer</footer>;
}

7
components/header.js Normal file
View File

@ -0,0 +1,7 @@
import React from 'react';
export default function Header() {
return <header className="container">
<h1>umami</h1>
</header>;
}

23
components/layout.js Normal file
View File

@ -0,0 +1,23 @@
import React from 'react';
import Head from 'next/head';
import Header from 'components/header';
import Footer from 'components/footer';
export default function Layout({ title, children }) {
return (
<>
<Head>
<title>umami{title && ` - ${title}`}</title>
<link rel="icon" href="/favicon.ico" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400&display=swap"
rel="stylesheet"
/>
<script async defer data-website-id="865234ad-6a92-11e7-8846-b05adad3f099" src="/umami.js" />
</Head>
<Header />
<main className="container">{children}</main>
<Footer />
</>
);
}

5
jsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"compilerOptions": {
"baseUrl": "."
}
}

65
lib/db.js Normal file
View File

@ -0,0 +1,65 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function runQuery(query) {
return query
.catch(e => {
throw e;
})
.finally(async () => {
await prisma.disconnect();
});
}
export async function getWebsite(website_id) {
return runQuery(
prisma.website.findOne({
where: {
website_id,
},
}),
);
}
export async function createSession(website_id, session_id, data) {
await runQuery(
prisma.session.create({
data: {
session_id,
website: {
connect: {
website_id,
},
},
...data,
},
}),
);
}
export async function getSession(session_id) {
return runQuery(
prisma.session.findOne({
where: {
session_id,
},
}),
);
}
export async function savePageView(session_id, url, referrer) {
return runQuery(
prisma.pageview.create({
data: {
session: {
connect: {
session_id,
},
},
url,
referrer,
},
}),
);
}

72
lib/utils.js Normal file
View File

@ -0,0 +1,72 @@
import crypto from 'crypto';
import { v5 as uuid } from 'uuid';
import requestIp from 'request-ip';
import { browserName, detectOS } from 'detect-browser';
export function md5(s) {
return crypto.createHash('md5').update(s).digest('hex');
}
export function hash(s) {
return uuid(s, md5(process.env.HASH_SALT));
}
export function getIpAddress(req) {
if (req.headers['cf-connecting-ip']) {
return req.headers['cf-connecting-ip'];
}
return requestIp.getClientIp(req);
}
export function getDevice(req) {
const userAgent = req.headers['user-agent'];
const browser = browserName(userAgent);
const os = detectOS(userAgent);
return { userAgent, browser, os };
}
export function getCountry(req) {
return req.headers['cf-ipcountry'];
}
export function parseSessionRequest(req) {
const ip = getIpAddress(req);
const { website_id, screen, language } = JSON.parse(req.body);
const { userAgent, browser, os } = getDevice(req);
const country = getCountry(req);
const session_id = hash(`${website_id}${ip}${userAgent}${os}`);
return {
website_id,
session_id,
browser,
os,
screen,
language,
country,
};
}
export function parseCollectRequest(req) {
const { type, payload } = JSON.parse(req.body);
if (payload.session) {
const {
url,
referrer,
session: { website_id, session_id, time, hash: validationHash },
} = payload;
if (hash(`${website_id}${session_id}${time}`) === validationHash) {
return {
type,
session_id,
url,
referrer,
};
}
}
return null;
}

15
next.config.js Normal file
View File

@ -0,0 +1,15 @@
require('dotenv').config();
module.exports = {
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
issuer: {
test: /\.js$/,
},
use: ['@svgr/webpack'],
});
return config;
},
};

67
package.json Normal file
View File

@ -0,0 +1,67 @@
{
"name": "umami",
"version": "0.1.0",
"description": "Deliciously simple website analytics",
"main": "index.js",
"author": "Mike Cao",
"license": "MIT",
"scripts": {
"dev": "next dev -p 8000",
"build": "next build",
"start": "next start",
"build-script": "rollup -c"
},
"lint-staged": {
"**/*.js": [
"prettier --write"
],
"**/*.css": [
"stylelint --fix",
"prettier --write"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"dependencies": {
"@prisma/client": "2.2.2",
"classnames": "^2.2.6",
"date-fns": "^2.14.0",
"detect-browser": "^5.1.1",
"dotenv": "^8.2.0",
"next": "9.3.5",
"node-fetch": "^2.6.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"request-ip": "^2.1.3",
"uuid": "^8.2.0",
"whatwg-fetch": "^3.2.0"
},
"devDependencies": {
"@prisma/cli": "2.2.2",
"@rollup/plugin-node-resolve": "^8.4.0",
"@svgr/webpack": "^5.4.0",
"eslint": "^7.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.0.4",
"husky": "^4.2.5",
"less": "^3.11.3",
"lint-staged": "^10.2.9",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-import": "^12.0.1",
"postcss-preset-env": "^6.7.0",
"prettier": "^2.0.5",
"prettier-eslint": "^10.1.1",
"rollup": "^2.21.0",
"rollup-plugin-replace": "^2.2.0",
"rollup-plugin-terser": "^6.1.0",
"stylelint": "^13.6.0",
"stylelint-config-css-modules": "^2.2.0",
"stylelint-config-prettier": "^8.0.1",
"stylelint-config-recommended": "^3.0.0"
}
}

10
pages/404.js Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import Layout from 'components/layout';
export default function Custom404() {
return (
<Layout title="404 - Page Not Found">
<h1>oops</h1>
</Layout>
);
}

7
pages/_app.js Normal file
View File

@ -0,0 +1,7 @@
import React from 'react';
import 'styles/index.css';
import 'styles/bootstrap-grid.css';
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />;
}

16
pages/api/collect.js Normal file
View File

@ -0,0 +1,16 @@
import { parseCollectRequest } from 'lib/utils';
import { savePageView } from '../../lib/db';
export default async (req, res) => {
const values = parseCollectRequest(req);
if (values) {
const { type, session_id, url, referrer } = values;
if (type === 'pageview') {
await savePageView(session_id, url, referrer);
}
}
res.status(200).json({ status: 'ok' });
};

28
pages/api/session.js Normal file
View File

@ -0,0 +1,28 @@
import { getWebsite, getSession, createSession } from 'lib/db';
import { hash, parseSessionRequest } from 'lib/utils';
export default async (req, res) => {
let result = { time: Date.now() };
const { website_id, session_id, browser, os, screen, language, country } = parseSessionRequest(
req,
);
const website = await getWebsite(website_id);
if (website) {
const session = await getSession(session_id);
if (!session) {
await createSession(website_id, session_id, { browser, os, screen, language, country });
}
result = {
...result,
session_id,
website_id,
hash: hash(`${website_id}${session_id}${result.time}`),
};
}
res.status(200).json(result);
};

10
pages/index.js Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import Layout from 'components/layout';
export default function Home() {
return (
<Layout>
Hello.
</Layout>
);
}

17
postcss.config.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
plugins: [
'postcss-flexbugs-fixes',
[
'postcss-preset-env',
{
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
features: {
'custom-properties': false,
},
},
],
],
};

48
prisma/schema.prisma Normal file
View File

@ -0,0 +1,48 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model event {
created_at DateTime? @default(now())
event_id Int @default(autoincrement()) @id
event_type String
event_value String
session_id String?
url String
session session? @relation(fields: [session_id], references: [session_id])
}
model pageview {
created_at DateTime? @default(now())
referrer String?
session_id String?
url String
view_id Int @default(autoincrement()) @id
session session? @relation(fields: [session_id], references: [session_id])
}
model session {
browser String?
country String?
created_at DateTime? @default(now())
language String?
os String?
screen String?
session_id String @id
website_id String?
website website? @relation(fields: [website_id], references: [website_id])
event event[]
pageview pageview[]
}
model website {
created_at DateTime? @default(now())
hostname String @unique
website_id String @id
session session[]
}

1
public/umami.js Normal file

File diff suppressed because one or more lines are too long

24
rollup.config.js Normal file
View File

@ -0,0 +1,24 @@
import 'dotenv/config';
import { terser } from 'rollup-plugin-terser';
import replace from 'rollup-plugin-replace';
import { nodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: 'scripts/umami/index.js',
output: {
file: 'public/umami.js',
format: 'iife',
globals: {
'detect-browser': 'detectBrowser',
'whatwg-fetch': 'fetch',
},
plugins: [terser()],
},
context: 'window',
plugins: [
nodeResolve(),
replace({
'process.env.UMAMI_URL': JSON.stringify(process.env.UMAMI_URL),
}),
],
};

39
scripts/umami/index.js Normal file
View File

@ -0,0 +1,39 @@
import 'whatwg-fetch';
function post(url, params) {
return fetch(url, {
method: 'post',
body: JSON.stringify(params),
}).then(res => res.json());
}
(async () => {
const script = document.querySelector('script[data-website-id]');
const website_id = script.getAttribute('data-website-id');
if (website_id) {
const { width, height } = window.screen;
const { language } = window.navigator;
const { hostname, pathname, search } = window.location;
const referrer = window.document.referrer;
const screen = `${width}x${height}`;
const url = `${pathname}${search}`;
if (!window.localStorage.getItem('umami.session')) {
const session = await post(`${process.env.UMAMI_URL}/api/session`, {
website_id,
hostname,
url,
screen,
language,
});
console.log(session);
window.localStorage.setItem('umami.session', JSON.stringify(session));
}
await post(`${process.env.UMAMI_URL}/api/collect`, {
type: 'pageview',
payload: { url, referrer, session: JSON.parse(window.localStorage.getItem('umami.session')) },
});
}
})();

33
sql/schema.sql Normal file
View File

@ -0,0 +1,33 @@
create table website (
website_id uuid primary key,
hostname varchar(255) unique not null,
created_at timestamp with time zone default current_timestamp
);
create table session (
session_id uuid primary key,
website_id uuid references website(website_id) on delete cascade,
created_at timestamp with time zone default current_timestamp,
browser varchar(20),
os varchar(20),
screen varchar(11),
language varchar(35),
country char(2)
);
create table pageview (
view_id serial primary key,
session_id uuid references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp,
url varchar(500) not null,
referrer varchar(500)
);
create table event (
event_id serial primary key,
session_id uuid references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp,
url varchar(500) not null,
event_type varchar(50) not null,
event_value varchar(255) not null
);

3981
styles/bootstrap-grid.css vendored Normal file

File diff suppressed because it is too large Load Diff

25
styles/index.css Normal file
View File

@ -0,0 +1,25 @@
html,
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 17px;
font-weight: 300;
line-height: 1.8;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
#__next {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}

8450
yarn.lock Normal file

File diff suppressed because it is too large Load Diff