chore: init

This commit is contained in:
Dorian Niemiec 2024-12-18 18:02:43 +01:00
commit 1d518fe1d8
29 changed files with 12867 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Build output
/dist/
# Backend configuration
/src/config.php
# Node.JS development dependencies
node_modules/
# ESLint cache
.eslintcache
# Editor directories and files
*.swa-p

2
.husky/commit-msg Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
npx --no -- commitlint --edit "$1"

2
.husky/pre-commit Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
npx lint-staged

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2024 SVR.JS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
commitlint.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
extends: ["@commitlint/config-conventional"]
};

63
db/database.sql Normal file
View file

@ -0,0 +1,63 @@
/*!999999\- enable the sandbox mode */
-- MariaDB dump 10.19 Distrib 10.6.18-MariaDB, for debian-linux-gnu (x86_64)
--
-- Host: localhost Database: statistics
-- ------------------------------------------------------
-- Server version 10.6.18-MariaDB-0ubuntu0.22.04.1
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `entries`
--
DROP TABLE IF EXISTS `entries`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `entries` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`ip` varchar(100) NOT NULL,
`time` datetime NOT NULL,
`version` varchar(100) NOT NULL,
`runtime` varchar(100) NOT NULL,
`runtime_version` varchar(100) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `entries_mods`
--
DROP TABLE IF EXISTS `entries_mods`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `entries_mods` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`entry_id` int(10) NOT NULL,
`name` varchar(100) NOT NULL,
`version` varchar(100) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2024-08-06 14:32:28

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/README.md Normal file
View file

@ -0,0 +1,8 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

40
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,40 @@
import js from "@eslint/js";
import globals from "globals";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import prettierRecommended from "eslint-plugin-prettier/recommended";
export default [
{ ignores: ["dist"] },
{
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: "latest",
ecmaFeatures: { jsx: true },
sourceType: "module"
}
},
settings: { react: { version: "18.3" } },
plugins: {
react,
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs["jsx-runtime"].rules,
...reactHooks.configs.recommended.rules,
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true }
]
}
},
prettierRecommended
];

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVR.JS statistics</title>
</head>
<body>
<noscript>SVR.JS statistics dashboard requires JavaScript to work correctly.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View file

@ -0,0 +1,3 @@
export default {
"src/**/*": "eslint --cache --fix"
};

7991
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

42
frontend/package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "svrjs-statistics-server-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/poppins": "^5.1.0",
"apexcharts": "^4.2.0",
"react": "^18.3.1",
"react-apexcharts": "^1.7.0",
"react-dom": "^18.3.1",
"unfetch": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"cssnano": "^7.0.6",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.12.0",
"postcss": "^8.4.49",
"postcss-css-variables": "^0.19.0",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.16",
"vite": "^6.0.1"
}
}

View file

@ -0,0 +1,8 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
"postcss-css-variables": {},
cssnano: {}
}
};

View file

@ -0,0 +1,15 @@
// prettier.config.js, .prettierrc.js, prettier.config.cjs, or .prettierrc.cjs
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
const config = {
trailingComma: "none",
tabWidth: 2,
semi: true,
singleQuote: false,
endOfLine: "lf"
};
export default config;

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

