<?php

/**
 * ---------------------------------------------------------------------
 *
 * GLPI - Gestionnaire Libre de Parc Informatique
 *
 * http://glpi-project.org
 *
 * @copyright 2015-2025 Teclib' and contributors.
 * @copyright 2003-2014 by the INDEPNET Development Team.
 * @licence   https://www.gnu.org/licenses/gpl-3.0.html
 *
 * ---------------------------------------------------------------------
 *
 * LICENSE
 *
 * This file is part of GLPI.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * ---------------------------------------------------------------------
 */

/**
 * @since 9.1
 */


/**
 * Itemlock is dedicated to manage real-time lock of items in GLPI.
 *
 * Item locks are used to lock items like Ticket, Computer, Reminder, ..., see list in $CFG_GLPI['lock_lockable_objects']
 *
 * @author Olivier Moron
 * @since 9.1
 *
 **/
class ObjectLock extends CommonDBTM
{
    private $itemtype     = "";
    private $itemtypename = "";
    private $itemid       = 0;

    private static $shutdownregistered = false;


    /**
     * @see CommonGLPI::getTypeName()
     */
    public static function getTypeName($nb = 0)
    {
        return _n('Object Lock', 'Object Locks', $nb);
    }


    /**
     * Summary of __construct
     *
     * @param $locitemtype       (default ObjectLoc
     * @param $locitemid         (default 0)
     **/
    public function __construct($locitemtype = 'ObjectLock', $locitemid = 0)
    {

        $this->itemtype     = $locitemtype;
        $this->itemid       = $locitemid;
        $this->itemtypename = $locitemtype::getTypeName(1);
    }


    /**
     * Summary of getEntityID
     * @return 0
     **/
    public function getEntityID()
    {
        return 0;
    }


