feat: rewrite analytics system in Next.js and remove PHP backend
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 22s

This commit is contained in:
Chneemann 2026-07-31 09:03:19 +02:00
parent b221d2bcc1
commit d4f76bb1ff
No known key found for this signature in database
4 changed files with 189 additions and 135 deletions

3
.gitignore vendored
View file

@ -40,8 +40,5 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# Apache server configuration files
htaccess
# Ignore all files in downloads directory
public/assets/downloads/*

View file

@ -1,119 +0,0 @@
<?php
/**
* Privacy-friendly, self-hosted web analytics tracking endpoint.
* Provides basic view counts, anonymized daily unique visitors, device categories, and server execution metrics.
*/
$startTime = microtime(true);
// 1. CORS Headers & Preflight Handling
$allowedOrigins = ['https://andre-kempf.com', 'https://www.andre-kempf.com', 'http://localhost:3000'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: " . $origin);
} else {
header("Access-Control-Allow-Origin: https://andre-kempf.com");
}
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json; charset=UTF-8");
// Handle HTTP OPTIONS preflight request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// 2. Error Reporting & Session Initialization
ini_set('display_errors', 0);
error_reporting(E_ALL);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// 3. Storage & Default Data Structure
$dataFile = __DIR__ . '/analytics.json';
$data = [
'totalViews' => 0,
'todayViews' => 0,
'lastDate' => date('Y-m-d'),
'lastPing' => time(),
'visitors' => [],
'devices' => ['Desktop' => 0, 'Mobile' => 0]
];
// Load existing analytics JSON data
if (file_exists($dataFile)) {
$content = @file_get_contents($dataFile);
if ($content) {
$loadedData = @json_decode($content, true);
if (is_array($loadedData)) {
$data = array_merge($data, $loadedData);
}
}
}
// 4. Daily Reset Logic
$today = date('Y-m-d');
if ($data['lastDate'] !== $today) {
$data['todayViews'] = 0;
$data['lastDate'] = $today;
$data['visitors'] = [];
}
// 5. Environment & Rate-Limiting Checks
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$isLocalhost = (
strpos($origin, 'localhost') !== false ||
strpos($referer, 'localhost') !== false ||
$ip === '127.0.0.1' ||
$ip === '::1'
);
$lastTrackTime = $_SESSION['last_track_time'] ?? 0;
$currentTime = time();
$isSpam = ($currentTime - $lastTrackTime) < 30; // 30-second cooldown per session
// 6. Analytics Tracking Execution
if (isset($_GET['track']) && $_GET['track'] === 'true' && !$isLocalhost && !$isSpam) {
$_SESSION['last_track_time'] = $currentTime;
$data['totalViews']++;
$data['todayViews']++;
$data['lastPing'] = $currentTime;
// Detect coarse device category
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$isMobile = preg_match('/mobile|android|iphone|ipad|tablet/i', $ua);
$deviceType = $isMobile ? 'Mobile' : 'Desktop';
$data['devices'][$deviceType] = ($data['devices'][$deviceType] ?? 0) + 1;
// Hash IP address with daily salt for anonymized unique visitor tracking
$visitorHash = md5($ip . $today);
if (!in_array($visitorHash, $data['visitors'], true)) {
$data['visitors'][] = $visitorHash;
}
// Atomic file save to prevent race condition corruption
@file_put_contents($dataFile, json_encode($data), LOCK_EX);
}
// 7. Calculate Server Execution Latency
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
// 8. Output Metrics Payload
echo json_encode([
'totalViews' => $data['totalViews'],
'todayViews' => $data['todayViews'],
'uniqueVisitors' => count($data['visitors']),
'devices' => $data['devices'],
'lastPing' => $data['lastPing'] ?? time(),
'latencyMs' => $responseTime
]);

View file

