<?php
namespace SilverStripe\Control\SessionHandler;
use Exception;
use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use RuntimeException;
use SensitiveParameter;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Path;
use SilverStripe\ORM\FieldType\DBDatetime;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
* Session save handler that stores session data in the filesystem as defined by `session.save_path` ini configuration.
*
* Similar to PHP's default filesystem session handler, except it doesn't lock the session file meaning
* sessions are non-blocking.
*/
class FileSessionHandler extends AbstractSessionHandler
{
public const string SESSION_FILE_PREFIX = 'sess_';
* The base directory that houses session files.
*/
private string $baseDir;
* Number of sub-directories to use when storing session files.
*/
private int $numSubDirs = 0;
* The filesystem mode to use when creating session files.
*/
private int $mode = 0600;
public function close(): bool
{
return true;
}
* @inheritDoc
* Deletes the file that represents this session ID.
*/
public function destroy(#[SensitiveParameter] string $id): bool
{
$path = $this->getFilePath($id);
$filesystem = new Filesystem();
if (!$filesystem->exists($path)) {
return true;
}
try {
$filesystem->remove($path);
return true;
} catch (IOException $e) {
$this->logError('Could not remove session file: ' . $this->getSafeExceptionMessage($e, $id));
return false;
}
}
* @inheritDoc
* Deletes all session files which have a last modified datetime older than the session max lifetime.
* Note that we use our own calculated session lifetime rather than the passed in lifetime which doesn't
* take Silverstripe CMS configuration values into account.
*/
public function gc(int $max_lifetime): int|false
{
$now = DBDatetime::now()->Rfc2822();
$maxLifetime = $this->getLifetime();
$files = Finder::create()
->in($this->baseDir)
->files()
->name(FileSessionHandler::SESSION_FILE_PREFIX . '*')
->ignoreDotFiles(true)
->date('<= ' . $now . ' - ' . $maxLifetime . ' seconds');
$filesystem = new Filesystem();
$numRemoved = 0;
$success = true;
foreach ($files as $file) {
try {
$filesystem->remove($file->getRealPath());
$numRemoved++;
} catch (IOException $e) {
$this->logError('Could not collect session file: ' . $this->getSafeExceptionMessage($e));
$success = false;
}
}
return $success ? $numRemoved : false;
}
* @inheritDoc
* Defines where and how to save session files.
*/
public function open(string $path, string $name): bool
{
try {
$this->setSavePath($path);
} catch (InvalidArgumentException) {
$this->logError('Could not open session due to invalid save path');
return false;
}
return true;
}
* @inheritDoc
* Returns data of a pre-existing session, or an empty string for a new session.
*/
public function read(#[SensitiveParameter] string $id): string|false
{
$this->checkSessionID($id);
$path = $this->getFilePath($id);
$filesystem = new Filesystem();
if (!$filesystem->exists($path)) {
return '';
}
try {
if ($this->isSessionExpired($path)) {
return '';
}
} catch (RuntimeException $e) {
$this->logError($this->getSafeExceptionMessage($e, $id));
return false;
}
try {
return $filesystem->readFile($path);
} catch (IOException $e) {
$this->logError('Could not read session file: ' . $this->getSafeExceptionMessage($e, $id));
return false;
}
}
* @inheritDoc
* Writes session data to a file and sets file permissions if necessary.
*/
public function write(#[SensitiveParameter] string $id, string $data): bool
{
$path = $this->getFilePath($id);
$filesystem = new Filesystem();
if (!$filesystem->exists($path)) {
try {
$filesystem->dumpFile($path, '');
} catch (IOException $e) {
$this->logError('Could not create session file: ' . $this->getSafeExceptionMessage($e, $id));
return false;
}
}
if ($this->needsPermissionUpdate($path)) {
try {
$filesystem->chmod($path, (int) $this->mode);
} catch (IOException $e) {
$this->logError('Could not set permissions for session file: ' . $this->getSafeExceptionMessage($e, $id));
return false;
}
}
try {
$filesystem->dumpFile($path, $data);
} catch (IOException $e) {
$this->logError('Could not write to session file: ' . $this->getSafeExceptionMessage($e, $id));
return false;
}
return true;
}
* @inheritDoc
* A session ID is valid if a file for that session ID already exists and has not expired.
*/
public function validateId(#[SensitiveParameter] string $id): bool
{
$path = $this->getFilePath($id);
$filesystem = new Filesystem();
$fileExists = $filesystem->exists($path);
try {
return $fileExists && !$this->isSessionExpired($path);
} catch (RuntimeException $e) {
$this->logError($this->getSafeExceptionMessage($e, $id));
return $fileExists;
}
}
* @inheritDoc
* Called instead of write if session.lazy_write is enabled and no data has changed for this session.
*/
public function updateTimestamp(#[SensitiveParameter] string $id, string $data): bool
{
$path = $this->getFilePath($id);
$filesystem = new Filesystem();
try {
$time = DBDatetime::now()->getTimestamp();
$filesystem->touch($path, $time, $time);
return true;
} catch (IOException $e) {
$this->logError('Could not update timestamp for session file: ' . $this->getSafeExceptionMessage($e, $id));
return false;
}
}
* Set the path sessions will be saved in, along with optional additional configuration.
* @throws InvalidArgumentException if $path is invalid (e.g. has too many semicolons)
*/
private function setSavePath(string $path): void
{
if (!$path) {
$path = sys_get_temp_dir();
}
if (str_contains($path, ';')) {
$parts = explode(';', $path);
$numParts = count($parts);
if ($numParts > 3) {
throw new InvalidArgumentException('$path was invalid');
}
$this->numSubDirs = (int) array_shift($parts);
if ($numParts === 3) {
$this->mode = $this->ensureValueIsOctal(array_shift($parts));
}
$path = array_shift($parts);
}
$this->baseDir = $path;
}
* Get the absolute path to the file which this session's data will be stored in.
*/
private function getFilePath(#[SensitiveParameter] string $id): string
{
$subParts = str_split(substr($id, 0, $this->numSubDirs));
return Path::join(...[$this->baseDir, ...$subParts, FileSessionHandler::SESSION_FILE_PREFIX . $id]);
}
* Take a numeric string from save_path configuration and make sure it's a PHP octal integer.
*/
private function ensureValueIsOctal(string $value): int
{
if (strlen($value) < 4) {
$value = '0' . $value;
}
return octdec($value);
}
* Check if a session file needs its permissions to be updated to match configured mode.
*/
private function needsPermissionUpdate(#[SensitiveParameter] string $path): bool
{
$rawPerms = @fileperms($path);
if ($rawPerms === false) {
return true;
}
$permsInOctal = octdec(substr(sprintf('%o', $rawPerms), -4));
return $permsInOctal !== $this->mode;
}
* Get the exception message without leaking session IDs
*/
private function getSafeExceptionMessage(Exception $exception, #[SensitiveParameter] ?string $id = null): string
{
if ($id) {
return str_replace($id, '<session_id>', $exception->getMessage());
}
$prefix = FileSessionHandler::SESSION_FILE_PREFIX;
return preg_replace('/(' . $prefix . ')[,\w\-]+/', '$1<session_id>', $exception->getMessage());
}
* Check if a session file is expired.
*
* This method is necessary because garbage collection may not have run yet.
*
* @throws RuntimeException if the modified time of the file can't be read
*/
private function isSessionExpired(#[SensitiveParameter] string $path): bool
{
$mTime = @filemtime($path);
$maxLifeInSeconds = $this->getLifetime();
if ($mTime === false) {
throw new RuntimeException('Could not read modified time of session file');
}
return $mTime < (DBDatetime::now()->getTimestamp() - $maxLifeInSeconds);
}
private function logError(string $message): void
{
Injector::inst()->get(LoggerInterface::class)->error($message);
}
}