<?php
/*********************************************************************
    class.ostsession.php

    osTicket Session Management Backend

    Peter Rotich <peter@osticket.com>
    Copyright (c)  2022 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
include_once INCLUDE_DIR.'class.session.php';

class osTicketSession {
    // Session SSID
    private $name = 'OSTSESSID';
    // Session Backend instance
    private $backend;
    // Session default TTL
    private $ttl;

    function __construct($name, $ttl = SESSION_TTL, $checkdbversion = false) {
        // session name/ssid
        if ($name && strcmp($this->name, $name))
            $this->name = $name;
        // Session ttl cannot exceed php.ini maxlifetime setting
        $maxlife =  ini_get('session.gc_maxlifetime');
        $this->ttl = min($ttl ?: ($maxlife ?: SESSION_TTL), $maxlife);
        // Set osTicket specific session name/sessid
        session_name($this->name);
        // Set Default cookie Params before we start the session
        session_set_cookie_params($this->ttl, ROOT_PATH, Http::domain(),
            osTicket::is_https(), true);

        /** Determine Session Backend to use **/
        if ((!defined('MAJOR_VERSION') || $checkdbversion)
                && OsticketConfig::getDBVersion())
            // Default PHP SessionHandler
            $bk = 'system';
        else  // default is database
            $bk = self::session_backend() ?: 'database';

        // Tag the backend with storage backend class
        switch ($bk) {
            case 'database':
                $bk = "$bk:DatabaseSessionStorageBackend";
                break;
            case 'memcache':
                $bk = "$bk:MemcacheSessionStorageBackend";
                break;
            case 'memcache.database':
                // memcache is primary while database is secondary
                // Database doesn't store data when set as secondary, but
                // very useful when session data is offloaded to memcache with
                // database tracking sessions ids and expire time for the
                // purpose of knowing who is online.
                $bk = sprintf('%s:%s:%s',
                        'memcache',
                        'MemcacheSessionStorageBackend',
                        'DatabaseSessionStorageBackend');
                break;
            case 'database.memcache':
                // database is primary while memcache is secondary
                // This setup only makes sense if memcache is being used as
                // backup backend
                $bk = sprintf('%s:%s:%s',
                        'database',
                        'DatabaseSessionStorageBackend',
                        'MemcacheSessionStorageBackend');
                break;
            case 'system':
            case 'noop':
                // system & noop don't require storage backends
                break;
            default:
                // Assume invalid entry - default to system
                $bk = 'system';
        }

        /** Session Backend options **/
        $options = [
            // Default TTL (maxlife)
            'session_ttl' => $this->ttl,
            // It indicates that the session is API session - which session
            // handler should handle as stateless for new sessions.
            'api_session' => defined('API_SESSION'),
            'callbacks' => $this->getCallbacks($bk),
        ];

        // Set MaxLifeTime if defined. This is defined per user so it's
        // preferred over ttl.
        if (defined('SESSION_MAXLIFE') && is_numeric(SESSION_MAXLIFE))
            $options['session_maxlife'] = SESSION_MAXLIFE;

        // If $bk doesn't exit then an Exception will be thrown
        // Backend is object is turned on success
        try {
            // If $bk doesn't exit then an Exception will be thrown
            // backend object is turned on success
            $this->backend = osTicket\Session\SessionBackend::register($bk,
                $options, true);
        } catch (Throwable $t) {
            die($t->getMessage());
            // We're just gonna default to php session handler and hope for
            // the best.
            // TODO: Log the error and perhaps rethrow the exception so it
            // can be fatal?
        }

        // Finally start the damn session.
        session_start();
    }

    // returns session callbacks we might be interested in monitoring
    private function getCallbacks($bk) {
        return [
            // see onClose routine for details
            'close' => [$this, 'onClose']
        ];
    }

    // onClose - is used to signal those interested on changing session
    // data, to do so,  before data is commited.
    public function onClose($handler) {
        $i = new ArrayObject(['touched' => false]);
        Signal::send('session.close', null, $i);
        return (bool) $i['touched'];
    }

    /*
     * session_backend
     *
     * Get configured session backend if any.
     */
    static function session_backend($default = false) {
        // Session Disabled?
        // This is useful when fetching logos or on CLI - we don't want to update the
        // session in such cases.
        if (defined('NOOP_SESSION') || defined('DISABLE_SESSION'))
            return 'noop'; // Ignore session data

         // No session backend set - return default
        if (!defined('SESSION_BACKEND'))
            return $default;

        // Explode backend incase it's chained
        list($bk, $secondary) = explode('.', SESSION_BACKEND);
        // Only recongnize supported primary backends
        switch (strtolower($bk)) {
            case 'memcache':
                // Make sure we have memcache servers defined  - if not
                // then break so we can return default.
                // TODO: Log an error or throw an exception
                if (!defined('MEMCACHE_SERVERS'))
                    break;
                // No break on purpose
            case 'database':
            case 'system':
                return strtolower(SESSION_BACKEND);
                break;
            default:
                return $default;
        }
    }

    /*
     * registered_backend
     *
     * get registered backend if any
     */
    static function registered_backend() {
        global $ost;
        if ($ost && isset($ost->session))
            return $ost->session->backend;
    }

    /*
     * regenerate($ttl)
     *
     * Regenerate current session_id and expire the old one in $ttl seconds
     * This is preferred over destroying the session immediately just incase
     * we have pending ajax requests for example
     *
     */
    static function regenerate(int $ttl=60) {
        // Make sure session is active and headers are not already sent
        if (session_status() !== PHP_SESSION_ACTIVE
                || headers_sent())
            return false;

        // Save current ($old) session id
        $old = session_id();
        // Expire current session cookie now + ttl
        // We have to do it here before re regenerate becase renewCookie has
        // hardcoded session calls to get name & id
        // FIXME: Maybe bae...
        self::renewCookie(time(), $ttl);
        // Regenerate the session
        // TODO: use session_create_id() instead of session_regenerate_id -
        // but PHP (even 8) is still bugy and inconsistent when it comes to
        // using SessionIdInterface::create_sid depening on session settings.
        session_regenerate_id(false);
        // get a new session id and force commit
        // Expire old session now + ttl
        session_write_close();
        // Expire old session now + ttl
        // ::expire() is not a standard session routine so we have to commit
        // the current "new" session first
        self::expire($old, $ttl);
        // Restart the new session
        session_start();
        // Return the new session id
        return session_id();
    }

    /*
     * expire session
     *
     * Expire session in $ttl from now  if the storage backend supports
     * it otherwise it should destroy it by calling this->destroy($id)
     */
    // Expire session - end is near mb!
    static function expire($id, int $ttl) {
        // See if we have a backend to ask to expire the session - otherwise
        // we destroy session now!
        if (!($backend=self::registered_backend()))
            return false;

        // Expire session soonish (now() + $ttl) - end is near mb!
        return (bool) $backend->expire($id, $ttl);
    }

    // Aks the backend to destroy a session now
    static function destroy($id) {
        // Expire with ttl of 0 destroys the session
        return (bool) self::expire($id, 0);
    }

    // Cleanup Expired Sessions
    static function cleanup() {
        // get active backend
        if (!($backend=self::registered_backend()))
            return false;

        return (bool) $backend->cleanup();
    }

    static function renewCookie($baseTime=false, $window=false) {
        global $ost;

        $ttl = $window ?: SESSION_TTL;
        $expire = ($baseTime ?: time()) + $ttl;
        $opts = [
            'expires' => $expire,
            'path' => ini_get('session.cookie_path'),
            'domain' => ini_get('session.cookie_domain'),
            'secure' => ini_get('session.cookie_secure'),
            'httponly' => ini_get('session.cookie_httponly'),
            'samesite' => !empty($ost->getConfig()->getAllowIframes()) ? 'None' : 'Strict'
        ];
        setcookie(session_name(), session_id(), $opts);
        // Trigger expire update - neeed for secondary handlers that only
        // log new sessions
         self::expire(session_id(), $ttl);
    }

    static function destroyCookie() {
        setcookie(session_name(), 'deleted', 1,
            ini_get('session.cookie_path'),
            ini_get('session.cookie_domain'),
            ini_get('session.cookie_secure'),
            ini_get('session.cookie_httponly'));
    }

    static function get_online_users(int $seconds = 0) {
        // Authoretative lookup is DatabaseSessionRecords assuming
        // database is the primary backend or secondary logger
        $records = DatabaseSessionRecord::active_sessions([
                'lastseen' => $seconds,
                'authenticated' => true,
         ]);
        $users = [];
        foreach ($records as $record)
             $users[] = $record->getUserId();
        return $users;
    }

    static function start($name, $ttl=0, $checkdbversion=false) {
        return new static($name, $ttl, $checkdbversion);
    }
}

