Files
api/services/nginx/app/classes/system_session_activity_tracker.php
T

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());
}
}