@ -0,0 +1,163 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export const dynamic = "force-dynamic";
const dataFilePath = path.join(process.cwd(), "data", "analytics.json");
interface AnalyticsData {
totalViews: number;
todayViews: number;
lastDate: string;
lastPing: number;
visitors: string[];
devices: { Desktop: number; Mobile: number };
}
// In-memory cache to prevent redundant disk reads and race condition overrides
let cachedData: AnalyticsData | null = null;
// Mutex flag to serialize disk write operations during high concurrent traffic
let isWriting = false;
/**
* Loads analytics data from memory cache or reads from persistent disk storage.
*/
function loadAnalyticsData(): AnalyticsData {
const today = new Date().toISOString().split("T")[0];
const defaultData: AnalyticsData = {
totalViews: 0,
todayViews: 0,
lastDate: today,
lastPing: Math.floor(Date.now() / 1000),
visitors: [],
devices: { Desktop: 0, Mobile: 0 },
};
// Return from memory if available to ensure sub-millisecond read access
if (cachedData) {
return cachedData;
}
try {
if (fs.existsSync(dataFilePath)) {
const fileContent = fs.readFileSync(dataFilePath, "utf-8");
const parsed = JSON.parse(fileContent);
cachedData = { ...defaultData, ...parsed };
return cachedData;
}
} catch (err) {
console.error("Error reading analytics file:", err);
}
cachedData = defaultData;
return cachedData;
}
/**
* Persists analytics data to memory cache and asynchronously flushes to disk.
*/
function saveAnalyticsData(data: AnalyticsData) {
// Update memory state immediately so concurrent requests see the latest state
cachedData = data;
// Prevent overlapping disk writes if another write is currently in progress
if (isWriting) return;
isWriting = true;
try {
const dir = path.dirname(dataFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Asynchronous write keeps the event loop non-blocking
fs.writeFile(
dataFilePath,
JSON.stringify(data, null, 2),
"utf-8",
(err) => {
isWriting = false;
if (err) console.error("Error writing analytics file:", err);
},
);
} catch (err) {
isWriting = false;
console.error("Error initiating analytics file write:", err);
}
}
/**
* Simple string hashing function for IP anonymization (replaces md5).
*/
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return Math.abs(hash).toString(36);
}
export async function GET(request: Request) {
const startTime = performance.now();
const { searchParams } = new URL(request.url);
const track = searchParams.get("track") === "true";
// Load state from memory cache (or disk on cold start)
const data = loadAnalyticsData();
const today = new Date().toISOString().split("T")[0];
// Daily reset logic for views and visitor hashes
if (data.lastDate !== today) {
data.todayViews = 0;
data.lastDate = today;
data.visitors = [];
}
// Extract client metadata from headers forwarded by Caddy reverse proxy
const referer = request.headers.get("referer") || "";
const host = request.headers.get("host") || "";
const userAgent = request.headers.get("user-agent") || "";
const forwardedFor = request.headers.get("x-forwarded-for") || "0.0.0.0";
const ip = forwardedFor.split(",")[0].trim();
const isLocalhost =
host.includes("localhost") ||
referer.includes("localhost") ||
ip === "127.0.0.1" ||
ip === "::1";
// Process tracking request
if (track && !isLocalhost) {
data.totalViews++;
data.todayViews++;
data.lastPing = Math.floor(Date.now() / 1000);
// Categorize device type
const isMobile = /mobile|android|iphone|ipad|tablet/i.test(userAgent);
const deviceType = isMobile ? "Mobile" : "Desktop";
data.devices[deviceType] = (data.devices[deviceType] || 0) + 1;
// Anonymize visitor IP + date to track unique visits per day
const visitorHash = simpleHash(ip + today);
if (!data.visitors.includes(visitorHash)) {
data.visitors.push(visitorHash);
}
// Atomic update of cache and deferred disk write
saveAnalyticsData(data);
}
const latencyMs = parseFloat((performance.now() - startTime).toFixed(2));
return NextResponse.json({
totalViews: data.totalViews,
todayViews: data.todayViews,
uniqueVisitors: data.visitors.length,
devices: data.devices,
lastPing: data.lastPing,
latencyMs,
});
}

View file

@ -11,22 +11,35 @@ export default function AnalyticsWidget() {
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// Helper function to fetch stats (optionally tracking the view)
const fetchStats = (shouldTrack: boolean = false) => {
fetch(`/api/analytics?track=${shouldTrack}`, { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! Status: ${res.status}`);
return res.json();
})
.then((data) => {
if (data && typeof data.todayViews === "number") setStats(data);
})
.catch((err) => console.error("Analytics Widget Fetch Error:", err));
};
// Initial load on client hydration: track page view
useEffect(() => {
setMounted(true);
// Track current page view and fetch live analytics data
fetch("https://andre-kempf.com/backend/analytics.php?track=true")
.then((res) => res.json())
.then((data) => {
if (!data.error) setStats(data);
})
.catch(() => null);
fetchStats(true); // track=true on initial page visit
}, []);
// Skeleton badge while metrics are loading
if (!stats) {
// Open modal and silently refresh stats without incrementing view count
const handleOpenModal = () => {
fetchStats(false); // track=false when clicking badge
setIsOpen(true);
};
// Skeleton badge while client hydration occurs or metrics are loading
if (!mounted || !stats) {
return (
<div className="h-6.75 w-52.5 font-mono text-[11px] text-slate-500/80 bg-slate-900/40 rounded-md border border-slate-800/60 flex items-center px-2.5 gap-1.5 select-none">
<div className="h-7 w-52 font-mono text-[11px] text-slate-500/80 bg-slate-900/40 rounded-md border border-slate-800/60 flex items-center px-2.5 gap-1.5 select-none">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400/80 animate-pulse" />
<span className="text-blue-400 font-semibold">$</span>
<span className="text-slate-400 animate-pulse">loading_stats...</span>
@ -38,8 +51,8 @@ export default function AnalyticsWidget() {
<>
{/* Interactive terminal badge in footer */}
<button
onClick={() => setIsOpen(true)}
className="font-mono text-xs text-slate-500 hover:text-slate-300 transition-all flex items-center gap-1.5 cursor-pointer group bg-slate-900/40 hover:bg-slate-900 px-2.5 py-1 rounded-md border border-slate-800/60 hover:border-blue-500/40 shadow-xs"
onClick={handleOpenModal}
className="font-mono text-xs text-slate-500 hover:text-slate-300 transition-all flex items-center gap-1.5 cursor-pointer group bg-slate-900/40 hover:bg-slate-900 px-2.5 py-1 rounded-md border border-slate-800/60 hover:border-blue-500/40 shadow-sm"
title="Open analytics terminal"
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />