307 lines
12 KiB
PHP
307 lines
12 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use DateTimeImmutable;
|
|
use DateTimeZone;
|
|
use Exception;
|
|
use objects\subusers_o;
|
|
use objects\users_o;
|
|
|
|
class system_session_activity_tracker
|
|
{
|
|
public const ACTIVE_WINDOW_MINUTES = 15;
|
|
public const PRUNE_AFTER_DAYS = 30;
|
|
private const DATABASE_TIMEZONE = 'UTC';
|
|
|
|
/**
|
|
* Deduplicate touches within a single request lifecycle.
|
|
*
|
|
* @var array<string, bool>
|
|
*/
|
|
private static array $touchedSessions = [];
|
|
|
|
public function __construct()
|
|
{
|
|
system_session_activity_schema_bootstrap::ensureTables();
|
|
}
|
|
|
|
public function touchUser(users_o $user, string $token): void
|
|
{
|
|
$customerNumber = null;
|
|
if (isset($user->customer_number)) {
|
|
try {
|
|
$customerNumber = (int)$user->customer_number->value();
|
|
} catch (Exception) {
|
|
$customerNumber = null;
|
|
}
|
|
}
|
|
|
|
$this->touch('user', (int)$user->id, $token, $customerNumber);
|
|
}
|
|
|
|
public function touchSubuser(subusers_o $subuser, string $token, ?int $customerNumberContext = null): void
|
|
{
|
|
$this->touch('subuser', (int)$subuser->id, $token, $customerNumberContext);
|
|
}
|
|
|
|
public function touch(string $sessionKind, int $principalId, string $token, ?int $customerNumberContext = null): void
|
|
{
|
|
global $db;
|
|
|
|
$token = trim($token);
|
|
if ($token === '' || $principalId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$sessionHash = hash('sha256', $token);
|
|
if (isset(self::$touchedSessions[$sessionHash])) {
|
|
return;
|
|
}
|
|
self::$touchedSessions[$sessionHash] = true;
|
|
|
|
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
|
|
$userAgent = trim((string)($headers['User-Agent'] ?? $headers['user-agent'] ?? ''));
|
|
$lastRoute = trim((string)($_SERVER['REQUEST_URI'] ?? ''));
|
|
if ($lastRoute !== '') {
|
|
$lastRoute = explode('?', $lastRoute)[0] ?? $lastRoute;
|
|
}
|
|
|
|
$sessionHashEscaped = $db->escape_string($sessionHash);
|
|
$sessionKindEscaped = $db->escape_string($sessionKind);
|
|
$deviceTypeEscaped = $db->escape_string(self::detectDeviceType($userAgent));
|
|
$userAgentEscaped = $db->escape_string(substr($userAgent, 0, 1024));
|
|
$lastRouteEscaped = $db->escape_string(substr($lastRoute, 0, 255));
|
|
$customerNumberSql = $customerNumberContext === null ? 'NULL' : (string)(int)$customerNumberContext;
|
|
$currentUtcDateTime = self::utcSqlDateTime();
|
|
|
|
$sql = "INSERT INTO system_session_activity (
|
|
session_hash,
|
|
session_kind,
|
|
principal_id,
|
|
customer_number_context,
|
|
device_type,
|
|
user_agent,
|
|
last_route,
|
|
first_seen_at,
|
|
last_seen_at
|
|
) VALUES (
|
|
'$sessionHashEscaped',
|
|
'$sessionKindEscaped',
|
|
" . (int)$principalId . ",
|
|
$customerNumberSql,
|
|
'$deviceTypeEscaped',
|
|
'$userAgentEscaped',
|
|
'$lastRouteEscaped',
|
|
'$currentUtcDateTime',
|
|
'$currentUtcDateTime'
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
customer_number_context = VALUES(customer_number_context),
|
|
device_type = VALUES(device_type),
|
|
user_agent = VALUES(user_agent),
|
|
last_route = VALUES(last_route),
|
|
last_seen_at = '$currentUtcDateTime'";
|
|
|
|
$db->query($sql);
|
|
}
|
|
|
|
public function getSnapshot(int $limit = 50): array
|
|
{
|
|
global $db;
|
|
|
|
system_session_activity_schema_bootstrap::ensureTables();
|
|
|
|
$limit = max(1, min(200, $limit));
|
|
$cutoff = self::utcSqlDateTime(time() - (self::ACTIVE_WINDOW_MINUTES * 60));
|
|
$cutoffEscaped = $db->escape_string($cutoff);
|
|
|
|
$activeUsersResult = $db->query(
|
|
"SELECT COUNT(DISTINCT CONCAT(session_kind, ':', principal_id)) AS c
|
|
FROM system_session_activity
|
|
WHERE last_seen_at >= '$cutoffEscaped'"
|
|
);
|
|
$activeSessionsResult = $db->query(
|
|
"SELECT COUNT(*) AS c
|
|
FROM system_session_activity
|
|
WHERE last_seen_at >= '$cutoffEscaped'"
|
|
);
|
|
|
|
$recentRows = $db->query(
|
|
"SELECT
|
|
s.session_kind,
|
|
s.principal_id,
|
|
s.customer_number_context,
|
|
s.device_type,
|
|
s.user_agent,
|
|
s.last_route,
|
|
s.first_seen_at,
|
|
s.last_seen_at,
|
|
u.display_name AS user_display_name,
|
|
u.customer_number AS user_customer_number,
|
|
su.name AS subuser_name,
|
|
su.username AS subuser_username
|
|
FROM system_session_activity s
|
|
LEFT JOIN users u
|
|
ON s.session_kind = 'user'
|
|
AND u.id = s.principal_id
|
|
LEFT JOIN subusers su
|
|
ON s.session_kind = 'subuser'
|
|
AND su.id = s.principal_id
|
|
ORDER BY s.last_seen_at DESC
|
|
LIMIT $limit"
|
|
);
|
|
|
|
$recentSessions = [];
|
|
while ($row = $recentRows->fetch_assoc()) {
|
|
$isUser = ($row['session_kind'] ?? '') === 'user';
|
|
$displayName = $isUser
|
|
? trim((string)($row['user_display_name'] ?? ''))
|
|
: trim((string)($row['subuser_name'] ?? ''));
|
|
$displayNameKey = null;
|
|
$displayNameParams = [];
|
|
|
|
if ($displayName === '') {
|
|
if ($isUser && !empty($row['user_customer_number'])) {
|
|
$displayName = 'Customer ' . $row['user_customer_number'];
|
|
$displayNameKey = 'customer_number';
|
|
$displayNameParams = ['number' => (int)$row['user_customer_number']];
|
|
} elseif (!$isUser && !empty($row['subuser_username'])) {
|
|
$displayName = $row['subuser_username'];
|
|
} elseif ($isUser) {
|
|
$displayName = 'User #' . (int)($row['principal_id'] ?? 0);
|
|
$displayNameKey = 'user_with_id';
|
|
$displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)];
|
|
} elseif (($row['session_kind'] ?? '') === 'subuser') {
|
|
$displayName = 'Subuser #' . (int)($row['principal_id'] ?? 0);
|
|
$displayNameKey = 'subuser_with_id';
|
|
$displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)];
|
|
} else {
|
|
$displayName = 'Session #' . (int)($row['principal_id'] ?? 0);
|
|
$displayNameKey = 'session_with_id';
|
|
$displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)];
|
|
}
|
|
}
|
|
|
|
$contextLabel = null;
|
|
$contextLabelKey = null;
|
|
$contextLabelParams = [];
|
|
if ($isUser && !empty($row['user_customer_number'])) {
|
|
$contextLabel = 'Customer ' . $row['user_customer_number'];
|
|
$contextLabelKey = 'customer_number';
|
|
$contextLabelParams = ['number' => (int)$row['user_customer_number']];
|
|
} elseif (!empty($row['customer_number_context'])) {
|
|
$contextLabel = 'Customer ' . $row['customer_number_context'];
|
|
$contextLabelKey = 'customer_number';
|
|
$contextLabelParams = ['number' => (int)$row['customer_number_context']];
|
|
}
|
|
|
|
$firstSeenAt = self::databaseDateTimeToIso8601($row['first_seen_at'] ?? null);
|
|
$lastSeenAt = self::databaseDateTimeToIso8601($row['last_seen_at'] ?? null);
|
|
|
|
$recentSessions[] = [
|
|
'session_kind' => (string)($row['session_kind'] ?? 'unknown'),
|
|
'principal_id' => (int)($row['principal_id'] ?? 0),
|
|
'display_name' => $displayName,
|
|
'display_name_key' => $displayNameKey,
|
|
'display_name_params' => $displayNameParams,
|
|
'context_label' => $contextLabel,
|
|
'context_label_key' => $contextLabelKey,
|
|
'context_label_params' => $contextLabelParams,
|
|
'customer_number_context' => isset($row['customer_number_context']) ? (int)$row['customer_number_context'] : null,
|
|
'device_type' => (string)($row['device_type'] ?? 'unknown'),
|
|
'user_agent' => (string)($row['user_agent'] ?? ''),
|
|
'last_route' => (string)($row['last_route'] ?? ''),
|
|
'first_seen_at' => $firstSeenAt,
|
|
'last_seen_at' => $lastSeenAt,
|
|
'active' => self::isActive($lastSeenAt),
|
|
];
|
|
}
|
|
|
|
$activeUsers = (int)(($activeUsersResult?->fetch_assoc()['c']) ?? 0);
|
|
$activeSessions = (int)(($activeSessionsResult?->fetch_assoc()['c']) ?? 0);
|
|
|
|
return [
|
|
'active_window_minutes' => self::ACTIVE_WINDOW_MINUTES,
|
|
'active_users' => $activeUsers,
|
|
'active_sessions' => $activeSessions,
|
|
'recent_sessions' => $recentSessions,
|
|
];
|
|
}
|
|
|
|
public function pruneOlderThanDays(int $days = self::PRUNE_AFTER_DAYS): int
|
|
{
|
|
global $db;
|
|
|
|
system_session_activity_schema_bootstrap::ensureTables();
|
|
|
|
$days = max(1, $days);
|
|
$cutoff = self::utcSqlDateTime(time() - ($days * 86400));
|
|
$cutoffEscaped = $db->escape_string($cutoff);
|
|
|
|
$db->query("DELETE FROM system_session_activity WHERE last_seen_at < '$cutoffEscaped'");
|
|
return (int)$db->conn()->affected_rows;
|
|
}
|
|
|
|
public static function detectDeviceType(string $userAgent): string
|
|
{
|
|
$userAgent = strtolower(trim($userAgent));
|
|
if ($userAgent === '') {
|
|
return 'unknown';
|
|
}
|
|
|
|
if (preg_match('/bot|crawler|spider|slurp|curl|wget|postman|insomnia/', $userAgent) === 1) {
|
|
return 'bot';
|
|
}
|
|
if (preg_match('/ipad|tablet|kindle|playbook|silk/', $userAgent) === 1) {
|
|
return 'tablet';
|
|
}
|
|
if (preg_match('/iphone|ipod|android.+mobile|windows phone|mobile/', $userAgent) === 1) {
|
|
return 'mobile';
|
|
}
|
|
if (preg_match('/macintosh|windows nt|linux|x11|cros/', $userAgent) === 1) {
|
|
return 'desktop';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
public static function isActive(?string $lastSeenAt, int $windowMinutes = self::ACTIVE_WINDOW_MINUTES, ?int $referenceTimestamp = null): bool
|
|
{
|
|
if ($lastSeenAt === null || trim($lastSeenAt) === '') {
|
|
return false;
|
|
}
|
|
|
|
$lastSeenTimestamp = strtotime($lastSeenAt);
|
|
if ($lastSeenTimestamp === false) {
|
|
return false;
|
|
}
|
|
|
|
$referenceTimestamp = $referenceTimestamp ?? time();
|
|
return $lastSeenTimestamp >= ($referenceTimestamp - (max(1, $windowMinutes) * 60));
|
|
}
|
|
|
|
public static function databaseDateTimeToIso8601(?string $value): ?string
|
|
{
|
|
if ($value === null || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
$dateTime = DateTimeImmutable::createFromFormat(
|
|
'Y-m-d H:i:s',
|
|
trim($value),
|
|
new DateTimeZone(self::DATABASE_TIMEZONE)
|
|
);
|
|
if (!$dateTime instanceof DateTimeImmutable) {
|
|
return null;
|
|
}
|
|
|
|
return $dateTime->format(DATE_ATOM);
|
|
}
|
|
|
|
private static function utcSqlDateTime(?int $timestamp = null): string
|
|
{
|
|
return gmdate('Y-m-d H:i:s', $timestamp ?? time());
|
|
}
|
|
}
|