Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1367,3 +1367,27 @@ Example:
tcp://localhost:6379?db=3
```

## New Feature: `SqliteSessionHandler`

A new **`SqliteSessionHandler`** class has been introduced under `MagicObject\Session`.
This provides a **persistent session storage** mechanism using **SQLite** as the backend.

### Features

* Stores sessions in a **SQLite database file** instead of filesystem or memory.
* Automatically **creates the session table** if it does not exist.
* Implements the full session lifecycle:

* **open** — Initializes session.
* **read** — Reads serialized session data.
* **write** — Writes or updates session data.
* **destroy** — Removes a session by ID.
* **gc** — Garbage collects expired sessions.
* Ensures **safe storage** even when multiple PHP processes are running.

### Why It Matters?

* **Portability:** No dependency on Redis or Memcached — only requires SQLite.
* **Lightweight:** Suitable for shared hosting or small applications.
* **Reliability:** Prevents session loss when PHP restarts, unlike file-based sessions.

376 changes: 355 additions & 21 deletions docs/doc.html

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions src/Exceptions/InvalidFileAccessException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
namespace MagicObject\Exceptions;

use Exception;
use Throwable;

/**
* Class InvalidFileAccessException
*
* Custom exception class for handling errors related to invalid file access.
*
* @author Kamshory
* @package MagicObject\Exceptions
* @link https://github.com/Planetbiru/MagicObject
*/
class InvalidFileAccessException extends Exception
{
/**
* Previous exception
*
* @var Throwable|null The previous exception
*/
private $previous;

/**
* Constructor for InvalidFileAccessException.
*
* @param string $message Exception message
* @param int $code Exception code
* @param Throwable|null $previous Previous exception
*/
public function __construct($message, $code = 0, $previous = null)
{
parent::__construct($message, $code, $previous);
$this->previous = $previous;
}

/**
* Get the previous exception.
*
* @return Throwable|null The previous exception
*/
public function getPreviousException()
{
return $this->previous;
}
}
35 changes: 17 additions & 18 deletions src/Session/PicoSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* @package MagicObject\Session
* @link https://github.com/Planetbiru/MagicObject
*/
class PicoSession
class PicoSession // NOSONAR
{
const SESSION_STARTED = true;
const SESSION_NOT_STARTED = false;
Expand Down Expand Up @@ -55,6 +55,7 @@ class PicoSession
*/
public function __construct($sessConf = null)
{
error_log(json_encode($sessConf));
if ($sessConf && $sessConf->getName() != "") {
$this->setSessionName($sessConf->getName());
}
Expand All @@ -66,36 +67,34 @@ public function __construct($sessConf = null)
$this->saveToRedis($redisParams->host, $redisParams->port, $redisParams->auth, $redisParams->db);
} elseif ($sessConf && $sessConf->getSaveHandler() == "files" && $sessConf->getSavePath() != "") {
$this->saveToFiles($sessConf->getSavePath());
} elseif ($sessConf && $sessConf->getSaveHandler() == "sqlite" && $sessConf->getSavePath() != "") {
error_log($sessConf->getSavePath());
$handler = new SqliteSessionHandler($sessConf->getSavePath());
session_set_save_handler(
[$handler, 'open'],
[$handler, 'close'],
[$handler, 'read'],
[$handler, 'write'],
[$handler, 'destroy'],
[$handler, 'gc']
);
register_shutdown_function('session_write_close');
}
}

/**
* Extracts Redis connection parameters from a session configuration object.
*
* Parses the Redis `save_path` (in URL format) from the given SecretObject instance
* and returns a stdClass object containing the Redis connection details.
* and returns a stdClass object containing the Redis host, port, and optional authentication.
*
* Supported save path formats:
* Example save path formats:
* - tcp://127.0.0.1:6379
* - tcp://[::1]:6379
* - tcp://localhost:6379?auth=yourpassword
* - tcp://localhost:6379?password=yourpassword
* - tcp://localhost:6379?db=3
* - tcp://localhost:6379?dbindex=3
* - tcp://localhost:6379?database=3
*
* Recognized query parameters:
* - `auth` or `password` : Redis authentication password
* - `db`, `dbindex`, or `database` : Redis logical database index (default: 0)
* - Any other query parameters are preserved in `options`
*
* @param SecretObject $sessConf Session configuration object containing the Redis save path.
* @return stdClass An object with the properties:
* - `host` (string) : Redis hostname or IP address
* - `port` (int) : Redis port number
* - `auth` (string|null) : Authentication password (if any)
* - `db` (int) : Redis database index (default: 0)
* - `options` (array) : All parsed query parameters
* @return stdClass An object with the properties: `host` (string), `port` (int), and `auth` (string|null).
*/
private function getRedisParams($sessConf) // NOSONAR
{
Expand Down
167 changes: 167 additions & 0 deletions src/Session/SqliteSessionHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

namespace MagicObject\Session;

use MagicObject\Exceptions\InvalidFileAccessException;
use PDO;

/**
* Class SqliteSessionHandler
*
* A custom session handler implementation using SQLite as the storage backend.
* This class manages session lifecycle including create, read, update,
* destroy, and garbage collection.
*/
class SqliteSessionHandler
{
/**
* PDO instance for SQLite connection.
*
* @var PDO
*/
private $pdo;

/**
* Table name used for storing session data.
*
* @var string
*/
private $table = "sessions";

/**
* Constructor.
*
* Ensures the database file and its parent directory exist,
* then initializes the SQLite connection and creates the
* sessions table if it does not exist.
*
* @param string $path Absolute path to the SQLite database file.
*
* @throws \RuntimeException If the target directory is not writable.
*/
public function __construct($path)
{
// Ensure the parent directory exists
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}

// Validate that the directory is writable
if (!is_writable($dir)) {
throw new InvalidFileAccessException("Folder not writable: " . $dir);
}

// Resolve real path, if file does not exist then create an empty one
$real = realpath($path);
if ($real === false) {
file_put_contents($path, "");
$real = realpath($path);
}

// Build DSN for SQLite connection
$dsn = "sqlite:" . $real;
$this->pdo = new PDO($dsn);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// Create session table if it does not exist
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS {$this->table} (
id TEXT PRIMARY KEY,
data TEXT,
timestamp INTEGER
)
");
}

/**
* Open the session.
*
* @param string $savePath Session save path.
* @param string $sessionName Session name.
*
* @return bool Always true.
*/
public function open($savePath, $sessionName) // NOSONAR
{
return true;
}

/**
* Close the session.
*
* @return bool Always true.
*/
public function close()
{
return true;
}

/**
* Read session data by session ID.
*
* @param string $id Session ID.
*
* @return string Serialized session data, or empty string if not found.
*/
public function read($id)
{
$stmt = $this->pdo->prepare("SELECT data FROM {$this->table} WHERE id = :id");
$stmt->execute(array(':id' => $id));
$row = $stmt->fetch(PDO::FETCH_ASSOC);

return isset($row['data']) ? $row['data'] : '';
}

/**
* Write session data.
*
* @param string $id Session ID.
* @param string $data Serialized session data.
*
* @return bool True on success.
*/
public function write($id, $data)
{
$time = time();
$stmt = $this->pdo->prepare("
INSERT INTO {$this->table} (id, data, timestamp)
VALUES (:id, :data, :time)
ON CONFLICT(id) DO UPDATE SET data = :data, timestamp = :time
");

return $stmt->execute(array(
':id' => $id,
':data' => $data,
':time' => $time
));
}

/**
* Destroy a session by ID.
*
* @param string $id Session ID.
*
* @return bool True on success.
*/
public function destroy($id)
{
$stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE id = :id");
return $stmt->execute(array(':id' => $id));
}

/**
* Perform garbage collection.
*
* Removes expired sessions older than max lifetime.
*
* @param int $maxlifetime Maximum lifetime in seconds.
*
* @return bool True if query executed successfully.
*/
public function gc($maxlifetime)
{
$old = time() - $maxlifetime;
return $this->pdo->exec("DELETE FROM {$this->table} WHERE timestamp < $old") !== false;
}
}
2 changes: 1 addition & 1 deletion src/Util/Database/PicoDatabaseUtilBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ public function importDataTable(
* @return bool True if there may still be more data remaining; false if the import
* process has finished and post-import scripts have been executed.
*/
public function importData($config, $callbackFunction, $tableName = null, $limit = null, $offset = null)
public function importData($config, $callbackFunction, $tableName = null, $limit = null, $offset = null) // NOSONAR
{
$databaseConfigSource = $config->getDatabaseSource();
$databaseConfigTarget = $config->getDatabaseTarget();
Expand Down