279
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
import Chart from "react-apexcharts";
function generateChartOptions(data) {
return {
xaxis: {
categories: Object.keys(data)
},
yaxis: {},
theme: {
mode: "dark",
monochrome: {
enabled: true,
color: "#007000"
}
},
chart: {
fontFamily: "Poppins, sans-serif"
}
};
}
function generateChartSeries(data, series) {
return [
{
name: series,
data: Object.keys(data).map((key) => data[key])
}
];
}
function App() {
const [dailyStarts, setDailyStarts] = useState(0);
const [monthlyStarts, setMonthlyStarts] = useState(0);
const [yearlyStarts, setYearlyStarts] = useState(0);
const [totalStarts, setTotalStarts] = useState(0);
const [versionDistribution, setVersionDistribution] = useState({});
const [modDistribution, setModDistribution] = useState({});
const [jsRuntimeDistribution, setJsRuntimeDistribution] = useState({});
const [jsRuntimeVersionDistribution, setJsRuntimeVersionDistribution] =
useState({});
const [latestStarts, setLatestStarts] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async (endpoint) => {
const res = await fetch(endpoint);
const data = await res.json();
if (!res.ok) throw new Error(data.message);
return data;
};
const load = async () => {
try {
setDailyStarts(
(await fetchData("./api.php?scope=count&period=daily")).count
);
setMonthlyStarts(
(await fetchData("./api.php?scope=count&period=monthly")).count
);
setYearlyStarts(
(await fetchData("./api.php?scope=count&period=yearly")).count
);
setTotalStarts(
(await fetchData("./api.php?scope=count&period=total")).count
);
setVersionDistribution(
(
await fetchData(
"./api.php?scope=usage&subscope=versiondistribution"
)
).usage
);
setModDistribution(
(await fetchData("./api.php?scope=usage&subscope=modsdistribution"))
.usage
);
setJsRuntimeDistribution(
(
await fetchData(
"./api.php?scope=usage&subscope=jsruntimedistribution"
)
).usage
);
setJsRuntimeVersionDistribution(
(
await fetchData(
"./api.php?scope=usage&subscope=jsruntimeversiondistribution"
)
).usage
);
setLatestStarts(await fetchData("./api.php?scope=starts"));
setLoading(false);
} catch (err) {
setError(err);
}
};
load();
const interval = setInterval(() => {
load();
}, 60000);
return () => clearInterval(interval);
}, []);
if (error) {
return (
<>
<h1 className="text-4xl md:text-5xl lg:text-6xl mb-4 md:mb-6 font-bold">
Unexpected error
</h1>
<p className="md:text-lg lg:text-xl text-muted-foreground my-4 md:my-6">
An unexpected error occurred: {error.message}
</p>
</>
);
} else if (loading) {
return (
<h1 className="text-4xl md:text-5xl lg:text-6xl mb-4 md:mb-6 font-bold">
Loading...
</h1>
);
} else {
return (
<>
<h1 className="text-4xl md:text-5xl lg:text-6xl mb-4 md:mb-6 font-bold">
SVR.JS statistics
</h1>
<p className="md:text-lg lg:text-xl text-muted-foreground my-4 md:my-6">
The statistics will be reloaded every minute.
</p>
<h2 className="text-2xl md:text-3xl lg:text-4xl my-4 md:my-6 font-bold">
Insights
</h2>
<div className="flex flex-col md:flex-row md:flex-wrap w-full">
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
Daily SVR.JS starts
</h3>
<span className="text-4xl md:text-5xl">{dailyStarts}</span>
</div>
</div>
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
Monthly SVR.JS starts
</h3>
<span className="text-4xl md:text-5xl">{monthlyStarts}</span>
</div>
</div>
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
Yearly SVR.JS starts
</h3>
<span className="text-4xl md:text-5xl">{yearlyStarts}</span>
</div>
</div>
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
Total SVR.JS starts
</h3>
<span className="text-4xl md:text-5xl">{totalStarts}</span>
</div>
</div>
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
SVR.JS version distribution (monthly)
</h3>
<Chart
type="bar"
options={generateChartOptions(versionDistribution)}
series={generateChartSeries(
versionDistribution,
"SVR.JS version distribution (monthly)"
)}
/>
</div>
</div>
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
SVR.JS mods distribution (monthly)
</h3>
<Chart
type="bar"
options={generateChartOptions(modDistribution)}
series={generateChartSeries(
modDistribution,
"SVR.JS mods distribution (monthly)"
)}
/>
</div>
</div>
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
JavaScript runtime distribution (monthly)
</h3>
<Chart
type="bar"
options={generateChartOptions(jsRuntimeDistribution)}
series={generateChartSeries(
jsRuntimeDistribution,
"JavaScript runtime distribution (monthly)"
)}
/>
</div>
</div>
<div className="w-full md:w-1/2 p-1 md:p-2">
<div className="p-2 border border-border rounded-lg bg-card text-card-foreground">
<h3 className="text-base md:text-xl font-bold mb-2">
JS runtime version distribution (monthly)
</h3>
<Chart
type="bar"
options={generateChartOptions(jsRuntimeVersionDistribution)}
series={generateChartSeries(
jsRuntimeVersionDistribution,
"JS runtime version distribution (monthly)"
)}
/>
</div>
</div>
</div>
<h2 className="text-2xl md:text-3xl lg:text-4xl my-4 md:my-6 font-bold">
{latestStarts.limit} latest SVR.JS starts
</h2>
{latestStarts.starts.map((start, index) => {
return (
<div
className="my-2 w-full bg-card border-border border rounded-lg p-1 md:p-2"
key={index}
>
<h3 className="text-2xl md:text-3xl my-1 md:mb-2 font-bold">
{start.ip}
</h3>
<p className="text-sm md:text-base my-1">
<b className="font-bold">SVR.JS version:</b> {start.version} |{" "}
<b className="font-bold">JavaScript runtime:</b> {start.runtime}{" "}
| <b className="font-bold">JavaScript runtime version:</b>{" "}
{start.runtimeVersion}
</p>
{start.mods && start.mods.length > 0 ? (
<>
<h4 className="text-lg md:text-xl font-bold">
Installed mods:
</h4>
<ul className="border-border border bg-background text-foreground rounded-lg my-1">
{start.mods.map((mod, index) => {
return (
<li
className={`text-sm md:text-base p-1 border-border ${index == start.mods.length - 1 ? "" : "border-b"}`}
key={index}
>
<b className="font-bold">{mod.name}</b> {mod.version}
</li>
);
})}
</ul>
</>
) : (
""
)}
</div>
);
})}
</>
);
}
}
export default App;

