<?php
/**
 * OnlyOfficeIntegrator Controller
 *
 * @package      OnlyOfficeIntegrator
 * @subpackage   Controller
 * @license      http://www.gnu.org/licenses/agpl.html AGPL Version 3
 * @author       Cornelius Weiß <c.weiss@metaways.de>
 * @copyright    Copyright (c) 2018 Metaways Infosystems GmbH (http://www.metaways.de)
 *
 */

use \Psr\Http\Message\RequestInterface;
use \Firebase\JWT\JWT;

/**
 * OnlyOfficeIntegrator Controller
 *
 * @TODO
 * - forcesave all docs and drop all users when upgrading installation
 * - keep editing state (what if we miss a status update???)
 *  -> we can't query for editors so far - right?
 *  -> on documentservice restart all users are dropped right? - how to get this
 *   -> maybe just 'cache' state in Redis
 *    -> drop users on cache clear - or from time to time?
 *
 *
 * @package      OnlyOfficeIntegrator
 * @subpackage   Controller
 */
class OnlyOfficeIntegrator_Controller extends Tinebase_Controller_Event
{
    const KEY_SEPARATOR = '.';

    protected $_cmdServiceClientAdapter = null;

    private function __construct()
    {
        $this->_applicationName = OnlyOfficeIntegrator_Config::APP_NAME;
    }

    private function __clone()
    {
    }

    private static $_instance = null;

    /**
     * singleton
     *
     * @return self
     */
    public static function getInstance()
    {
        if (self::$_instance === null) {
            self::$_instance = new self();
        }
        return self::$_instance;
    }

    public static function addFastRoutes(
        /** @noinspection PhpUnusedParameterInspection */
        \FastRoute\RouteCollector $r
    ) {
        $r->addGroup('/' . OnlyOfficeIntegrator_Config::APP_NAME, function (\FastRoute\RouteCollector $routeCollector) {
            $routeCollector->get('/getDocument/{token}', (new Tinebase_Expressive_RouteHandler(
                self::class, 'getDocument', [
                Tinebase_Expressive_RouteHandler::IS_PUBLIC => true
            ]))->toArray());
            $routeCollector->get('/getChanges/{token}[/{revision}]', (new Tinebase_Expressive_RouteHandler(
                self::class, 'getChanges', [
                Tinebase_Expressive_RouteHandler::IS_PUBLIC => true
            ]))->toArray());
            $routeCollector->post('/updateStatus/{token}', (new Tinebase_Expressive_RouteHandler(
                self::class, 'updateStatus', [
                Tinebase_Expressive_RouteHandler::IS_PUBLIC => true
            ]))->toArray());
        });
    }

    public function getChanges($token)
    {
        /** @var \Zend\Diactoros\Request $request **/
        $request = Tinebase_Core::getContainer()->get(RequestInterface::class);
        // WTF! getChanges is a jwt less CORS request from the browser
//        $jwt = $this->_getDecodedAuthToken($request, false);
//        if (!isset($jwt['payload']['url']) ||
//            $jwt['payload']['url'] !== preg_replace('/\?.*$/', '', $request->getUri())) {
//            throw new Tinebase_Exception_AccessDenied('jwt mismatch ' . $request->getUri());
//        }

        if (!is_string($token) || empty($token)) {
            $e = new Tinebase_Exception_Expressive_HttpStatus('parameter token needs to be a string', 400);
            $e->logToSentry(true);
            $e->setLogLevelMethod('notice');
            throw $e;
        }

        $transId = Tinebase_TransactionManager::getInstance()->startTransaction(Tinebase_Core::getDb());
        // if $transId is not set to null, rollback. note the & pass-by-ref! otherwise it would not work
        $raii = (new Tinebase_RAII(function() use (&$transId) {
            if (null !== $transId) {
                Tinebase_TransactionManager::getInstance()->rollBack();
            }
        }))->setReleaseFunc(function () use (&$transId) {
            Tinebase_TransactionManager::getInstance()->commitTransaction($transId);
            $transId = null;
        });

        OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->invalidateTimeouts();

        /** @var OnlyOfficeIntegrator_Model_AccessToken $accessToken */
        $accessToken = OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->search(
            Tinebase_Model_Filter_FilterGroup::getFilterForModel(OnlyOfficeIntegrator_Model_AccessToken::class, [
                ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_TOKEN, 'operator' => 'equals', 'value' => $token],
            ]), new Tinebase_Model_Pagination(['limit' => 1]))->getFirstRecord();

