feat: rewrite analytics system in Next.js and remove PHP backend
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 22s
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 22s
This commit is contained in:
parent
b221d2bcc1
commit
d4f76bb1ff
4 changed files with 189 additions and 135 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/*
|
||||
|
|
@ -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
|
||||
]);
|
||||
163
src/app/api/analytics/route.ts
Normal file
163
src/app/api/analytics/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue