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
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# Apache server configuration files
|
|
||||||
htaccess
|
|
||||||
|
|
||||||
# Ignore all files in downloads directory
|
# Ignore all files in downloads directory
|
||||||
public/assets/downloads/*
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [mounted, setMounted] = 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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
fetchStats(true); // track=true on initial page visit
|
||||||
// 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);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Skeleton badge while metrics are loading
|
// Open modal and silently refresh stats without incrementing view count
|
||||||
if (!stats) {
|
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 (
|
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="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-blue-400 font-semibold">$</span>
|
||||||
<span className="text-slate-400 animate-pulse">loading_stats...</span>
|
<span className="text-slate-400 animate-pulse">loading_stats...</span>
|
||||||
|
|
@ -38,8 +51,8 @@ export default function AnalyticsWidget() {
|
||||||
<>
|
<>
|
||||||
{/* Interactive terminal badge in footer */}
|
{/* Interactive terminal badge in footer */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
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-xs"
|
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"
|
title="Open analytics terminal"
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue