first commit
This commit is contained in:
Executable
+336
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
// Timezone: the eternal struggle between server time and human sanity
|
||||
date_default_timezone_set('UTC'); // Adjust to your server's actual timezone
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Paranoid? Maybe. Secure? Definitely.
|
||||
// Nginx filters IPs, but we're not taking chances with your data
|
||||
$allowedIPs = ['127.0.0.1', '::1'];
|
||||
$clientIP = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
|
||||
// Math! It's what separates us from the script kiddies
|
||||
function ipInRange($ip, $range) {
|
||||
list($subnet, $mask) = explode('/', $range);
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = -1 << (32 - (int)$mask);
|
||||
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
|
||||
}
|
||||
|
||||
$isLocal = in_array($clientIP, $allowedIPs) ||
|
||||
(filter_var($clientIP, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) ||
|
||||
ipInRange($clientIP, '10.0.0.0/8') ||
|
||||
ipInRange($clientIP, '172.16.0.0/12') ||
|
||||
ipInRange($clientIP, '192.168.0.0/16');
|
||||
|
||||
if (!$isLocal) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Access denied']); // No peeking, strangers
|
||||
exit;
|
||||
}
|
||||
|
||||
$date = isset($_GET['date']) ? $_GET['date'] : date('Y-m-d');
|
||||
$endDate = isset($_GET['endDate']) ? $_GET['endDate'] : null;
|
||||
$dataDir = __DIR__ . '/../data/analytics';
|
||||
$reactionsDir = __DIR__ . '/../data/reactions';
|
||||
|
||||
// Date range wizard - conjures dates out of thin air
|
||||
function getDatesInRange($startDate, $endDate) {
|
||||
$dates = [];
|
||||
$current = strtotime($startDate);
|
||||
$end = strtotime($endDate);
|
||||
while ($current <= $end) {
|
||||
$dates[] = date('Y-m-d', $current);
|
||||
$current = strtotime('+1 day', $current);
|
||||
}
|
||||
return $dates;
|
||||
}
|
||||
|
||||
// Load summary data - aggregate if date range is provided
|
||||
$data = [
|
||||
'total' => 0,
|
||||
'new' => 0,
|
||||
'returning' => 0,
|
||||
'byHour' => array_fill(0, 24, 0),
|
||||
'shares' => ['mastodon' => 0, 'bluesky' => 0, 'copy' => 0],
|
||||
'rss' => 0
|
||||
];
|
||||
|
||||
if ($endDate && $endDate >= $date) {
|
||||
// Date range mode - aggregate data from multiple days
|
||||
$dates = getDatesInRange($date, $endDate);
|
||||
$allVisits = [];
|
||||
|
||||
foreach ($dates as $dayDate) {
|
||||
$filename = $dataDir . '/summary_' . $dayDate . '.json';
|
||||
if (file_exists($filename)) {
|
||||
$dayData = json_decode(file_get_contents($filename), true);
|
||||
if ($dayData) {
|
||||
// Aggregate totals
|
||||
$data['total'] += $dayData['total'] ?? 0;
|
||||
$data['new'] += $dayData['new'] ?? 0;
|
||||
$data['returning'] += $dayData['returning'] ?? 0;
|
||||
|
||||
// Aggregate shares
|
||||
if (isset($dayData['shares'])) {
|
||||
$data['shares']['mastodon'] += $dayData['shares']['mastodon'] ?? 0;
|
||||
$data['shares']['bluesky'] += $dayData['shares']['bluesky'] ?? 0;
|
||||
$data['shares']['copy'] += $dayData['shares']['copy'] ?? 0;
|
||||
}
|
||||
|
||||
// Aggregate hourly data
|
||||
if (isset($dayData['byHour']) && is_array($dayData['byHour'])) {
|
||||
for ($i = 0; $i < 24; $i++) {
|
||||
$data['byHour'][$i] += $dayData['byHour'][$i] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect visits for recent visitors
|
||||
$visitsFile = $dataDir . '/visits_' . $dayDate . '.json';
|
||||
if (file_exists($visitsFile)) {
|
||||
$dayVisits = json_decode(file_get_contents($visitsFile), true) ?: [];
|
||||
$allVisits = array_merge($allVisits, $dayVisits);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Load visits even if summary doesn't exist
|
||||
$visitsFile = $dataDir . '/visits_' . $dayDate . '.json';
|
||||
if (file_exists($visitsFile)) {
|
||||
$dayVisits = json_decode(file_get_contents($visitsFile), true) ?: [];
|
||||
$allVisits = array_merge($allVisits, $dayVisits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate byHour from actual timestamps for the range
|
||||
$recalculatedByHour = array_fill(0, 24, 0);
|
||||
foreach ($allVisits as $visit) {
|
||||
if (isset($visit['type']) && $visit['type'] === 'pageview' && isset($visit['timestamp'])) {
|
||||
$hour = (int)date('H', $visit['timestamp']);
|
||||
if ($hour >= 0 && $hour < 24) {
|
||||
$recalculatedByHour[$hour]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['byHour'] = $recalculatedByHour;
|
||||
|
||||
// Use all visits for recent visitors
|
||||
$visits = $allVisits;
|
||||
} else {
|
||||
// Single date mode
|
||||
$filename = $dataDir . '/summary_' . $date . '.json';
|
||||
if (file_exists($filename)) {
|
||||
$data = json_decode(file_get_contents($filename), true);
|
||||
}
|
||||
|
||||
// Load visits file for this date
|
||||
$visitsFile = $dataDir . '/visits_' . $date . '.json';
|
||||
$visits = [];
|
||||
if (file_exists($visitsFile)) {
|
||||
$visits = json_decode(file_get_contents($visitsFile), true) ?: [];
|
||||
}
|
||||
|
||||
// Recalculate byHour from actual timestamps
|
||||
$recalculatedByHour = array_fill(0, 24, 0);
|
||||
foreach ($visits as $visit) {
|
||||
if (isset($visit['type']) && $visit['type'] === 'pageview' && isset($visit['timestamp'])) {
|
||||
$hour = (int)date('H', $visit['timestamp']);
|
||||
if ($hour >= 0 && $hour < 24) {
|
||||
$recalculatedByHour[$hour]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['byHour'] = $recalculatedByHour;
|
||||
}
|
||||
|
||||
// Ensure RSS tracking exists
|
||||
if (!isset($data['rss'])) {
|
||||
$data['rss'] = 0;
|
||||
}
|
||||
|
||||
// Calculate active RSS subscribers (readers who fetched in last 7 days)
|
||||
$readersFile = $dataDir . '/rss_readers.json';
|
||||
$activeRssSubscribers = 0;
|
||||
if (file_exists($readersFile)) {
|
||||
$readers = json_decode(file_get_contents($readersFile), true) ?: [];
|
||||
$sevenDaysAgo = time() - (7 * 24 * 60 * 60);
|
||||
foreach ($readers as $reader) {
|
||||
if (isset($reader['lastFetch']) && $reader['lastFetch'] >= $sevenDaysAgo) {
|
||||
$activeRssSubscribers++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['activeRssSubscribers'] = $activeRssSubscribers;
|
||||
|
||||
// Get 10 most recent unique IP addresses with geolocation
|
||||
function getGeolocation($ip) {
|
||||
// Skip private/local IPs
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
||||
return ['country' => 'Local', 'city' => 'Private Network', 'isp' => ''];
|
||||
}
|
||||
|
||||
// Use ip-api.com (free, no API key needed, 45 requests/minute limit)
|
||||
$url = "http://ip-api.com/json/{$ip}?fields=status,country,countryCode,city,region,isp,query";
|
||||
|
||||
// Use file_get_contents with stream context
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 2,
|
||||
'method' => 'GET',
|
||||
'header' => 'User-Agent: Analytics/1.0'
|
||||
]
|
||||
]);
|
||||
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($response) {
|
||||
$geo = json_decode($response, true);
|
||||
if ($geo && isset($geo['status']) && $geo['status'] === 'success') {
|
||||
return [
|
||||
'country' => $geo['country'] ?? 'Unknown',
|
||||
'countryCode' => $geo['countryCode'] ?? '',
|
||||
'city' => $geo['city'] ?? 'Unknown',
|
||||
'region' => $geo['region'] ?? '',
|
||||
'isp' => $geo['isp'] ?? 'Unknown',
|
||||
'ip' => $geo['query'] ?? $ip
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['country' => 'Unknown', 'city' => 'Unknown', 'isp' => 'Unknown', 'ip' => $ip];
|
||||
}
|
||||
|
||||
// Get unique IPs with their most recent visit timestamp
|
||||
$ipVisits = [];
|
||||
foreach ($visits as $visit) {
|
||||
$ip = $visit['ip'] ?? '';
|
||||
if ($ip && !isset($ipVisits[$ip])) {
|
||||
$ipVisits[$ip] = [
|
||||
'ip' => $ip,
|
||||
'timestamp' => $visit['timestamp'] ?? 0,
|
||||
'page' => $visit['page'] ?? '',
|
||||
'userAgent' => $visit['userAgent'] ?? ''
|
||||
];
|
||||
} elseif ($ip && isset($ipVisits[$ip])) {
|
||||
// Keep the most recent timestamp
|
||||
if (($visit['timestamp'] ?? 0) > $ipVisits[$ip]['timestamp']) {
|
||||
$ipVisits[$ip]['timestamp'] = $visit['timestamp'] ?? 0;
|
||||
$ipVisits[$ip]['page'] = $visit['page'] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (most recent first) and get top 10
|
||||
usort($ipVisits, function($a, $b) {
|
||||
return $b['timestamp'] - $a['timestamp'];
|
||||
});
|
||||
$recentIPs = array_slice($ipVisits, 0, 10);
|
||||
|
||||
// Get geolocation for each IP (with caching to avoid rate limits)
|
||||
$geoCacheFile = $dataDir . '/geo_cache.json';
|
||||
$geoCache = [];
|
||||
if (file_exists($geoCacheFile)) {
|
||||
$geoCache = json_decode(file_get_contents($geoCacheFile), true) ?: [];
|
||||
}
|
||||
|
||||
$recentVisitors = [];
|
||||
foreach ($recentIPs as $ipVisit) {
|
||||
$ip = $ipVisit['ip'];
|
||||
|
||||
// Check cache first (cache expires after 24 hours)
|
||||
$geo = null;
|
||||
if (isset($geoCache[$ip]) && (time() - $geoCache[$ip]['cached_at']) < 86400) {
|
||||
$geo = $geoCache[$ip]['data'];
|
||||
} else {
|
||||
$geo = getGeolocation($ip);
|
||||
$geoCache[$ip] = ['data' => $geo, 'cached_at' => time()];
|
||||
// Small delay to respect API rate limits
|
||||
usleep(200000); // 0.2 second delay
|
||||
}
|
||||
|
||||
$recentVisitors[] = [
|
||||
'ip' => $ip,
|
||||
'timestamp' => $ipVisit['timestamp'],
|
||||
'time' => gmdate('Y-m-d\TH:i:s\Z', $ipVisit['timestamp']), // ISO 8601 UTC format for frontend conversion
|
||||
'page' => $ipVisit['page'],
|
||||
'country' => $geo['country'] ?? 'Unknown',
|
||||
'countryCode' => $geo['countryCode'] ?? '',
|
||||
'city' => $geo['city'] ?? 'Unknown',
|
||||
'region' => $geo['region'] ?? '',
|
||||
'isp' => $geo['isp'] ?? 'Unknown'
|
||||
];
|
||||
}
|
||||
|
||||
// Save updated cache
|
||||
if (count($geoCache) > 0) {
|
||||
file_put_contents($geoCacheFile, json_encode($geoCache, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
$data['recentVisitors'] = $recentVisitors;
|
||||
|
||||
// Ensure shares structure exists and normalize old platform names
|
||||
if (!isset($data['shares'])) {
|
||||
$data['shares'] = ['mastodon' => 0, 'bluesky' => 0, 'copy' => 0];
|
||||
} else {
|
||||
// Normalize old platform names to new ones
|
||||
$normalizedShares = ['mastodon' => 0, 'bluesky' => 0, 'copy' => 0];
|
||||
foreach ($data['shares'] as $platform => $count) {
|
||||
if ($platform === 'twitter' || $platform === 'mastodon') {
|
||||
$normalizedShares['mastodon'] += $count;
|
||||
} else if ($platform === 'facebook' || $platform === 'bluesky') {
|
||||
$normalizedShares['bluesky'] += $count;
|
||||
} else if ($platform === 'copy') {
|
||||
$normalizedShares['copy'] += $count;
|
||||
}
|
||||
}
|
||||
$data['shares'] = $normalizedShares;
|
||||
}
|
||||
|
||||
// Load blog posts and their stats
|
||||
$postsFile = __DIR__ . '/../blog/data/posts.json';
|
||||
$posts = [];
|
||||
if (file_exists($postsFile)) {
|
||||
$posts = json_decode(file_get_contents($postsFile), true) ?: [];
|
||||
}
|
||||
|
||||
// Get reactions and shares for each post
|
||||
$blogStats = [];
|
||||
foreach ($posts as $post) {
|
||||
$postId = $post['id'];
|
||||
$reactionFile = $reactionsDir . '/post_' . $postId . '.json';
|
||||
|
||||
$reactions = ['like' => 0, 'love' => 0, 'helpful' => 0];
|
||||
if (file_exists($reactionFile)) {
|
||||
$reactionData = json_decode(file_get_contents($reactionFile), true);
|
||||
if (isset($reactionData['counts'])) {
|
||||
$reactions = $reactionData['counts'];
|
||||
}
|
||||
}
|
||||
|
||||
// Get shares for this post from visits data (already loaded, includes date range)
|
||||
$postShares = ['mastodon' => 0, 'bluesky' => 0, 'copy' => 0];
|
||||
foreach ($visits as $visit) {
|
||||
if (isset($visit['type']) && $visit['type'] === 'share' &&
|
||||
isset($visit['page']) && strpos($visit['page'], '#post-' . $postId) !== false) {
|
||||
$platform = $visit['platform'] ?? 'copy';
|
||||
if (isset($postShares[$platform])) {
|
||||
$postShares[$platform]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$blogStats[] = [
|
||||
'id' => $postId,
|
||||
'title' => $post['title'],
|
||||
'reactions' => $reactions,
|
||||
'shares' => $postShares,
|
||||
'totalReactions' => array_sum($reactions),
|
||||
'totalShares' => array_sum($postShares)
|
||||
];
|
||||
}
|
||||
|
||||
$data['blogPosts'] = $blogStats;
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $data]);
|
||||
?>
|
||||
Reference in New Issue
Block a user