    /**
     * Summary of getLockableObjects
     *
     * @return array of lockable objects 'itemtype' => 'plural itemtype'
     **/
    public static function getLockableObjects()
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        $ret = [];
        foreach ($CFG_GLPI['lock_lockable_objects'] as $lo) {
            $ret[$lo] = $lo::getTypeName(Session::getPluralNumber());
        }
        asort($ret, SORT_STRING);
        return $ret;
    }


    /**
     * Summary of autoLockMode
     * Manages autolock mode
     *
     * @return bool: true if read-only profile lock has been set
     **/
    private function autoLockMode()
    {
        // if !autolock mode then we are going to view the item with read-only profile
        // if isset($_POST['lockwrite']) then will behave like if automode was true but for this object only and for the lifetime of the session
        // look for lockwrite request
        if (isset($_POST['lockwrite'])) {
            $_SESSION['glpilock_autolock_items'][ $this->itemtype ][$this->itemid] = 1;
        }

        $ret = isset($_SESSION['glpilock_autolock_items'][ $this->itemtype ][ $this->itemid ])
             || $_SESSION['glpilock_autolock_mode'] == 1;
        $locked = $this->getLockedObjectInfo();
        if (!$ret && !$locked) {
            // open the object using read-only profile
            self::setReadonlyProfile();
            $this->setReadOnlyMessage();
        }
        return $ret || $locked;
    }


    /**
     * Summary of getScriptToUnlock
     */
    private function getScriptToUnlock()
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        $ret = Html::scriptBlock("
         function unlockIt(obj) {
            function callUnlock( ) {
               $.post({
                  url: '" . $CFG_GLPI['root_doc'] . "/ajax/unlockobject.php',
                  cache: false,
                  data: {
                     unlock: 1,
                     force: 1,
                     id: {$this->fields['id']}
                  },
                  dataType: 'json',
                  success: function( data, textStatus, jqXHR ) { " .
                        Html::jsConfirmCallback(__('Reload page?'), __('Item unlocked!'), "function() {
                              window.location.reload(true);
                           }") . "
                     },
                  error: function() { " .
                        Html::jsAlertCallback(__('Contact your GLPI admin!'), __('Item NOT unlocked!')) . "
                     }
               });
            }" .
            Html::jsConfirmCallback(__('Force unlock this item?'), $this->itemtypename . " #" . $this->itemid, "callUnlock") . "
         }");

        return $ret;
    }

    /**
     * Summary of getForceUnlockMessage
     * @return string '' if no rights to unlock type,
     *                else html @see getForceUnlockButton
     */
    private function getForceUnlockMessage()
    {

        if (isset($_SESSION['glpilocksavedprofile']) && ($_SESSION['glpilocksavedprofile'][strtolower($this->itemtype)] & UNLOCK)) {
            echo $this->getScriptToUnlock();
            return $this->getForceUnlockButton();
        }

        return '';
    }


    private function getForceUnlockButton()
    {
        $msg = "<a class='btn btn-sm btn-primary ms-2' onclick='javascript:unlockIt(this);'>
               <i class='fas fa-unlock'></i>
               <span>" . sprintf(__('Force unlock %1s #%2s'), $this->itemtypename, $this->itemid) . "</span>
            </a>";
        return $msg;
    }


    /**
     * Summary of setLockedByYouMessage
     * Shows 'Locked by You!' message and proposes to unlock it
     **/
    private function setLockedByYouMessage()
    {

        echo $this->getScriptToUnlock();

        $msg  = "<strong class='nowrap'>";
        $msg .= __("Locked by you!");
        $msg .= $this->getForceUnlockButton();
        $msg .= "</strong>";

        $this->displayLockMessage($msg);
    }


    /**
     * Summary of setLockedByMessage
     * Shows 'Locked by ' message and proposes to request unlock from locker
     **/
    private function setLockedByMessage()
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        // should get locking user info
        $user = new User();
        $user->getFromDB($this->fields['users_id']);

        $useremail     = new UserEmail();
        $showAskUnlock = $useremail->getFromDBByCrit([
            'users_id'     => $this->fields['users_id'],
            'is_default'   => 1,
        ]) && ($CFG_GLPI['notifications_mailing'] == 1);

        $userdata = getUserName($this->fields['users_id'], 2);

        if ($showAskUnlock) {
            $ret = Html::scriptBlock("
         function askUnlock() {
            " . Html::jsConfirmCallback(__('Ask for unlock this item?'), $this->itemtypename . " #" . $this->itemid, "function() {
                  $.post({
                     url: '" . $CFG_GLPI['root_doc'] . "/ajax/unlockobject.php',
                     cache: false,
                     data: {
                        requestunlock: 1,
                        id: {$this->fields['id']}
                     },
                     dataType: 'json',
                     success: function( data, textStatus, jqXHR ) {
                           " . Html::jsAlertCallback($userdata['name'], __('Request sent to')) . "
                        }
                     });
               }") . "
         }");
            echo $ret;
        }

        $ret = Html::scriptBlock("
         $(function(){
            var lockStatusTimer;
            $('#alertMe').on('change',function( eventObject ){
               if( this.checked ) {
                  lockStatusTimer = setInterval( function() {
                     $.get({
                           url: '" . $CFG_GLPI['root_doc'] . "/ajax/unlockobject.php',
                           cache: false,
                           data: 'lockstatus=1&id=" . $this->fields['id'] . "',
                           success: function( data, textStatus, jqXHR ) {
                                 if( data == 0 ) {
                                    clearInterval(lockStatusTimer);" .
                                    Html::jsConfirmCallback(__('Reload page?'), __('Item unlocked!'), "function() {
                                       window.location.reload(true);
                                    }") . "
                                 }
                              }
                           });
                  },15000)
               } else {
                  clearInterval(lockStatusTimer);
               }
            });
         });
      ");

        $msg = "<strong class='nowrap'>";
        $msg .= sprintf(__('Locked by %s'), "<a href='" . $user->getLinkURL() . "'>" . $userdata['name'] . "</a>");
        $msg .= "&nbsp;" . Html::showToolTip($userdata["comment"], ['link' => $userdata['link'], 'display' => false]);
        $msg .= " -&gt; " . Html::convDateTime($this->fields['date']);
        $msg .= "</strong>";
        if ($showAskUnlock) {
            $msg .= "<a class='btn btn-sm btn-primary ms-2' onclick='javascript:askUnlock();'>
               <i class='fas fa-unlock'></i>
               <span>" . __('Ask for unlock') . "</span>
            </a>";
        }
        $msg .= "<label for='alertMe'>" . __('Alert me when unlocked') . "</label>";
        $msg .= Html::getCheckbox(['id' => 'alertMe']);
        $msg .= $this->getForceUnlockMessage(); // will get a button to force unlock if UNLOCK rights are in the user's profile

        $this->displayLockMessage($msg);

        echo $ret;
    }


    /**
     * Summary of setReadOnlyMessage
     * Shows 'Read-only!' message and propose to request a lock on the item
     * This function is used by autoLockMode function
     **/
    private function setReadOnlyMessage()
    {

        $msg = "<span class=red style='padding-left:5px;'>";
        $msg .= __('Warning: read-only!');
        $msg .= "</span>";
        $msg .= '<form action="' . $_SERVER['REQUEST_URI'] . '" method="POST" style="display:inline;">';
        $msg .= Html::hidden('_glpi_csrf_token', ['value' => Session::getNewCSRFToken()]);
        $msg .= Html::hidden('lockwrite', ['value' => 1]);
        $msg .= '<button type="submit" class="btn btn-sm btn-primary ms-2">';
        $msg .= __('Request write on ') . $this->itemtypename . " #" . $this->itemid;
        $msg .= '</button>';
        $msg .= '</form>';
        $this->displayLockMessage($msg);
    }


    /**
     * Summary of lockObject
     * Tries to lock object and if yes output code to auto unlock it when leaving browser page.
     * If lock can't be set (i.e.: someone has already locked it), LockedBy message is shown accordingly,
     * and read-only profile is set
     * @return bool: true if locked
     **/
    private function lockObject()
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        $ret = false;
        if (
            !($gotIt = $this->getFromDBByCrit(['itemtype' => $this->itemtype,
                'items_id' => $this->itemid,
            ]))
               && $id = $this->add(['itemtype' => $this->itemtype,
                   'items_id' => $this->itemid,
                   'users_id' => Session::getLoginUserID(),
               ])
        ) {
            // add a script to unlock the Object
            echo Html::scriptBlock("$(function() {
            $(window).on('beforeunload', function() {
               var fallback_request = function() {
                  $.post({
                     url: '" . $CFG_GLPI['root_doc'] . "/ajax/unlockobject.php',
                     async: false,
                     cache: false,
                     data: {
                        unlock: 1,
                        id: $id
                     },
                     dataType: 'json'
                  });
               };

               if (typeof window.fetch !== 'undefined') {
                  fetch('" . $CFG_GLPI['root_doc'] . "/ajax/unlockobject.php', {
                     method: 'POST',
                     cache: 'no-cache',
                     headers: {
                        'Accept': 'application/json',
                        'Content-Type': 'application/x-www-form-urlencoded;',
                        'X-Glpi-Csrf-Token': getAjaxCsrfToken()
                     },
                     body: 'unlock=1&id={$id}'
                  }).catch(function(error) {
                     //fallback if fetch fails
                     fallback_request();
                  });
               } else {
                  //fallback for browsers with no fetch support
                  fallback_request();
               }
            });
         })");

            $ret = true;
        } else { // can't add a lock as another one is already existing
            if (!$gotIt) {
                $this->getFromDBByCrit([
                    'itemtype'  => $this->itemtype,
                    'items_id'  => $this->itemid,
                ]);
            }
            // open the object as read-only as it is already locked by someone
            self::setReadonlyProfile();
            if ($this->fields['users_id'] != Session::getLoginUserID()) {
                $this->setLockedByMessage();
            } else {
                $this->setLockedByYouMessage();
            }
            // and if autolock was set for this item then unset it
            unset($_SESSION['glpilock_autolock_items'][ $this->itemtype ][ $this->itemid ]);
        }
        return $ret;
    }


    /**
     * Summary of getLockedObjectInfo
     *
     * @return bool: true if object is locked, and $this is filled with record from DB
     **/
    private function getLockedObjectInfo()
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        $ret = false;
        if (
            $CFG_GLPI["lock_use_lock_item"]
            && ($CFG_GLPI["lock_lockprofile_id"] > 0)
            && Session::getCurrentInterface() == 'central'
            && in_array($this->itemtype, $CFG_GLPI['lock_item_list'])
            && $this->getFromDBByCrit(['itemtype' => $this->itemtype,
                'items_id' => $this->itemid,
            ])
        ) {
            $ret = true;
        }
        return $ret;
    }


    /**
     * Summary of isLocked
     *
     * @param $itemtype
     * @param $items_id
     *
     * @return bool|ObjectLock: returns ObjectLock if locked, else false
     **/
    public static function isLocked($itemtype, $items_id)
    {

        $ol = new self($itemtype, $items_id);
        return ($ol->getLockedObjectInfo() ? $ol : false);
    }


    /**
     * Summary of setReadOnlyProfile
     * Switches current profile with read-only profile
     * Registers a shutdown function to be sure that even in case of die() calls,
     * the switch back will be done: to ensure correct reset of normal profile
     **/
    public static function setReadOnlyProfile()
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        // to prevent double set ReadOnlyProfile
        if (!isset($_SESSION['glpilocksavedprofile'])) {
            if (isset($CFG_GLPI['lock_lockprofile'])) {
                if (!self::$shutdownregistered) {
                    // this is a security in case of a die that can prevent correct revert of profile
                    register_shutdown_function([__CLASS__,  'revertProfile']);
                    self::$shutdownregistered = true;
                }
                $_SESSION['glpilocksavedprofile'] = $_SESSION['glpiactiveprofile'];
                $_SESSION['glpiactiveprofile']    = $CFG_GLPI['lock_lockprofile'];

                // this mask is mandatory to prevent read of information
                // that are not permitted to view by active profile
                $rights = ProfileRight::getAllPossibleRights();
                foreach ($rights as $key => $val) {
                    if (isset($_SESSION['glpilocksavedprofile'][$key])) {
                        $_SESSION['glpiactiveprofile'][$key]
                        = intval($_SESSION['glpilocksavedprofile'][$key])
                        & (isset($CFG_GLPI['lock_lockprofile'][$key])
                             ? intval($CFG_GLPI['lock_lockprofile'][$key]) : 0);
                    }
                }
                // don't forget entities
                $_SESSION['glpiactiveprofile']['entities'] = $_SESSION['glpilocksavedprofile']['entities'];
            }
        }
    }


    /**
     * Summary of revertProfile
     * Will revert normal user profile
     **/
    public static function revertProfile()
    {
        if (isset($_SESSION['glpilocksavedprofile'])) {
            $_SESSION['glpiactiveprofile'] = $_SESSION['glpilocksavedprofile'];
            unset($_SESSION['glpilocksavedprofile']);
        }
    }


    /**
     * Summary of manageObjectLock
     * Is the main function to be called in order to lock an item
     *
     * @param  $itemtype
     * @param  $options
     **/
    public static function manageObjectLock($itemtype, &$options)
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        if (isset($options['id']) && ($options['id'] > 0)) {
            $ol       = new self($itemtype, $options['id']);
            $template = (isset($options['withtemplate'])
                      && ($options['withtemplate'] > 0) ? true : false);
            if (
                (Session::getCurrentInterface() == "central")
                && isset($CFG_GLPI["lock_use_lock_item"]) && $CFG_GLPI["lock_use_lock_item"]
                && ($CFG_GLPI["lock_lockprofile_id"] > 0)
                && in_array($itemtype, $CFG_GLPI['lock_item_list'])
                && Session::haveRightsOr($itemtype::$rightname, [UPDATE, DELETE, PURGE, UPDATENOTE])
                && !$template
            ) {
                if (
                    !$ol->autoLockMode()
                    || !$ol->lockObject()
                ) {
                    $options['locked'] = 1;
                }
            }
        }
    }


    /**
     * Summary of displayLockMessage
     * Shows a short message top-left of screen
     * This message is permanent, and can't be closed
     *
     * @param  $msg      : message to be shown
     * @param  $title    : if $title is '' then title bar it is not shown (default '')
     **/
    private function displayLockMessage($msg, $title = '')
    {
        $json_msg = json_encode($msg); // Encode in JSON to prevent issues with quotes mix
        echo Html::scriptBlock("
         $(function() {
            const container = $(`<div id='message_after_lock' class='objectlockmessage' style='display: inline-block;'></div>`);
            container.append($json_msg);
            container.insertAfter('.navigationheader');
         });
      ");
    }


    /**
     * @see CommonDBTM::processMassiveActionsForOneItemtype
     **/
    public static function processMassiveActionsForOneItemtype(
        MassiveAction $ma,
        CommonDBTM $item,
        array $ids
    ) {
        foreach ($ids as $items_id) {
            $itemtype = get_class($item);
            $lo       = new self($itemtype, $items_id);
            if ($lo->getLockedObjectInfo()) {
                $lo->deleteFromDB();
                Log::history($items_id, $itemtype, [0, '', ''], 0, Log::HISTORY_UNLOCK_ITEM);
                $ma->itemDone($itemtype, $items_id, MassiveAction::ACTION_OK);
            }
        }
    }


    public static function rawSearchOptionsToAdd($itemtype)
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;
        $tab = [];

        if (
            (Session::getCurrentInterface() == "central")
            && isset($CFG_GLPI["lock_use_lock_item"]) && $CFG_GLPI["lock_use_lock_item"]
            && ($CFG_GLPI["lock_lockprofile_id"] > 0)
            && in_array($itemtype, $CFG_GLPI['lock_item_list'])
        ) {
            $tab[] = [
                'id'            => '207',
                'table'         => 'glpi_users',
                'field'         => 'name',
                'datatype'      => 'dropdown',
                'right'         => 'all',
                'name'          => __('Locked by'),
                'forcegroupby'  => true,
                'massiveaction' => false,
                'joinparams'    => [
                    'jointype'   => '',
                    'beforejoin' => [
                        'table'      => getTableForItemType('ObjectLock'),
                        'joinparams' => ['jointype' => "itemtype_item"],
                    ],
                ],
            ];

            $tab[] = [
                'id'            => '208',
                'table'         => getTableForItemType('ObjectLock'),
                'field'         => 'date',
                'datatype'      => 'datetime',
                'name'          => __('Locked date'),
                'joinparams'    => ['jointype' => 'itemtype_item'],
                'massiveaction' => false,
                'forcegroupby'  => true,
            ];
        }

        return $tab;
    }


    /**
     * Summary of getRightsToAdd
     *
     * @param  $itemtype
     * @param  $interface   (default 'central')
     *
     * @return array: empty array if itemtype is not lockable; else returns UNLOCK right
     **/
    public static function getRightsToAdd($itemtype, $interface = 'central')
    {
        /** @var array $CFG_GLPI */
        global $CFG_GLPI;

        $ret = [];
        if (
            ($interface == "central")
            && isset($CFG_GLPI["lock_use_lock_item"]) && $CFG_GLPI["lock_use_lock_item"]
            && ($CFG_GLPI["lock_lockprofile_id"] > 0)
            && in_array($itemtype, $CFG_GLPI['lock_lockable_objects'])
        ) {
            $ret = [UNLOCK  => __('Unlock')];
        }
        return $ret;
    }


    /**
     * Give cron information
     *
     * @param $name : task's name
     *
     * @return array of information
     **/
    public static function cronInfo($name)
    {

        switch ($name) {
            case 'unlockobject':
                return ['description' => __('Unlock forgotten locked objects'),
                    'parameter'   => __('Timeout to force unlock (hours)'),
                ];
        }
        return [];
    }


    /**
     * Cron for unlocking forgotten locks
     *
     * @param $task : crontask object
     *
     * @return integer
     *    >0 : done
     *    <0 : to be run again (not finished)
     *     0 : nothing to do
     */
    public static function cronUnlockObject($task)
    {
        // here we have to delete old locks
        $actionCode = 0; // by default
        $task->setVolume(0); // start with zero

        $lockedItems = getAllDataFromTable(
            getTableForItemType(__CLASS__),
            [
                'date' => ['<', date("Y-m-d H:i:s", time() - ($task->fields['param'] * HOUR_TIMESTAMP))],
            ]
        );

        foreach ($lockedItems as $row) {
            $ol = new self();
            if ($ol->delete($row)) {
                $actionCode++;
                $item = new $row['itemtype']();
                $item->getFromDB($row['items_id']);
                $task->log($row['itemtype'] . " #" . $row['items_id'] . ": " . $item->getLink());
                $task->addVolume(1);
                Log::history(
                    $row['items_id'],
                    $row['itemtype'],
                    [0, '', ''],
                    0,
                    Log::HISTORY_UNLOCK_ITEM
                );
            } else {
                return -1; // error can't delete record, then exit with error
            }
        }

        return $actionCode;
    }
}
