import fs from 'node:fs/promises'
import path from 'node:path'
import type { IncomingMessage } from 'node:http'
import createDatabase from './db.ts'
import { registerRoute } from './routes.ts'
import { renderVariables } from './renderer.ts'
import { authenticate } from './utils.ts'
import type { DatabaseSync } from 'node:sqlite'
type DBRecord = {
created_at: string
ip: string
referer: string
}
type AnalyticsConfig = {
root?: string
admin?: {
login?: string
password?: string
}
}
export function createAnalytics(config: AnalyticsConfig) {
const { db, createTable } = createDatabase('analytics.db')
createTable('visitor', {
ip: 'string',
referer: 'string',
})
// generateFakeVisitors(db, 10000)
registerRoute({
route: '/analytics',
handler: async (req, res) => {
authenticate(req, res, {
username: config.admin?.login || '',
password: config.admin?.password || '',
})
const fileContent = await fs.readFile(
path.join(config.root || '', 'analytics.html')
)
const pretty = getData(db, { days: 365, resolution: '1 day' })
const maxValue = Math.max(...pretty.map(v => v[1] || 0))
const html = pretty.reduce((acc, cur, i, arr) => {
if (!cur[1]) return acc
const li = `
| ${cur[0]} |
${cur[1]}
|
`
return acc + li
}, '')
const total_count = pretty.reduce((acc, cur) => (acc += cur.length), 0)
return renderVariables(fileContent.toString(), {
data: html,
total_count,
})
},
})
function recordVisitor(req: IncomingMessage) {
const { userAgent, remoteIp } = visitorFromRequest(req)
const { pathname } = new URL(req.url || '', 'https://foobar.com')
if (pathname === '/analytics') return
if (isBot(userAgent)) return
if (!remoteIp || isRegistered(db, remoteIp)) return
const referer = req.headers.referrer || req.headers.referer
const refererStr = Array.isArray(referer) ? referer[0] : referer
if (typeof remoteIp === 'string' && refererStr) {
db.prepare('INSERT INTO visitor (ip, referer) VALUES (?, ?)').run(
remoteIp,
refererStr
)
}
}
return {
recordVisitor,
}
}
function getData(db: DatabaseSync, { days = 31, resolution = '1 hour' }) {
let query = ''
switch (days) {
case 1: {
query = `SELECT * FROM visitor WHERE created_at BETWEEN datetime('now', '-1 day') AND datetime('now')`
break
}
case 7: {
query = `SELECT * FROM visitor WHERE created_at BETWEEN datetime('now', '-1 week') AND datetime('now')`
break
}
case 31: {
query = `SELECT * FROM visitor WHERE created_at BETWEEN datetime('now', '-1 month') AND datetime('now')`
break
}
case 365: {
query = `SELECT * FROM visitor WHERE created_at BETWEEN datetime('now', '-1 year') AND datetime('now')`
break
}
}
const data = db.prepare(query).all() as DBRecord[]
const resolutions: Record = {
'1 second': 1000,
'1 minute': 1000 * 60,
'1 hour': 1000 * 60 * 60,
'1 day': 1000 * 60 * 60 * 24,
'1 week': 1000 * 60 * 60 * 24 * 7,
'1 month': 1000 * 60 * 60 * 24 * 7 * 31,
'1 year': 1000 * 60 * 60 * 24 * 7 * 365,
}
const res = resolutions[resolution]
const grouped = Object.groupBy(
data,
({ created_at }: DBRecord) =>
Math.floor(new Date(created_at).getTime() / res) * res
)
const sorted = Object.entries(grouped).toSorted((a, b) => {
return a[0] < b[0] ? -1 : 1
})
const pretty = sorted.map(([key, value]): [string, number | undefined] => {
const date = new Date(parseInt(key))
const showTime = ['1 second', '1 minute', '1 hour'].includes(resolution)
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
...(showTime
? { hour: 'numeric', minute: 'numeric', second: 'numeric' }
: {}),
}
const formatter = new Intl.DateTimeFormat('da-DK', options)
const formattedDate = formatter.format(date)
return [formattedDate, value?.length]
})
return pretty
}
function isRegistered(db: DatabaseSync, ip: string) {
return db
.prepare(
`SELECT ip, created_at FROM visitor WHERE ip = ? AND created_at >= datetime('now', '-1 hour')`
)
.get(ip)
}
function visitorFromRequest(req: IncomingMessage) {
const userAgent = req.headers['user-agent']
const remoteIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress
return {
userAgent,
remoteIp: Array.isArray(remoteIp) ? remoteIp[0] : remoteIp,
}
}
function isBot(userAgent: string = '') {
return /(bot|check|cloud|crawler|download|monitor|preview|scan|spider|google|qwantify|yahoo|HeadlessChrome)/i.test(
userAgent
)
}
export function generateFakeVisitors(db: DatabaseSync, count: number) {
db.exec(`CREATE TABLE IF NOT EXISTS visitor (
id 'integer primary key autoincrement',
created_at 'date',
ip 'string',
referer 'string'
);`)
const zero255 = () => Math.floor(Math.random() * 255)
const randomIP = () => `${zero255()}.${zero255()}.${zero255()}.${zero255()}`
for (let i = 0; i < count; i++) {
db.prepare(
"INSERT INTO visitor (created_at, ip) VALUES (datetime('now', '-' || (abs(random()) % (365 * 24 * 60 * 60 * 3)) || ' seconds'), ?)"
).run(randomIP())
}
}