64
frontend/src/index.css Normal file
View file

@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
height: 100%;
width: 100%;
font-family: "Poppins", sans-serif;
background-color: hsla(var(--background), 1);
color: hsla(var(--foreground), 1);
}
@layer base {
:root {
--svg-fill: white;
--svg-background: black;
--background: 0, 0%, 100%;
--foreground: 240, 10%, 3.9%;
--card: 0, 0%, 100%;
--card-foreground: 240, 10%, 3.9%;
--popover: 0, 0%, 100%;
--popover-foreground: 240, 10%, 3.9%;
--primary: 142.1, 76.2%, 36.3%;
--primary-foreground: 355.7, 100%, 97.3%;
--secondary: 240, 4.8%, 95.9%;
--secondary-foreground: 240, 5.9%, 10%;
--muted: 240, 4.8%, 95.9%;
--muted-foreground: 240, 3.8%, 46.1%;
--accent: 240, 4.8%, 95.9%;
--accent-foreground: 240, 5.9%, 10%;
--destructive: 0, 84.2%, 60.2%;
--destructive-foreground: 0, 0%, 98%;
--border: 240, 5.9%, 90%;
--input: 240, 5.9%, 90%;
--ring: 142.1, 76.2%, 36.3%;
--radius: 0.5rem;
}
@media screen and (prefers-color-scheme: dark) {
:root {
--svg-fill: black;
--svg-background: white;
--background: 20, 14.3%, 4.1%;
--foreground: 0, 0%, 95%;
--card: 24, 9.8%, 10%;
--card-foreground: 0, 0%, 95%;
--popover: 0, 0%, 9%;
--popover-foreground: 0, 0%, 95%;
--primary: 142.1, 70.6%, 45.3%;
--primary-foreground: 144.9, 80.4%, 10%;
--secondary: 240, 3.7%, 15.9%;
--secondary-foreground: 0, 0%, 98%;
--muted: 0, 0%, 15%;
--muted-foreground: 240, 5%, 64.9%;
--accent: 12, 6.5%, 15.1%;
--accent-foreground: 0, 0%, 98%;
--destructive: 0, 62.8%, 30.6%;
--destructive-foreground: 0, 85.7%, 97.3%;
--border: 240, 3.7%, 15.9%;
--input: 240, 3.7%, 15.9%;
--ring: 142.4, 71.8%, 29.2%;
}
}
}

14
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@fontsource/poppins";
import "@fontsource/poppins/700.css";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<div className="max-w-screen-lg mx-auto lg:my-8 xl:my-16 p-2">
<App />
</div>
</StrictMode>
);

View file

@ -0,0 +1,63 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
border: "hsla(var(--border), 1)",
input: "hsla(var(--input), 1)",
ring: "hsla(var(--ring), 1)",
background: "hsla(var(--background), 1)",
foreground: "hsla(var(--foreground), 1)",
primary: {
DEFAULT: "hsla(var(--primary), 1)",
foreground: "hsla(var(--primary-foreground), 1)"
},
secondary: {
DEFAULT: "hsla(var(--secondary), 1)",
foreground: "hsla(var(--secondary-foreground), 1)"
},
destructive: {
DEFAULT: "hsla(var(--destructive), 1)",
foreground: "hsla(var(--destructive-foreground), 1)"
},
muted: {
DEFAULT: "hsla(var(--muted), 1)",
foreground: "hsla(var(--muted-foreground), 1)"
},
accent: {
DEFAULT: "hsla(var(--accent), 1)",
foreground: "hsla(var(--accent-foreground), 1)"
},
popover: {
DEFAULT: "hsla(var(--popover), 1)",
foreground: "hsla(var(--popover-foreground), 1)"
},
card: {
DEFAULT: "hsla(var(--card), 1)",
foreground: "hsla(var(--card-foreground), 1)"
}
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)"
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" }
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 }
}
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out"
}
}
},
plugins: []
};

