diff --git a/.gitignore b/.gitignore index 572dd60..dfcd2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* \ No newline at end of file diff --git a/backend/analytics.php b/backend/analytics.php deleted file mode 100644 index 1a19e31..0000000 --- a/backend/analytics.php +++ /dev/null @@ -1,119 +0,0 @@ - 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 -]); \ No newline at end of file diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts new file mode 100644 index 0000000..a568aa9 --- /dev/null +++ b/src/app/api/analytics/route.ts @@ -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, + }); +} diff --git a/src/components/AnalyticsWidget.tsx b/src/components/AnalyticsWidget.tsx index 5f14f41..01670db 100644 --- a/src/components/AnalyticsWidget.tsx +++ b/src/components/AnalyticsWidget.tsx @@ -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 ( -
+
$ loading_stats... @@ -38,8 +51,8 @@ export default function AnalyticsWidget() { <> {/* Interactive terminal badge in footer */}