/*
 * DatabaseSessionRecord
 *
 * ORM hook to session table with SessionRecordInterface implementation
 */
class DatabaseSessionRecord extends VerySimpleModel
    implements osTicket\Session\SessionRecordInterface {
    static $meta = array(
        'table' => SESSION_TABLE,
        'pk' => array('session_id'),
    );

    public function isNew() {
        return $this->__new__;
    }

    public function isValid() {
        // NOTE: We're not enforcing IP match here because it's a user space
        // setting and checked elsewhere

        // User Agent must remain same for the lifecycle of the session
        if (isset($this->user_agent)
                && strcmp($_SERVER['HTTP_USER_AGENT'], $this->user_agent) !== 0)
            return false;

        // Make sure id len is 32
        return (strlen($this->getId()) == 32);
    }

    public function setId(string $id) {
        $this->session_id = $id;
        return $this;
    }

    public function getId() {
        return $this->session_id;
    }

    public function setData(?string $data = null) {
        $this->session_data = $data;
        return $this;
    }

    public function getData() {
        return $this->session_data;
    }

    public function getExpireTime() {
        return $this->session_expire;
    }

    private function setExpire(int $maxlife) {
        $this->session_expire = SqlFunction::NOW()
            ->plus(SqlInterval::SECOND($maxlife));
    }

    public function setTTL($ttl) {
        $this->setExpire($ttl);
        return $this;
    }

    public function expire(int $ttl) {
        $this->setTTL($ttl);
        // Assume it will expire shortly - clear user_id
        $this->user_id = 0;
        return $this->commit();
    }

    public function getUpdateTime() {
        return $this->session_updated;
    }

    public function commit() {
        return ($this->save());
    }

    public function save($refresh=false) {
        global $thisstaff;
        // See if we need to set the user id - should only be set once onlogin
        if (!$this->user_id && $thisstaff)
            $this->user_id = $thisstaff->getId();
        if (count($this->dirty))
            $this->session_updated = SqlFunction::NOW();
        return parent::save($refresh);
    }

    public function delete() {
        return (parent::delete());
    }

    public function toArray() {
        return [
            'id' => $this->getId(),
            'data' => $this->getData(),
            'updated' => $this->getUpdateTime(),
            'expires' => $this->getExpireTime(),
        ];
    }

    /*
     *  lookupRecord
     *
     *  Given session id - lookup the record
     *
     *  Possible returns;
     *      false: on lookup failure
     *      null: doesn't exists and autocreate is false
     *      record: fetched or newly created session record
     *
     */
    public static function lookupRecord($id, $autocreate = false,
            $backend=null) {
        // We're doing lookup locally so we can auto-create one if the
        // session doesn't exist.
        $record = false;
        try {
            $record = self::objects()
              ->filter(['session_id' => $id])
              ->annotate(array('is_expired' =>
                new SqlExpr(new Q(array('session_expire__lt' => SqlFunction::NOW())))))
              ->one();
            if ($record->is_expired > 0) {
                // session_expire is in the past. Pretend it is expired and
                // reset the data. This will assist with CSRF issues
                $record->session_data = '';
            }
        }
        catch (DoesNotExist $e) {
            // We're auto-creating model (unsaved) when one doesn't exist?
            $record = ($autocreate && ctype_alnum($id)) ? self::create($id) : null;
        }
        catch (OrmException | Exception $ex) {
            // This could happen if more than one record exits in the
            // database for example.
        }
        return $record;
    }

    static function create($vars) {
        // We expect ::init($id) or ::init(array $vars)
        if ($vars && !is_array($vars))
            $vars = ['session_id' => $vars];
        elseif (!isset($vars['session_id']))
            // Session Id is required
            throw Exception(sprintf('session_id: %s', __('Required')));

        // Set User Session Atrributes
        if (!isset($vars['user_ip']))
            $vars['user_ip'] = $_SERVER['REMOTE_ADDR'];
        if (!isset($vars['user_agent']))
            $vars['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
        // Default to system SESSION_TTL if session_maxlife is not set
        $maxlife = $vars['session_maxlife'] ?? SESSION_TTL;
        // filter out the $vars to just the fields we support - backend can
        // send way more data than we want.
        $vars = array_intersect_key($vars,
                array_flip([
                    'session_id',
                    'session_data',
                    'session_expire',
                    'session_updated',
                    'user_id',
                    'user_ip',
                    'user_agent',
        ]));

        // Create an instance
        $record = new self($vars);
        // set session_expire timestamp based on $maxlife
        $record->setExpire($maxlife);
        // Set updated to now()
        $record->session_updated = SqlFunction::NOW();
        return $record;
    }

    static function destroy($id) {
        return ( (bool) self::objects()
                ->filter(['session_id' => $id])
                ->delete());
    }

    static function cleanupExpired() {
        return self::objects()->filter([
                'session_expire__lte' => SqlFunction::NOW()
        ])->delete();
    }

    static function user_sessions(int $id)  {
        $criteria = ['user_id' => $id];
        return self::active_sessions($criteria);
    }

    static function active_sessions(array $criteria = []) {
        $criteria['active'] = true;
        return self::sessions($criteria);
    }

    static function expired_sessions(array $criteria = []) {
        $criteria['active'] = false;
        return self::sessions($criteria);
    }

    static function sessions(array $criteria = []) {
        // now
        $now = SqlFunction::NOW();
        // empty filters
        $filters = [];
        // Active or Expired
        if (isset($criteria['active']) && $criteria['active']) {
            // Active must not be expired
            $filters = ['session_expire__gt' => $now];
        } elseif (isset($criteria['active'])) {
            // expired session if active is set to false
            $filters = ['session_expire__lt' => $now];
        }

        // Authenticated users have user_id set (only Agents at the moment)
        if (isset($criteria['user_id']) && $criteria['user_id'])
            $filters['user_id'] = $criteria['user_id'];
        elseif (isset($criteria['authenticated']) && $criteria['authenticated'])
            $filters['user_id__gt'] = 0;
        elseif (isset($criteria['authenticated']))
            $filters['user_id'] = 0; // Guests only

        // last seen since X seconds
        if (isset($criteria['lastseen']) && $criteria['lastseen']) {
            $interval = new SqlInterval('SECOND', $criteria['lastseen']);
            $filters['session_updated__gte'] = $now->minus($interval);
        }

        return self::objects()->filter($filters);
    }
}

/*
 * Database Session Storage Backend
 *
 */
class DatabaseSessionStorageBackend
    extends osTicket\Session\AbstractSessionStorageBackend {

    public function expireRecord($id, $ttl) {
        if (!($record=$this->getRecord($id)))
            return false;

        return (bool) $record->expire($ttl);
    }

    public function saveRecord($record, $secondary=false) {
        if ($secondary || !is_a($record, 'DatabaseSessionRecord'))  {
            // We're only recording new sessions without data if we're
            // secondary backend
            if ($record->isNew())
                $this->writeRecord($record->getId(), null);
            return true;
        }
        return (bool) $record->commit();
    }

    public function writeRecord($id, $data=null) {
        $record = self:: lookupRecord($id, true);
        $record->setData($data);
        return (bool) $record->commit();
    }

    public function lookupRecord($id, $autocreate) {
        return DatabaseSessionRecord::lookupRecord($id, $autocreate, $this);
    }

    public function destroyRecord($id) {
        return (bool) (DatabaseSessionRecord::destroy($id));
    }

    public function cleanupExpiredRecords() {
        return DatabaseSessionRecord::cleanupExpired();
    }
}
/*
 * Memchache Session Storage Backend
 *
 */
class MemcacheSessionStorageBackend
    extends osTicket\Session\AbstractMemcacheSessionStorageBackend {

    public function __construct($options) {
        // Make sure we have memcache servers
        $servers = [];
        if (isset($options['servers']) && is_array($options['servers']))
            $servers = $options['servers'];
        elseif (defined('MEMCACHE_SERVERS'))
            $servers = explode(',', MEMCACHE_SERVERS);
        // Bro Got Servers or Nah?!
        if (!count($servers))
            throw new Exception('MEMCACHE_SERVERS must be defined');

        $options['servers'] = $servers;
        parent::__construct($options);
    }

    public function saveRecord($record, $secondary = false) {
        if ($secondary || !is_a($record, 'MemcacheSessionRecord'))  {
            if (!$record->isNew())
                return true;
        }
        return $this->writeRecord($record->getId(), $record->getData());
    }

    public function writeRecord($id, $data) {
        return $this->set($id, $data);
    }

    public function lookupRecord($id, $autocreate = false) {
        if (!($data = $this->get($id)) && !$autocreate)
            return false;
        return new  MemcacheSessionRecord([
                'id' => $id,
                'new' => $data === false,
                'data' => $data,
        ], $this);
    }
}

class MemcacheSessionRecord
implements osTicket\Session\SessionRecordInterface {
    private $backend;
    private $data;

    public function __construct(array $ht, $backend) {
        // Make sure we have an id
        if (!isset($ht['id']) || !$ht['id'])
            throw new Exception('Session id required');
        // Set backend
        $this->backend = $backend;
        // Set the data as array object
        $this->data = new ArrayObject($ht, ArrayObject::ARRAY_AS_PROPS);
    }

    public function isNew() {
        return ($this->data->new);
    }

    public function isValid() {
        return (strlen($this->getId()) == 32);
    }

    public function getId() {
        return $this->data->id;
    }

    public function setId(string $id) {
        $this->data->id = $id;
    }

    public function getData() {
        return $this->data->data;
    }

    public function setData(?string $data = null) {
        $this->data->data = $data;
    }

    public function setTTl(int $ttl) {
        $this->data->ttl = $ttl;
    }

    // Unsupported call but required
    public function expire(int $ttl) {
        return true;
    }

    public function commit() {
        return $this->backend
            ? $this->backend->saveRecord($this)
            : false;
    }

    public function toArray() {
        return [
            'id' => $this->getId(),
            'data' => $this->getData(),
        ];
    }

    public static function create($vars) {
        return new self($vars);
    }
}
?>