32
frontend/vite.config.js Normal file
View file

@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import legacy from "@vitejs/plugin-legacy";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
legacy({
targets: ["firefox >= 52", "chrome >= 49", "safari >= 11", "edge >= 12"],
additionalLegacyPolyfills: ["unfetch/polyfill"]
})
],
build: {
outDir: "../dist",
emptyOutDir: false,
chunkSizeWarningLimit: 800
},
resolve: {
alias: {
"@": "/src"
}
},
server: {
proxy: {
"^/(?:(?!\\.php(?:$|[?#])).)+.php(?:$|[?#])": {
target: "http://localhost:8000",
changeOrigin: true
}
}
}
});

3
lint-staged.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
"src/**/*.php": "prettier --list-different"
};

3638
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "svrjs-statistics-server",
"version": "0.1.0",
"private": true,
"scripts": {
"build:frontend": "cd frontend && npm run build",
"build:backend": "node scripts/copyBackend.js",
"build": "rimraf dist && npm run build:backend && npm run build:frontend",
"cz": "cz",
"dev": "concurrently \"php -t src/ -S localhost:8000\" \"cd frontend && npm run dev\"",
"lint:backend": "prettier src/**/*.php --list-different",
"lint:backend-fix": "prettier src/**/*.php --write",
"lint:frontend": "cd frontend && npm run lint",
"lint:frontend-fix": "cd frontend && npm run lint:fix",
"lint": "npm run lint:backend && npm run lint:frontend",
"lint:fix": "npm run lint:backend-fix && npm run lint:frontend-fix",
"postinstall": "cd frontend && npm install",
"prepare": "husky",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@commitlint/cli": "^19.6.1",
"@commitlint/config-conventional": "^19.6.0",
"@prettier/plugin-php": "^0.22.2",
"commitizen": "^4.3.1",
"concurrently": "^9.1.0",
"cpr": "^3.0.1",
"cz-conventional-changelog": "^3.3.0",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"prettier": "^3.4.2",
"rimraf": "^5.0.10"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

16
prettier.config.js Normal file
View file

@ -0,0 +1,16 @@
// prettier.config.js, .prettierrc.js, prettier.config.cjs, or .prettierrc.cjs
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
const config = {
plugins: ["@prettier/plugin-php"],
trailingComma: "none",
tabWidth: 2,
semi: true,
singleQuote: false,
endOfLine: "lf"
};
module.exports = config;

22
scripts/copyBackend.js Normal file
View file

@ -0,0 +1,22 @@
const fs = require("fs");
const cpr = require("cpr");
const path = require("path");
const srcFolder = path.resolve(__dirname, "..", "src");
const dbFolder = path.resolve(__dirname, "..", "db");
const distFolder = path.resolve(__dirname, "..", "dist");
fs.mkdir(distFolder, (err) => {
if (err && err.code != "EEXIST") throw err;
cpr(srcFolder, distFolder, {
overwrite: true,
filter: (pathname) => pathname != path.resolve(srcFolder, "config.php")
}, (err) => {
if (err) throw err;
cpr(dbFolder, distFolder, {
overwrite: true
}, (err) => {
if (err) throw err;
})
})
});

264
src/api.php Normal file
View file

@ -0,0 +1,264 @@
<?php
include "./config.php";
$mysqlDriver = new mysqli_driver();
$mysqlDriver->report_mode = MYSQLI_REPORT_OFF;
header("Content-Type: application/json");
if (!isset($_GET["scope"])) {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "No scope defined.",
]);
exit();
} elseif ($_GET["scope"] == "count") {
$query = "";
if (!isset($_GET["period"])) {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "No period defined.",
]);
exit();
} elseif ($_GET["period"] == "daily") {
$query =
"SELECT COUNT(*) AS 'count' FROM entries WHERE time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY);";
} elseif ($_GET["period"] == "weekly") {
$query =
"SELECT COUNT(*) AS 'count' FROM entries WHERE time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 WEEK);";
} elseif ($_GET["period"] == "monthly") {
$query =
"SELECT COUNT(*) AS 'count' FROM entries WHERE time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH);";
} elseif ($_GET["period"] == "yearly" || $_GET["period"] == "annual") {
$query =
"SELECT COUNT(*) AS 'count' FROM entries WHERE time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 YEAR);";
} elseif ($_GET["period"] == "total" || $_GET["period"] == "all") {
$query = "SELECT COUNT(*) AS 'count' FROM entries;";
} else {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "Invalid period.",
]);
exit();
}
$connection = new mysqli(
MYSQL_HOST,
MYSQL_USERNAME,
MYSQL_PASSWORD,
MYSQL_DATABASE,
MYSQL_PORT
);
if ($connection->connect_error) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->connect_error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
exit();
}
$result = $connection->query($query);
if (!$result) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
$connection->close();
exit();
}
$row = $result->fetch_assoc();
if (!$row || !isset($row["count"])) {
error_log("There was an error while processing the request!");
error_log("Error: The row is either missing or invalid.");
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
$connection->close();
exit();
}
http_response_code(200);
echo json_encode([
"status" => 200,
"count" => intval($row["count"]),
]);
$connection->close();
exit();
} elseif ($_GET["scope"] == "usage") {
$query = "";
if (!isset($_GET["subscope"])) {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "No subscope defined.",
]);
exit();
} elseif ($_GET["subscope"] == "versiondistribution") {
$query =
"SELECT CONCAT('SVR.JS ', version) AS 'key', COUNT(*) AS 'value' FROM entries WHERE time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH) GROUP BY version;";
} elseif ($_GET["subscope"] == "modsdistribution") {
$query =
"SELECT entries_mods.name AS 'key', COUNT(*) AS 'value' FROM entries_mods INNER JOIN entries ON entries_mods.entry_id = entries.id WHERE entries.time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH) GROUP BY entries_mods.name;";
} elseif ($_GET["subscope"] == "jsruntimedistribution") {
$query =
"SELECT runtime AS 'key', COUNT(*) AS 'value' FROM entries WHERE time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH) GROUP BY runtime;";
} elseif ($_GET["subscope"] == "jsruntimeversiondistribution") {
$query =
"SELECT CONCAT(runtime, ' ', runtime_version) AS 'key', COUNT(*) AS 'value' FROM entries WHERE time >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH) GROUP BY runtime, runtime_version;";
} else {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "Invalid subscope.",
]);
exit();
}
$connection = new mysqli(
MYSQL_HOST,
MYSQL_USERNAME,
MYSQL_PASSWORD,
MYSQL_DATABASE,
MYSQL_PORT
);
if ($connection->connect_error) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->connect_error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
exit();
}
$result = $connection->query($query);
if (!$result) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
$connection->close();
exit();
}
$usage = [];
while ($row = $result->fetch_assoc()) {
if (isset($row["key"]) && isset($row["value"])) {
$usage[$row["key"]] = $row["value"];
}
}
http_response_code(200);
echo json_encode([
"status" => 200,
"usage" => $usage,
]);
$connection->close();
exit();
} elseif ($_GET["scope"] == "starts") {
$connection = new mysqli(
MYSQL_HOST,
MYSQL_USERNAME,
MYSQL_PASSWORD,
MYSQL_DATABASE,
MYSQL_PORT
);
if ($connection->connect_error) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->connect_error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
exit();
}
$result = $connection->query(
"SELECT id, ip, time, version, runtime, runtime_version FROM entries ORDER BY id DESC LIMIT 10;"
);
if (!$result) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
$connection->close();
exit();
}
$starts = [];
while ($row = $result->fetch_assoc()) {
$start = [
"ip" => $row["ip"],
"time" => $row["time"],
"version" => $row["version"],
"runtime" => $row["runtime"],
"runtimeVersion" => $row["runtime_version"],
"mods" => [],
];
$startID = intval($row["id"]);
$result2 = $connection->query(
"SELECT name, version FROM entries_mods WHERE entry_id = $startID;"
);
if (!$result2) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
$connection->close();
exit();
}
while ($row2 = $result2->fetch_assoc()) {
array_push($start["mods"], [
"name" => $row2["name"],
"version" => $row2["version"],
]);
}
array_push($starts, $start);
}
http_response_code(200);
echo json_encode([
"status" => 200,
"limit" => 10,
"starts" => $starts,
]);
$connection->close();
exit();
} else {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "Invalid scope.",
]);
exit();
}
?>