        if (null === $accessToken) {
            throw new Tinebase_Exception_Expressive_HttpStatus('token not valid', 404);
        }

        if ((int)$accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} ===
                (int)OnlyOfficeIntegrator_Model_AccessToken::TEMP_FILE_REVISION) {
            throw new Tinebase_Exception_Expressive_HttpStatus('token is temp file, no history available', 404);
        }

        try {
            $node = Tinebase_FileSystem::getInstance()->stat(OnlyOfficeIntegrator_Controller::getRevisionsChangesPath()
                . '/' . $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID} . '/' .
                $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION});
        } catch (Tinebase_Exception_NotFound $tenf) {
            throw new Tinebase_Exception_Expressive_HttpStatus('changes not found', 404);
        }
        $stream = new \Zend\Diactoros\Stream(Tinebase_FileSystem::getInstance()->getRealPathForHash($node->hash));
        $name = $node->name;
        $contentType = $node->contenttype;

        $raii->release();

        $response = new \Zend\Diactoros\Response($stream, 200, [
            'Content-Disposition'   => 'attachment; filename="' . $name . '"',
            'Content-Type'          => $contentType,
            'Access-Control-Allow-Origin' => '*',
        ]);

        return $response;
    }

    public function getDocument($token)
    {
        /** @var \Zend\Diactoros\Request $request **/
        $request = Tinebase_Core::getContainer()->get(RequestInterface::class);
        $jwt = $this->_getDecodedAuthToken($request, false);
        if (!isset($jwt['payload']['url']) ||
                $jwt['payload']['url'] !== preg_replace('/\?.*$/', '', $request->getUri())) {
            throw new Tinebase_Exception_AccessDenied('jwt mismatch ' . $request->getUri());
        }

        if (!is_string($token) || empty($token)) {
            $e = new Tinebase_Exception_Expressive_HttpStatus('parameter token needs to be a string', 400);
            $e->logToSentry(true);
            $e->setLogLevelMethod('notice');
            throw $e;
        }
        $transId = Tinebase_TransactionManager::getInstance()->startTransaction(Tinebase_Core::getDb());
        // if $transId is not set to null, rollback. note the & pass-by-ref! otherwise it would not work
        $raii = (new Tinebase_RAII(function() use (&$transId) {
            if (null !== $transId) {
                Tinebase_TransactionManager::getInstance()->rollBack();
            }
        }))->setReleaseFunc(function () use (&$transId) {
            Tinebase_TransactionManager::getInstance()->commitTransaction($transId);
            $transId = null;
        });

        OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->invalidateTimeouts();

        /** @var OnlyOfficeIntegrator_Model_AccessToken $accessToken */
        $accessToken = OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->search(
            Tinebase_Model_Filter_FilterGroup::getFilterForModel(OnlyOfficeIntegrator_Model_AccessToken::class, [
                ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_TOKEN, 'operator' => 'equals', 'value' => $token],
            ]), new Tinebase_Model_Pagination(['limit' => 1]))->getFirstRecord();

        if (null === $accessToken) {
            throw new Tinebase_Exception_Expressive_HttpStatus('token not valid', 404);
        }

        if ((int)$accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} ===
                (int)OnlyOfficeIntegrator_Model_AccessToken::TEMP_FILE_REVISION) {
            try {
                /** @var Tinebase_Model_TempFile $tempFile */
                $tempFile = Tinebase_TempFile::getInstance()
                    ->get($accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID});
            } catch (Tinebase_Exception_NotFound $tenf) {
                throw new Tinebase_Exception_Expressive_HttpStatus('node not found', 404);
            }
            $stream = new \Zend\Diactoros\Stream($tempFile->path);
            $name = $tempFile->name;
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $contentType = finfo_file($finfo, $tempFile->path);
            finfo_close($finfo);
        } else {
            try {
                $node = Tinebase_FileSystem::getInstance()->get(
                    $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID}, false,
                    $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION});
            } catch (Tinebase_Exception_NotFound $tenf) {
                throw new Tinebase_Exception_Expressive_HttpStatus('node not found', 404);
            }
            $stream = new \Zend\Diactoros\Stream(Tinebase_FileSystem::getInstance()->getRealPathForHash($node->hash));
            $name = $node->name;
            $contentType = $node->contenttype;
        }

        $raii->release();


        $response = new \Zend\Diactoros\Response($stream, 200, [
            'Content-Disposition'   => 'attachment; filename="' . $name . '"',
            'Content-Type'          => $contentType,
        ]);

        return $response;
    }


    public function updateStatus($token)
    {
        if (!is_string($token) || empty($token)) {
            $e = new Tinebase_Exception_Expressive_HttpStatus('parameter token needs to be a string', 400);
            $e->logToSentry(true);
            $e->setLogLevelMethod('notice');
            throw $e;
        }

        /** @var \Zend\Diactoros\Request $request */
        $request = Tinebase_Core::getContainer()->get(RequestInterface::class);
        $conf = OnlyOfficeIntegrator_Config::getInstance();
        // header auth signing check
        $this->_getDecodedAuthToken($request);
        $requestData = json_decode((string)$request->getBody(), true);

        if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) {
            Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
                ' requestData ' . print_r($requestData, true));
        }

        if (!isset($requestData['status']) || !isset($requestData['key'])) {
            $e = new Tinebase_Exception_Expressive_HttpStatus('bad message format, status or key not set', 400);
            $e->logToSentry(true);
            $e->setLogLevelMethod('notice');
            throw $e;
        }

        switch ((int)$requestData['status']) {
            // case 2: ready for saving, do so, invalidate token
            case 2:
                return $this->processStatus2($requestData, $token);

            //case 3: document save error, treat like 4 -> invalidate token
            case 3:
                $e = new Tinebase_Exception_Backend('OnlyOffice send status 3: save error');
                $e->logToSentry(true);
                $e->setLogLevelMethod('warn');
                Tinebase_Exception::log($e);
            // case 4: document closed without changes -> invalidate token
            case 4:
                return $this->processStatus4($token);

            // case 6: force save, just save, no token invalidation
            case 6:
                return $this->processStatus6($requestData, $token);

            default:
                $response = new \Zend\Diactoros\Response();
                $response->getBody()->write('{
    "error": 0
}');
                return $response;
        }
    }

    protected function processStatus6($requestData, $token)
    {
        if (!isset($requestData['url'])) {
            $e = new Tinebase_Exception_Expressive_HttpStatus('bad message format, url not set', 400);
            $e->logToSentry(true);
            $e->setLogLevelMethod('notice');
            throw $e;
        }

        $transId = Tinebase_TransactionManager::getInstance()->startTransaction(Tinebase_Core::getDb());
        // if $transId is not set to null, rollback. note the & pass-by-ref! otherwise it would not work
        $raii = (new Tinebase_RAII(function() use (&$transId) {
            if (null !== $transId) {
                Tinebase_TransactionManager::getInstance()->rollBack();
            }
        }))->setReleaseFunc(function() use (&$transId) {
            Tinebase_TransactionManager::getInstance()->commitTransaction($transId);
            $transId = null;
        });

        $allTokens = $this->doSave($requestData, $token);
        $previousRevision = null;

        if ((int)$allTokens->getFirstRecord()->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} !==
                (int)OnlyOfficeIntegrator_Model_AccessToken::TEMP_FILE_REVISION) {
            $node = Tinebase_FileSystem::getInstance()->get($allTokens->getFirstRecord()
                ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID});
            $previousRevision = $allTokens->getFirstRecord()
                ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION};
            $allTokens->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} = $node->revision;
            foreach ($allTokens as $token) {
                OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->update($token);
            }
        }

        $raii->release();

        $this->createHistory($allTokens->getFirstRecord(), $requestData, $previousRevision);

        $response = new \Zend\Diactoros\Response();
        $response->getBody()->write('{
    "error": 0
}');
        return $response;
    }

    protected function processStatus4($token)
    {
        $transId = Tinebase_TransactionManager::getInstance()->startTransaction(Tinebase_Core::getDb());
        // if $transId is not set to null, rollback. note the & pass-by-ref! otherwise it would not work
        $raii = (new Tinebase_RAII(function() use (&$transId) {
            if (null !== $transId) {
                Tinebase_TransactionManager::getInstance()->rollBack();
            }
        }))->setReleaseFunc(function () use (&$transId) {
            Tinebase_TransactionManager::getInstance()->commitTransaction($transId);
            $transId = null;
        });

        $allTokens = OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->search(
            Tinebase_Model_Filter_FilterGroup::getFilterForModel(OnlyOfficeIntegrator_Model_AccessToken::class, [
                ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_TOKEN, 'operator' => 'equals', 'value' => $token],
                ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_INVALIDATED, 'operator' => 'equals', 'value' => Tinebase_Model_Filter_Bool::VALUE_NOTSET],
            ]));

        foreach ($allTokens as $tokenRecord) {
            $tokenRecord->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_INVALIDATED} = 1;
            $tokenRecord->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_RESOLUTION} = 4;

            OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->update($tokenRecord);
        }

        $raii->release();


        $response = new \Zend\Diactoros\Response();
        $response->getBody()->write('{
    "error": 0
}');
        return $response;
    }

    protected function processStatus2($requestData, $token)
    {
        if (!isset($requestData['url'])) {
            $e = new Tinebase_Exception_Expressive_HttpStatus('bad message format, url not set', 400);
            $e->logToSentry(true);
            $e->setLogLevelMethod('notice');
            throw $e;
        }

        $transId = Tinebase_TransactionManager::getInstance()->startTransaction(Tinebase_Core::getDb());
        // if $transId is not set to null, rollback. note the & pass-by-ref! otherwise it would not work
        $raii = (new Tinebase_RAII(function() use (&$transId) {
            if (null !== $transId) {
                Tinebase_TransactionManager::getInstance()->rollBack();
            }
        }))->setReleaseFunc(function () use (&$transId) {
            Tinebase_TransactionManager::getInstance()->commitTransaction($transId);
            $transId = null;
        });

        $allTokens = $this->doSave($requestData, $token);

        $previousRevision = null;
        if ((int)$allTokens->getFirstRecord()->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} !==
                (int)OnlyOfficeIntegrator_Model_AccessToken::TEMP_FILE_REVISION) {
            $node = Tinebase_FileSystem::getInstance()->get($allTokens->getFirstRecord()
                ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID});
            $previousRevision = $allTokens->getFirstRecord()
                ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION};
        } else {
            $node = null;
        }

        foreach ($allTokens as $tokenRecord) {
            if (null !== $node) {
                $tokenRecord->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} = $node->revision;
            }
            $tokenRecord->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_INVALIDATED} = 1;
            $tokenRecord->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_RESOLUTION} = 2;

            OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->update($tokenRecord);
        }

        $raii->release();

        $this->createHistory($tokenRecord, $requestData, $previousRevision);
        

        $response = new \Zend\Diactoros\Response();
        $response->getBody()->write('{
    "error": 0
}');
        return $response;
    }

    protected function createHistory(OnlyOfficeIntegrator_Model_AccessToken $accessToken, array $requestData, $previousRevision)
    {
        if ((int)$accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} !==
            (int)OnlyOfficeIntegrator_Model_AccessToken::TEMP_FILE_REVISION) {

            if (isset($requestData['changesurl']) && isset($requestData['history'])) {

                $transId = Tinebase_TransactionManager::getInstance()->startTransaction(Tinebase_Core::getDb());
                // if $transId is not set to null, rollback. note the & pass-by-ref! otherwise it would not work
                $raii = (new Tinebase_RAII(function() use (&$transId) {
                    if (null !== $transId) {
                        Tinebase_TransactionManager::getInstance()->rollBack();
                    }
                }))->setReleaseFunc(function () use (&$transId) {
                    Tinebase_TransactionManager::getInstance()->commitTransaction($transId);
                    $transId = null;
                });

                // eventually rewrite the source url depending on config
                $conf = OnlyOfficeIntegrator_Config::getInstance();
                if ($conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_SERVER_URL} && $conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_PUBLIC_URL}) {
                    $requestData['changesurl'] = str_replace($conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_PUBLIC_URL},
                        $conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_SERVER_URL}, $requestData['changesurl']);
                }
                
                // download changesurl
                if (!($srcStream = fopen($requestData['changesurl'], 'r'))) {
                    throw new Tinebase_Exception_Backend('could not open changes url');
                }
                $closeSrcStreamRaii = new Tinebase_RAII(function () use (&$srcStream) {
                    @fclose($srcStream);
                });

                Tinebase_FileSystem::getInstance()->mkdir(static::getRevisionsChangesPath() . '/' . $accessToken
                        ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID});
                if (!($dstStream = fopen('tine20://' . static::getRevisionsChangesPath() . '/' . $accessToken
                        ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID} . '/' . $accessToken
                        ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION}, 'w'))) {
                    throw new Tinebase_Exception_Backend('could not open tine node to store revisions changes');
                }

                if (false === stream_copy_to_stream($srcStream, $dstStream)) {
                    throw new Tinebase_Exception_Backend('stream copy failed');
                }
                if (false === fclose($dstStream)) {
                    throw new Tinebase_Exception_Backend('tine20 fclose failed');
                }
                // just for unused variable check
                unset($closeSrcStreamRaii);

                // save history meta data
                $user = Tinebase_Core::getUser();
                OnlyOfficeIntegrator_Controller_History::getInstance()->create(new OnlyOfficeIntegrator_Model_History([
                    OnlyOfficeIntegrator_Model_History::FLDS_NODE_ID => $accessToken
                        ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID},
                    OnlyOfficeIntegrator_Model_History::FLDS_NODE_REVISION => $accessToken
                        ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION},
                    OnlyOfficeIntegrator_Model_History::FLDS_VERSION => $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION},
                    OnlyOfficeIntegrator_Model_History::FLDS_JSON => [
                        'history' => $requestData['history'],
                        'created' => Tinebase_DateTime::now()->toString(),
                        'user' => [
                            'id' => $user->getId(),
                            'name' => $user->getTitle(),
                        ],
                    ],
                ]));

                $raii->release();
            } else {
                if (Tinebase_Core::isLogLevel(Zend_Log::WARN)) Tinebase_Core::getLogger()->warn(__METHOD__ . '::' .
                    __LINE__ . 'changesurl or history key missing, no history created for current save!' . PHP_EOL .
                    print_r($requestData, true));
            }
        }
    }

    protected function doSave($requestData, $token)
    {
        OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->invalidateTimeouts();

        // lets check if we are allowed to write, meaning if at anytime a person with write rights was on the token
        /** @var OnlyOfficeIntegrator_Model_AccessToken $accessToken */
        $accessToken = ($allTokens = OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->search(
            Tinebase_Model_Filter_FilterGroup::getFilterForModel(OnlyOfficeIntegrator_Model_AccessToken::class, [
                ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_TOKEN, 'operator' => 'equals', 'value' => $token],
                ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_INVALIDATED, 'operator' => 'equals', 'value' => Tinebase_Model_Filter_Bool::VALUE_NOTSET],
            ]), new Tinebase_Model_Pagination([
            'order' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_GRANTS,
            'dir'   => 'DESC'
        ])))->getFirstRecord();

        if (null === $accessToken || $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_GRANTS} <
            OnlyOfficeIntegrator_Model_AccessToken::GRANT_WRITE) {
            throw new Tinebase_Exception_Expressive_HttpStatus('token not valid', 404);
        }

        if ($requestData['key'] !== $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_KEY}) {
            throw new Tinebase_Exception_Expressive_HttpStatus('token doesnt match key', 400);
        }

        $conf = OnlyOfficeIntegrator_Config::getInstance();

        $saveConflict = false;
        // also check if the token itself is still alive (even if the writing person is not alive anymore)
        if ($accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_INVALIDATED} &&
            (int)OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->searchCount(
                Tinebase_Model_Filter_FilterGroup::getFilterForModel(OnlyOfficeIntegrator_Model_AccessToken::class, [
                    ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_TOKEN, 'operator' => 'equals', 'value' => $token],
                    ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_INVALIDATED, 'operator' => 'equals', 'value' => 0],
                ])) === 0) {

            $timeLimit = Tinebase_DateTime::now()->subDay(1);
            if ($timeLimit->isLater($accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_LAST_SEEN})) {
                throw new Tinebase_Exception_Expressive_HttpStatus('token not valid', 404);
            }

            // if there was no token created in the meantime, we allow the save anyway, otherwise we save a conflict
            if (OnlyOfficeIntegrator_Controller_AccessToken::getInstance()->searchCount(
                    Tinebase_Model_Filter_FilterGroup::getFilterForModel(OnlyOfficeIntegrator_Model_AccessToken::class, [
                        ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_TOKEN, 'operator' => 'not',
                            'value' => $token],
                        ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID, 'operator' => 'equals',
                            'value' => $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID}],
                        ['field' => OnlyOfficeIntegrator_Model_AccessToken::FLDS_LAST_SEEN, 'operator' => 'after',
                            'value' => $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_LAST_SEEN}],
                    ])) > 0) {
                // save conflict!
                $saveConflict = true;
            }
        }

        // eventually rewrite the source url depending on config
        if ($conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_SERVER_URL} && $conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_PUBLIC_URL}) {
            $requestData['url'] = str_replace($conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_PUBLIC_URL},
                $conf->{OnlyOfficeIntegrator_Config::ONLYOFFICE_SERVER_URL}, $requestData['url']);
        }

        Tinebase_Core::setUser($user = Tinebase_User::getInstance()->getFullUserById($accessToken
            ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_USER_ID}));

        // figure out target path ... also file ending rewrite happens here
        $srcEnding = ltrim(substr($requestData['url'], strrpos($requestData['url'], '.')), '.');
        $node = null;
        $oldName = null;
        try {
            if ((int)$accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} ===
                    (int)OnlyOfficeIntegrator_Model_AccessToken::TEMP_FILE_REVISION) {
                /** @var Tinebase_Model_TempFile $tempFile */
                $tempFile = Tinebase_TempFile::getInstance()->get($accessToken
                    ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID});
                $trgtPath = $tempFile->path;

                $trgtEnding = ltrim(substr($tempFile->name, $pos = strrpos($tempFile->name, '.')), '.');
                if ($trgtEnding !== $srcEnding) {
                    if (false !== $pos) {
                        $tempFile->name = substr($tempFile->name, 0, $pos);
                    }
                    $tempFile->name = $tempFile->name . '.' . $srcEnding;
                }
            } else {
                $trgtPath = 'tine20://' . Tinebase_FileSystem::getInstance()->getPathOfNode($accessToken
                        ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID}, true);

                $trgtEnding = ltrim(substr($trgtPath, $pos = strrpos($trgtPath, '.')), '.');
                if ($trgtEnding !== $srcEnding || $saveConflict) {
                    if (false === $pos) {
                        $trgtStart = $trgtPath;
                    } else {
                        $trgtStart = substr($trgtPath, 0, $pos);
                    }

                    $tmpPath = $trgtStart . '.' . ($saveConflict ? 'conflict.' : '') . $srcEnding;
                    $i = 1;
                    while (file_exists($tmpPath)) {
                        $tmpPath = $trgtStart . ' (' . $i . ').' . ($saveConflict ? 'conflict.' : '') . $srcEnding;
                        if (++$i > 100) {
                            throw new Tinebase_Exception_Backend('there are already 100 copies of this file');
                        }
                    }
                    $trgtPath = $tmpPath;
                    $node = Tinebase_FileSystem::getInstance()->get($accessToken
                        ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID});
                    $oldName = $node->name;
                    $newName = $node->name = @end(explode('/', $trgtPath));
                    /** do not use the return of this call! revision will be increased later! */
                    Tinebase_FileSystem::getInstance()->update($node);
                    Tinebase_FileSystem::getInstance()->clearStatCache();
                }
            }
        } catch (Tinebase_Exception_NotFound $tenf) {
            throw new Tinebase_Exception_Expressive_HttpStatus('node not found', 404);
        }


        if (!($srcStream = fopen($requestData['url'], 'r'))) {
            throw new Tinebase_Exception_Backend('could not open document to save');
        }
        $closeSrcStreamRaii = new Tinebase_RAII(function() use(&$srcStream) {
            @fclose($srcStream);
        });


        if (!($dstStream = fopen($trgtPath, 'w'))) {
            throw new Tinebase_Exception_Backend('could not open tine node for writing');
        }

        if (false === stream_copy_to_stream($srcStream, $dstStream)) {
            throw new Tinebase_Exception_Backend('stream copy failed');
        }
        if (false === fclose($dstStream)) {
            throw new Tinebase_Exception_Backend('tine20 fclose failed');
        }
        // just for unused variable check
        unset($closeSrcStreamRaii);

        if ((int)$accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_REVISION} !==
                (int)OnlyOfficeIntegrator_Model_AccessToken::TEMP_FILE_REVISION) {

            $notes = Tinebase_Notes::getInstance()->searchNotes(new Tinebase_Model_NoteFilter([
                [
                    'field' => 'record_id',
                    'operator' => 'equals',
                    'value' =>
                        $accessToken->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_NODE_ID}
                ],
                ['field' => 'record_model', 'operator' => 'equals', 'value' => Tinebase_Model_Tree_Node::class],
            ]), new Tinebase_Model_Pagination([
                'sort' => 'seq',
                'dir' => 'DESC',
                'limit' => 2,
            ]));

            $note = $notes->getFirstRecord();

            if (null === $note || $note->created_by !== $user->getId()) {
                Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' did not find file modification note');
            } else {
                if ($oldName !== null) {
                    $newNote = ' name (' . $oldName . ' -> ' . $newName . ')';
                    $note->note = $note->note . $newNote;
                    if ($notes->count() === 2 && strpos($notes->getLastRecord()->note, $newNote) !== false) {
                        $notes->removeFirst();
                        Tinebase_Notes::getInstance()->deleteNotes($notes);
                    } else {
                        Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' did not find file rename note');
                    }
                }
                $allUsers = join(', ', Tinebase_User::getInstance()->getMultiple($allTokens
                    ->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_USER_ID}, Tinebase_Model_FullUser::class)
                    ->accountDisplayName);

                $note->note = str_replace($user->accountDisplayName, $allUsers, $note->note);
                Tinebase_Notes::getInstance()->update($note);
            }
        } else {
            $tempFile->size = filesize($trgtPath);
            Tinebase_TempFile::getInstance()->update($tempFile);
        }

        return $allTokens;
    }

    /**
     * returns decoded auth token signed by OO
     *
     * @param RequestInterface $request
     * @return array
     * @throws Tinebase_Exception_AccessDenied
     */
    protected function _getDecodedAuthToken(RequestInterface $request, $checkBody = true)
    {
        $conf = OnlyOfficeIntegrator_Config::getInstance();
        $auth = $request->getHeader('Authorization');
        if (! isset($auth[0]) || ! preg_match('/^Bearer (.+)/', $auth[0], $token)) {
            throw new Tinebase_Exception_AccessDenied('auth token missing');
        }

        $key = $conf->get(OnlyOfficeIntegrator_Config::JWT_SECRET);
        try {
            JWT::$leeway = 10;
            $decoded = json_decode(json_encode(JWT::decode($token[1], $key, array('HS256'))), true);
        } catch (Exception $e) {
            throw new Tinebase_Exception_AccessDenied('auth token not valid: ' . $e->getMessage());
        }

        if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ .
            ' JWT data ' . print_r($decoded, true));

        if ($checkBody) {
            $bodyMsg = json_decode((string)$request->getBody(), true);

            if (!is_array($decoded) || !isset($decoded['payload']) || $decoded['payload'] !== $bodyMsg) {
                throw new Tinebase_Exception_AccessDenied('auth token doesn\'t match body');
            }
        }

        return $decoded;
    }

    public function setCmdServiceClientAdapter(Zend_Http_Client_Adapter_Interface $adapter = null)
    {
        $this->_cmdServiceClientAdapter = $adapter;
    }

    protected function _getCmdServiceHttpClient($url)
    {
        $client = new Zend_Http_Client($url);
        if ($this->_cmdServiceClientAdapter) {
            $client->setAdapter($this->_cmdServiceClientAdapter);
        }

        return $client;
    }

    public function callCmdServiceForceSave(OnlyOfficeIntegrator_Model_AccessToken $token)
    {
        if (empty($url = OnlyOfficeIntegrator_Config::getInstance()
                ->{OnlyOfficeIntegrator_Config::ONLYOFFICE_SERVER_URL})) {
            if (empty($url = OnlyOfficeIntegrator_Config::getInstance()
                    ->{OnlyOfficeIntegrator_Config::ONLYOFFICE_SERVER_URL})) {
                throw new Tinebase_Exception_Backend('no only office url configured');
            }
        }

        $url = rtrim($url, '/') . '/coauthoring/CommandService.ashx';
        $response = $this->_getCmdServiceHttpClient($url)->setRawData($body = json_encode([
            'c'     => 'forcesave',
            'key'   => $token->{OnlyOfficeIntegrator_Model_AccessToken::FLDS_KEY},
        ]))->setHeaders('Authorization', 'Bearer ' . JWT::encode(['payload' => $body],
                OnlyOfficeIntegrator_Config::getInstance()->{OnlyOfficeIntegrator_Config::JWT_SECRET}, 'HS256'))
            ->request(Zend_Http_Client::POST);

        if (200 !== $response->getStatus()) {
            Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' ' . $response->getStatus() . ' ' .
                $response->getMessage() . PHP_EOL . $response->getBody());
            throw new Tinebase_Exception_Backend('onlyoffice command service did not responde with status code 200');
        }
        if (!is_array($body = json_decode($response->getBody(), true)) || !isset($body['error'])) {
            throw new Tinebase_Exception_Backend('onlyoffice command service response body not well formed: ' .
                $response->getBody());
        }

        if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::'
            . __LINE__ . " onlyoffice command service response:\n"  . $response->getHeadersAsString() . "\n" . $response->getBody());

        // TODO we probably need to return more information what happend here...
        if ((int)$body['error'] === 0 || (int)$body['error'] === 4) {
            return true;
        }

        Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' onlyoffice command service response: ' .
            $response->getBody());

        return false;
    }

    public static function getNewTemplatePath()
    {
        return Tinebase_FileSystem::getInstance()
            ->getApplicationBasePath(OnlyOfficeIntegrator_Config::APP_NAME, Tinebase_FileSystem::FOLDER_TYPE_SHARED) .
            '/newTemplates';
    }

    public static function getRevisionsChangesPath()
    {
        return Tinebase_FileSystem::getInstance()
                ->getApplicationBasePath(OnlyOfficeIntegrator_Config::APP_NAME,
                Tinebase_FileSystem::FOLDER_TYPE_RECORDS) . '/revisionChanges';
    }
}
