Files

548 lines
15 KiB
PHP
Raw Permalink Normal View History

2025-12-02 10:32:59 -05:00
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Helper;
use Carbon\Carbon;
use Xibo\Service\LogServiceInterface;
use Xibo\Storage\PdoStorageService;
/**
* Class Session
* @package Xibo\Helper
*/
class Session implements \SessionHandlerInterface
{
private $maxLifetime;
private $key;
/**
* Refresh expiry
* @var bool
*/
public $refreshExpiry = true;
/**
* Expiry time
* @var int
*/
private $sessionExpiry = 0;
/**
* Is the session expired?
* @var bool
*/
private $expired = true;
/**
* The UserId whom owns this session
* @var int
*/
private $userId = 0;
/**
* @var bool Whether gc() has been called
*/
private $gcCalled = false;
/**
* Prune this key?
* @var bool
*/
private $pruneKey = false;
/**
* The database connection
* @var PdoStorageService
*/
private $pdo = null;
/**
* Log
* @var LogServiceInterface
*/
private LogServiceInterface $log;
/**
* Session constructor.
* @param LogServiceInterface $log
*/
public function __construct(LogServiceInterface $log)
{
$this->log = $log;
}
/**
* {@inheritdoc}
*/
public function open($savePath, $sessionName): bool
{
//$this->log->debug('Session open');
$this->maxLifetime = ini_get('session.gc_maxlifetime');
return true;
}
/**
* {@inheritdoc}
*/
public function close(): bool
{
//$this->log->debug('Session close');
try {
// Commit
$this->commit();
} catch (\PDOException $e) {
$this->log->error('Error closing session: %s', $e->getMessage());
}
try {
// Prune this session if necessary
if ($this->pruneKey || $this->gcCalled) {
$db = new PdoStorageService($this->log);
$db->setConnection();
if ($this->pruneKey) {
$db->update('DELETE FROM `session` WHERE session_id = :session_id', [
'session_id' => $this->key,
]);
}
if ($this->gcCalled) {
// Delete sessions older than 10 times the max lifetime
$db->update('DELETE FROM `session` WHERE IsExpired = 1 AND session_expiration < :expiration', [
'expiration' => Carbon::now()->subSeconds($this->maxLifetime * 10)->format('U'),
]);
// Update expired sessions as expired
$db->update('UPDATE `session` SET IsExpired = 1 WHERE session_expiration < :expiration', [
'expiration' => Carbon::now()->format('U'),
]);
}
$db->commitIfNecessary();
$db->close();
}
} catch (\PDOException $e) {
$this->log->error('Error closing session: %s', $e->getMessage());
}
// Close
$this->getDb()->close();
return true;
}
/**
* {@inheritdoc}
*/
public function read($key): false|string
{
//$this->log->debug('Session read');
$data = '';
$this->key = $key;
$userAgent = substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253);
try {
$dbh = $this->getDb();
// Start a transaction
$this->beginTransaction();
// Get this session
$sth = $dbh->getConnection()->prepare('
SELECT `session_data`, `isexpired`, `useragent`, `session_expiration`, `userId`
FROM `session`
WHERE `session_id` = :session_id
');
$sth->execute(['session_id' => $key]);
$row = $sth->fetch();
if (!$row) {
// New session.
$this->insertSession(
$key,
'',
Carbon::now()->format('U'),
Carbon::now()->addSeconds($this->maxLifetime)->format('U'),
);
$this->expired = false;
} else {
// Existing session
// Check the session hasn't expired
if ($row['session_expiration'] < Carbon::now()->format('U')) {
$this->expired = true;
} else {
$this->expired = $row['isexpired'];
}
// What happens if the UserAgent has changed?
if ($row['useragent'] != $userAgent) {
// Force delete this session
$this->expired = 1;
$this->pruneKey = true;
}
$this->userId = $row['userId'];
$this->sessionExpiry = $row['session_expiration'];
// Set the session data (expired or not)
$data = $row['session_data'];
}
return (string)$data;
} catch (\Exception $e) {
$this->log->error('Error reading session: %s', $e->getMessage());
return $data;
}
}
/**
* {@inheritdoc}
*/
public function write($id, $data): bool
{
//$this->log->debug('Session write');
// What should we do with expiry?
$expiry = ($this->refreshExpiry)
? Carbon::now()->addSeconds($this->maxLifetime)->format('U')
: $this->sessionExpiry;
try {
$this->updateSession($id, $data, Carbon::now()->format('U'), $expiry);
} catch (\PDOException $e) {
$this->log->error('Error writing session data: %s', $e->getMessage());
return false;
}
return true;
}
/**
* {@inheritdoc}
*/
public function destroy($id): bool
{
//$this->log->debug('Session destroy');
try {
$this->getDb()->update('DELETE FROM `session` WHERE session_id = :session_id', ['session_id' => $id]);
} catch (\PDOException $e) {
$this->log->error('Error destroying session: %s', $e->getMessage());
}
return true;
}
/**
* {@inheritdoc}
*/
public function gc($max_lifetime): false|int
{
//$this->log->debug('Session gc');
$this->gcCalled = true;
return true;
}
/**
* Sets the User Id
* @param $userId
*/
public function setUser($userId): void
{
//$this->log->debug('Setting user Id to %d', $userId);
$_SESSION['userid'] = $userId;
$this->userId = $userId;
}
/**
* Updates the session ID with a new one
*/
public function regenerateSessionId(): void
{
//$this->log->debug('Session regenerate');
session_regenerate_id(true);
$this->key = session_id();
}
/**
* Set this session to expired
* @param $isExpired
*/
public function setIsExpired($isExpired): void
{
$this->expired = $isExpired;
}
/**
* Store a variable in the session
* @param string $key
* @param mixed $secondKey
* @param mixed|null $value
* @return mixed
*/
public static function set(string $key, mixed $secondKey, mixed $value = null): mixed
{
if (func_num_args() == 2) {
$_SESSION[$key] = $secondKey;
return $secondKey;
} else {
if (!isset($_SESSION[$key]) || !is_array($_SESSION[$key])) {
$_SESSION[$key] = [];
}
$_SESSION[$key][(string) $secondKey] = $value;
return $value;
}
}
/**
* Get the Value from the position denoted by the 2 keys provided
* @param string $key
* @param string $secondKey
* @return bool
*/
public static function get(string $key, ?string $secondKey = null): mixed
{
if ($secondKey != null) {
if (isset($_SESSION[$key][$secondKey])) {
return $_SESSION[$key][$secondKey];
}
} else {
if (isset($_SESSION[$key])) {
return $_SESSION[$key];
}
}
return false;
}
/**
* Is the session expired?
* @return bool
*/
public function isExpired(): bool
{
return $this->expired;
}
/**
* Get a Database
* @return PdoStorageService
*/
private function getDb(): PdoStorageService
{
if ($this->pdo == null) {
$this->pdo = (new PdoStorageService($this->log))->setConnection();
}
return $this->pdo;
}
/**
* Helper method to begin a transaction.
*
* MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
* due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
* So we change it to READ COMMITTED.
*/
private function beginTransaction(): void
{
if (!$this->getDb()->getConnection()->inTransaction()) {
try {
$this->getDb()->getConnection()->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
} catch (\PDOException $e) {
// https://github.com/xibosignage/xibo/issues/787
// this only works if BINLOG format is set to MIXED or ROW
$this->log->error('Unable to set session transaction isolation level, message = ' . $e->getMessage());
}
$this->getDb()->getConnection()->beginTransaction();
}
}
/**
* Commit
*/
private function commit(): void
{
if ($this->getDb()->getConnection()->inTransaction()) {
$this->getDb()->getConnection()->commit();
}
}
/**
* Insert session
* @param $key
* @param $data
* @param $lastAccessed
* @param $expiry
*/
private function insertSession($key, $data, $lastAccessed, $expiry): void
{
//$this->log->debug('Session insert');
$this->insertSessionHistory();
$sql = '
INSERT INTO `session` (
`session_id`,
`session_data`,
`session_expiration`,
`lastaccessed`,
`userid`,
`isexpired`,
`useragent`,
`remoteaddr`
)
VALUES (
:session_id,
:session_data,
:session_expiration,
:lastAccessed,
:userId,
:expired,
:useragent,
:remoteaddr
)
';
$params = [
'session_id' => $key,
'session_data' => $data,
'session_expiration' => $expiry,
'lastAccessed' => Carbon::createFromTimestamp($lastAccessed)->format(DateFormatHelper::getSystemFormat()),
'userId' => $this->userId,
'expired' => ($this->expired) ? 1 : 0,
'useragent' => substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253),
'remoteaddr' => $this->getIp()
];
$this->getDb()->update($sql, $params);
}
private function insertSessionHistory(): void
{
$sql = '
INSERT INTO `session_history` (`ipAddress`, `userAgent`, `startTime`, `userId`, `lastUsedTime`)
VALUES (:ipAddress, :userAgent, :startTime, :userId, :lastUsedTime)
';
$params = [
'ipAddress' => $this->getIp(),
'userAgent' => substr(htmlspecialchars($_SERVER['HTTP_USER_AGENT']), 0, 253),
'startTime' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
'userId' => $this->userId,
'lastUsedTime' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
];
$id = $this->getDb()->insert($sql, $params);
$this->set('sessionHistoryId', $id);
}
/**
* Update Session
* @param $key
* @param $data
* @param $lastAccessed
* @param $expiry
*/
private function updateSession($key, $data, $lastAccessed, $expiry): void
{
//$this->log->debug('Session update');
$this->updateSessionHistory();
$sql = '
UPDATE `session` SET
session_data = :session_data,
session_expiration = :session_expiration,
LastAccessed = :lastAccessed,
userID = :userId,
IsExpired = :expired
WHERE session_id = :session_id
';
$params = [
'session_data' => $data,
'session_expiration' => $expiry,
'lastAccessed' => Carbon::createFromTimestamp($lastAccessed)->format(DateFormatHelper::getSystemFormat()),
'userId' => $this->userId,
'expired' => ($this->expired) ? 1 : 0,
'session_id' => $key
];
$this->getDb()->update($sql, $params);
}
/**
* Updates the session history
*/
private function updateSessionHistory(): void
{
$sql = '
UPDATE `session_history` SET
lastUsedTime = :lastUsedTime, userID = :userId
WHERE sessionId = :sessionId
';
$params = [
'lastUsedTime' => Carbon::now()->format(DateFormatHelper::getSystemFormat()),
'userId' => $this->userId,
'sessionId' => $_SESSION['sessionHistoryId'],
];
$this->getDb()->update($sql, $params);
}
/**
* Get the Client IP Address
* @return string
*/
private function getIp(): string
{
$clientIp = '';
$keys = array('X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR', 'CLIENT_IP', 'REMOTE_ADDR');
foreach ($keys as $key) {
if (isset($_SERVER[$key]) && filter_var($_SERVER[$key], FILTER_VALIDATE_IP) !== false) {
$clientIp = $_SERVER[$key];
break;
}
}
return $clientIp;
}
/**
* @param $userId
*/
public function expireAllSessionsForUser($userId): void
{
$this->getDb()->update('UPDATE `session` SET IsExpired = 1 WHERE userID = :userId', [
'userId' => $userId
]);
}
}