172
src/collect.php Normal file
View file

@ -0,0 +1,172 @@
<?php
include "./config.php";
$mysqlDriver = new mysqli_driver();
$mysqlDriver->report_mode = MYSQLI_REPORT_OFF;
header("Content-Type: application/json");
if ($_SERVER["REQUEST_METHOD"] == "POST") {
if (
isset($_SERVER["CONTENT_TYPE"]) &&
!preg_match('/^application\/json(?:$|;)/', $_SERVER["CONTENT_TYPE"])
) {
header("Accept: application/json", true, 415);
echo json_encode([
"status" => 415,
"message" => "Only JSON is supported.",
]);
exit();
}
$jsonData = file_get_contents("php://input");
$parsedJsonData = json_decode($jsonData, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "JSON parse error.",
]);
exit();
}
$isValid = true;
if (
!isset($parsedJsonData["version"]) ||
!isset($parsedJsonData["runtime"]) ||
!isset($parsedJsonData["runtimeVersion"]) ||
!isset($parsedJsonData["mods"]) ||
!is_string($parsedJsonData["version"]) ||
!is_string($parsedJsonData["runtime"]) ||
!is_string($parsedJsonData["runtimeVersion"]) ||
!is_array($parsedJsonData["mods"])
) {
$isValid = false;
} elseif (!in_array($parsedJsonData["runtime"], ["Node.js", "Bun", "Deno"])) {
$isValid = false;
} else {
foreach ($parsedJsonData["mods"] as $mod) {
if (
!isset($mod["version"]) ||
!is_string($mod["name"]) ||
!is_string($mod["version"])
) {
$isValid = false;
break;
}
}
}
if (!$isValid) {
http_response_code(400);
echo json_encode([
"status" => 400,
"message" => "Invalid data.",
]);
exit();
}
$requestIP = $_SERVER["REMOTE_ADDR"];
$dnsRecords = dns_get_record(DOMAIN, DNS_A + DNS_AAAA);
if ($dnsRecords) {
$ipAddresses = array_map(
function ($record) {
return isset($record["ip"]) ? $record["ip"] : $record["ipv6"];
},
array_filter($dnsRecords, function ($record) {
return isset($record["ip"]) || isset($record["ipv6"]);
})
);
if (in_array($requestIP, $ipAddresses)) {
http_response_code(200);
echo json_encode([
"status" => 200,
"message" => "The statistics are added successfully.",
]);
exit();
}
}
$connection = new mysqli(
MYSQL_HOST,
MYSQL_USERNAME,
MYSQL_PASSWORD,
MYSQL_DATABASE,
MYSQL_PORT
);
if ($connection->connect_error) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->connect_error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
exit();
}
$escapedRequestIP = $connection->real_escape_string($requestIP);
$version = $connection->real_escape_string($parsedJsonData["version"]);
$runtime = $connection->real_escape_string($parsedJsonData["runtime"]);
$runtimeVersion = $connection->real_escape_string(
$parsedJsonData["runtimeVersion"]
);
$query = "INSERT INTO entries (ip, time, version, runtime, runtime_version) VALUES ('$escapedRequestIP', NOW(), '$version', '$runtime', '$runtimeVersion')";
if (!$connection->query($query)) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
$connection->close();
exit();
}
$entryId = intval($connection->insert_id);
$entriesToInsert = [];
foreach ($parsedJsonData["mods"] as $mod) {
$name = $connection->real_escape_string($mod["name"]);
$modVersion = $connection->real_escape_string($mod["version"]);
$entriesToInsert[] = "($entryId, '$name', '$modVersion')";
}
if (!empty($entriesToInsert)) {
$query =
"INSERT INTO entries_mods (entry_id, name, version) VALUES " .
implode(", ", $entriesToInsert);
if (!$connection->query($query)) {
error_log("There was an error while processing the request!");
error_log("Error: " . $connection->error);
http_response_code(500);
echo json_encode([
"status" => 500,
"message" => "An unexpected error occurred.",
]);
$connection->close();
exit();
}
}
http_response_code(200);
echo json_encode([
"status" => 200,
"message" => "The statistics are added successfully.",
]);
$connection->close();
} else {
header("Allow: POST", true, 405);
echo json_encode([
"status" => 405,
"message" => "Invalid HTTP method.",
]);
}
?>

11
src/config.example.php Normal file
View file

@ -0,0 +1,11 @@
<?php
// MySQL/MariaDB configuration
define("MYSQL_HOST", "localhost");
define("MYSQL_USERNAME", "statistics");
define("MYSQL_PASSWORD", "statistics");
define("MYSQL_DATABASE", "statistics");
define("MYSQL_PORT", null);
// Domain name of the statistics server to ignore statistics request from
define("DOMAIN", "localhost");
?>