468 lines
13 KiB
TypeScript
468 lines
13 KiB
TypeScript
import React from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { useState, useEffect } from 'react';
|
|
import { ApiaryAPI, LoginAttempt, DummyApiaryAPIClient, ApiaryAPIClient, TotalStats, StatsResult, StatsType } from "./api";
|
|
import { BrowserRouter, NavLink, Routes, Route } from "react-router";
|
|
import { Chart as ChartJS, Tooltip, ArcElement, Legend, ChartOptions } from "chart.js";
|
|
import { Pie } from "react-chartjs-2";
|
|
import humanizeDuration from "humanize-duration";
|
|
import * as classes from "../css/style.module.css";
|
|
|
|
ChartJS.register(Tooltip, ArcElement, Legend);
|
|
console.log(classes);
|
|
|
|
interface AppProps {
|
|
api: ApiaryAPI
|
|
}
|
|
|
|
let chartColors = [
|
|
"#1abc9c",
|
|
"#2ecc71",
|
|
"#3498db",
|
|
"#9b59b6",
|
|
"#34495e",
|
|
"#16a085",
|
|
"#27ae60",
|
|
"#2980b9",
|
|
"#8e44ad",
|
|
"#2c3e50",
|
|
"#f1c40f",
|
|
"#e67e22",
|
|
"#e74c3c",
|
|
"#ecf0f1",
|
|
"#95a5a6",
|
|
"#f39c12",
|
|
"#d35400",
|
|
"#c0392b",
|
|
"#bdc3c7",
|
|
"#7f8c8d",
|
|
]
|
|
|
|
export function App({ api }: AppProps) {
|
|
const [mode, setMode] = useState("light");
|
|
const headerProps: HeaderMenuProps = {
|
|
title: "apiary.home.2rjus.net",
|
|
items: [
|
|
{
|
|
name: "Totals",
|
|
path: "/",
|
|
},
|
|
{
|
|
name: "Passwords",
|
|
path: "/stats/password",
|
|
},
|
|
{
|
|
name: "Usernames",
|
|
path: "/stats/username",
|
|
},
|
|
{
|
|
name: "IPs",
|
|
path: "/stats/ip",
|
|
},
|
|
{
|
|
name: "Live",
|
|
path: "/live",
|
|
},
|
|
{
|
|
name: "Query",
|
|
path: "/query",
|
|
}
|
|
]
|
|
}
|
|
const onSelectMode = (mode: string) => {
|
|
setMode(mode);
|
|
|
|
if (mode === "dark") {
|
|
document.body.classList.add(classes.dark_mode);
|
|
} else {
|
|
document.body.classList.remove(classes.dark_mode);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light"));
|
|
onSelectMode(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
return () => {
|
|
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', (e) => onSelectMode(e.matches ? "dark" : "light"));
|
|
}
|
|
}, [])
|
|
return (
|
|
<>
|
|
<BrowserRouter>
|
|
<div id="app">
|
|
<HeaderMenu title={headerProps.title} items={headerProps.items} />
|
|
<div className={classes.content}>
|
|
<Routes>
|
|
<Route path="/" element={<Home api={api} />} />
|
|
<Route path="/stats/password" element={<Stats api={api} type="password" />} />
|
|
<Route path="/stats/username" element={<Stats api={api} type="username" />} />
|
|
<Route path="/stats/ip" element={<Stats api={api} type="ip" />} />
|
|
<Route path="/live" element={<Live api={api} />} />
|
|
<Route path="/query" element={<Query api={api} />} />
|
|
</Routes>
|
|
</div>
|
|
</div>
|
|
</BrowserRouter >
|
|
</>
|
|
);
|
|
}
|
|
|
|
export interface StatsProps {
|
|
api: ApiaryAPI
|
|
type: StatsType
|
|
}
|
|
|
|
export function Stats({ api, type }: StatsProps) {
|
|
const [stats, setStats] = useState<StatsResult[] | null>(null)
|
|
const [currentType, setCurrentType] = useState(type)
|
|
|
|
useEffect(() => {
|
|
async function getStats() {
|
|
try {
|
|
let newStats = await api.stats(type, 10);
|
|
if (JSON.stringify(newStats) !== JSON.stringify(stats)) {
|
|
setStats(newStats)
|
|
}
|
|
} catch (e) {
|
|
console.log("Error getting stats", e)
|
|
}
|
|
}
|
|
|
|
if (currentType !== type) {
|
|
setCurrentType(type)
|
|
getStats()
|
|
}
|
|
|
|
if (stats === null) {
|
|
getStats()
|
|
}
|
|
|
|
const interval = setInterval(() => {
|
|
getStats()
|
|
}, 60000)
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
}
|
|
}, [stats, type])
|
|
|
|
return (
|
|
<>
|
|
{(stats != null && stats.length > 0) ? <StatsPie data={stats} /> : <p>Loading...</p>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export interface StatsPieProps {
|
|
data: StatsResult[]
|
|
}
|
|
|
|
export function StatsPie({ data }: StatsPieProps) {
|
|
const labels = data.map((d) => d.name);
|
|
const values = data.map((d) => d.count);
|
|
const piedata = {
|
|
labels,
|
|
datasets: [{
|
|
label: "# of attempts",
|
|
data: values,
|
|
backgroundColor: chartColors,
|
|
borderWidth: 1
|
|
}]
|
|
};
|
|
console.log(piedata)
|
|
const getTextColor = () => {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'white' : 'black'
|
|
}
|
|
const options: ChartOptions<"pie"> = {
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
align: "start",
|
|
labels: {
|
|
color: getTextColor(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
<div className={classes.stats_pie}>
|
|
<Pie data={piedata} options={options} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Home({ api }: AppProps) {
|
|
const [totals, setTotals] = useState<TotalStats | null>(null)
|
|
useEffect(() => {
|
|
async function getTotals() {
|
|
let totals = await api.totals();
|
|
setTotals(totals);
|
|
}
|
|
|
|
if (!totals) {
|
|
getTotals();
|
|
}
|
|
|
|
const interval = setInterval(() => {
|
|
getTotals();
|
|
}, 5000)
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
}
|
|
})
|
|
return (
|
|
<>
|
|
{totals ? <Totals totals={totals} /> : <p>Loading...</p>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export interface TotalsProps {
|
|
totals: TotalStats
|
|
}
|
|
|
|
export function Totals({ totals }: TotalsProps) {
|
|
return (
|
|
<div className={classes.totals}>
|
|
<div className={classes.totals_key}>
|
|
<h2>Unique passwords</h2>
|
|
<h2>Unique username</h2>
|
|
<h2>Unique IPs</h2>
|
|
<h2>Total attempts</h2>
|
|
</div>
|
|
<div className={classes.totals_value}>
|
|
<h2>{totals.password}</h2>
|
|
<h2>{totals.username}</h2>
|
|
<h2>{totals.ip}</h2>
|
|
<h2>{totals.attempts}</h2>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Live({ api }: AppProps) {
|
|
let list: LoginAttempt[] = [];
|
|
let [liveList, setLiveList] = useState(list);
|
|
|
|
useEffect(() => {
|
|
const cleanup = api.live((a) => {
|
|
setLiveList((list) => {
|
|
return [a, ...list];
|
|
});
|
|
});
|
|
|
|
return cleanup
|
|
|
|
}, [liveList, api]);
|
|
|
|
return (
|
|
<>
|
|
<LiveList list={liveList} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export interface LiveListProps {
|
|
list: LoginAttempt[]
|
|
};
|
|
|
|
export interface DateTDProps {
|
|
date: Date
|
|
now: Date
|
|
}
|
|
|
|
export function DateTD({ date, now }: DateTDProps) {
|
|
const [displayDate, setDisplayDate] = useState(date.toLocaleString());
|
|
|
|
useEffect(() => {
|
|
if (now.getTime() - date.getTime() < 14400000) {
|
|
const newDate = humanizeDuration(now.getTime() - date.getTime(), { largest: 1, round: true }) + " ago";
|
|
if (newDate !== displayDate) {
|
|
setDisplayDate(newDate);
|
|
}
|
|
}
|
|
}, [displayDate, now])
|
|
|
|
return (
|
|
<td className={classes.live_table_date}>{displayDate}</td>
|
|
)
|
|
}
|
|
|
|
export function LiveList({ list }: LiveListProps) {
|
|
const [now, setNow] = useState(new Date())
|
|
|
|
let items = list.map((a) => {
|
|
const attemptDate = new Date(a.date);
|
|
const key = `${a.username}-${a.password}-${a.remoteIP}-${a.date}`;
|
|
return (
|
|
<tr key={a.date}>
|
|
<DateTD date={attemptDate} now={now} />
|
|
<td className={classes.live_table_username}>{a.username}</td>
|
|
<td className={classes.live_table_password}>{a.password}</td>
|
|
<td className={classes.live_table_ip}>{a.remoteIP}</td>
|
|
<td className={classes.live_table_country}>{a.country}</td>
|
|
</tr>
|
|
)
|
|
})
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setNow(new Date())
|
|
}, 1000)
|
|
|
|
return () => {
|
|
clearInterval(interval)
|
|
}
|
|
}, [now])
|
|
|
|
return (
|
|
<>
|
|
<table className={classes.live_table}>
|
|
<thead>
|
|
<tr>
|
|
<th className={classes.live_table_date}>Date</th>
|
|
<th className={classes.live_table_username}>Username</th>
|
|
<th className={classes.live_table_password}>Password</th>
|
|
<th className={classes.live_table_ip}>IP</th>
|
|
<th className={classes.live_table_country}>Country</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items}
|
|
</tbody>
|
|
</table>
|
|
</>
|
|
)
|
|
};
|
|
|
|
|
|
export function Query({ api }: AppProps) {
|
|
const [liveList, setLiveList] = useState<LoginAttempt[]>([]);
|
|
const [queryErr, setQueryErr] = useState<Error | null>(null);
|
|
|
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
const value = event.currentTarget.query.value;
|
|
if (value === "") {
|
|
setQueryErr(new Error("Query cannot be empty"));
|
|
return
|
|
}
|
|
try {
|
|
const results = await api.query("", value)
|
|
setQueryErr(null);
|
|
setLiveList(results);
|
|
} catch (e) {
|
|
if (e instanceof Error) {
|
|
setQueryErr(e);
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
<>
|
|
<form onSubmit={handleSubmit}>
|
|
<input placeholder="Search..." name="query" type="text" />
|
|
</form>
|
|
{queryErr ? <ErrorBox message={queryErr.message} /> : null}
|
|
<LiveList list={liveList} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface ErrorBoxProps {
|
|
message: string | null
|
|
};
|
|
|
|
export function ErrorBox({ message }: ErrorBoxProps) {
|
|
return (
|
|
<div className={classes.error_box}>
|
|
<p>Error: {message}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Header() {
|
|
return (
|
|
<div className={classes.navbar}>
|
|
<h2 id="menu-title">apiary.home.2rjus.net</h2>
|
|
<nav className={classes.nav_flex}>
|
|
<ul>
|
|
<li><NavLink to="/" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Home</NavLink></li>
|
|
<li><NavLink to="/stats/password" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Stats</NavLink></li>
|
|
<li><NavLink to="/live" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Live</NavLink></li>
|
|
<li><NavLink to="/query" className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>Query</NavLink></li>
|
|
</ul>
|
|
</nav>
|
|
</div >
|
|
);
|
|
}
|
|
|
|
interface HeaderItem {
|
|
name: string
|
|
path: string
|
|
}
|
|
|
|
interface HeaderMenuProps {
|
|
title: string
|
|
items: Array<HeaderItem>
|
|
}
|
|
|
|
export function HeaderMenu({ title, items }: HeaderMenuProps) {
|
|
const menuItems = items.map((item) => {
|
|
return (
|
|
<li key={item.path}>
|
|
<NavLink to={item.path} className={({ isActive }) => isActive ? classes.menu_link_active : classes.menu_link}>{item.name}</NavLink>
|
|
</li>
|
|
)
|
|
})
|
|
|
|
return (
|
|
<div className={classes.navbar}>
|
|
<img id={classes.menu_logo}></img>
|
|
<h2 id={classes.menu_title}>{title}</h2>
|
|
<nav className={classes.nav_flex}>
|
|
<ul>
|
|
{menuItems}
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export interface SubMenuProps {
|
|
items: Array<{ name: string, active: () => boolean, onClick: () => void }>
|
|
}
|
|
|
|
export function SubMenu({ items }: SubMenuProps) {
|
|
return (
|
|
<nav className={classes.submenu}>
|
|
<ul>
|
|
{items.map((item) => {
|
|
return <li>
|
|
<a
|
|
href="#"
|
|
className={item.active() ? classes.sub_menu_active : classes.sub_menu_link}
|
|
onClick={item.onClick}>{item.name}</a>
|
|
</li>
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
const rootElement = document.getElementById('root');
|
|
if (rootElement) {
|
|
const root = createRoot(rootElement);
|
|
let api: ApiaryAPI;
|
|
if (process.env.NODE_ENV === "production") {
|
|
api = new ApiaryAPIClient();
|
|
} else {
|
|
api = new DummyApiaryAPIClient();
|
|
}
|
|
|
|
|
|
root.render(
|
|
<App api={api} />
|
|
);
|
|
}
|