diff --git a/.github/workflows/artifact.yml b/.github/workflows/artifact.yml index e3704c7..2167b2d 100644 --- a/.github/workflows/artifact.yml +++ b/.github/workflows/artifact.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Build Artifact run: | cd ${{ github.workspace }} @@ -29,7 +29,7 @@ jobs: cd $cwd awk '/## [0-9]/{p++} p; /## [0-9]/{if (p > 1) exit}' CHANGELOG.md | awk 'NR>2 {print last} {last=$0}' > RELEASE.md - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: onlyoffice path: deploy \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73a58bd..5f7054b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,12 +9,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Install Zip run: sudo apt-get install zip - - name: Get Info + - name: Get Info run: | - echo "::set-output name=version::$(awk '/##/{gsub(/[^0-9]+.[^0-9]+.[^0-9]/, "", $NF); print $NF}' CHANGELOG.md | awk 'NR==1{print $1}')" + echo "version=$(awk '/##/{gsub(/[^0-9]+.[^0-9]+.[^0-9]/, "", $NF); print $NF}' CHANGELOG.md | awk 'NR==1{print $1}')" >> $GITHUB_OUTPUT id: info - name: Build Artifact run: | @@ -34,23 +34,10 @@ jobs: awk '/## [0-9]/{p++} p; /## [0-9]/{if (p > 1) exit}' CHANGELOG.md | awk 'NR>2 {print last} {last=$0}' > RELEASE.md - name: Pack Artifact run: zip -r onlyoffice onlyoffice - - name: Release Artifact - uses: actions/create-release@v1 + - name: Create Release + uses: ncipollo/release-action@v1 id: create_release with: - draft: false - prerelease: false - release_name: v${{ steps.info.outputs.version }} - tag_name: v${{ steps.info.outputs.version }} - body_path: ./RELEASE.md - env: - GITHUB_TOKEN: ${{ github.token }} - - name: Upload Release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: onlyoffice.zip - asset_name: onlyoffice.zip - asset_content_type: application/zip \ No newline at end of file + artifacts: "onlyoffice.zip" + bodyFile: RELEASE.md + tag: v${{ steps.info.outputs.version }} \ No newline at end of file diff --git a/3rd-Party.txt b/3rd-Party.txt deleted file mode 100644 index f10b75a..0000000 --- a/3rd-Party.txt +++ /dev/null @@ -1,5 +0,0 @@ -Chamilo ONLYOFFICE integration plugin uses code from the following 3rd party projects: - -JWT - JSON Web Token implementation (https://github.com/firebase/php-jwt/blob/master/LICENSE) -License: BSD -License File: 3rdparty/jwt/LICENSE diff --git a/3rdparty/jwt/BeforeValidException.php b/3rdparty/jwt/BeforeValidException.php deleted file mode 100644 index a6ee2f7..0000000 --- a/3rdparty/jwt/BeforeValidException.php +++ /dev/null @@ -1,7 +0,0 @@ - - * @author Anant Narayanan - * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD - * @link https://github.com/firebase/php-jwt - */ -class JWT -{ - - /** - * When checking nbf, iat or expiration times, - * we want to provide some extra leeway time to - * account for clock skew. - */ - public static $leeway = 0; - - /** - * Allow the current timestamp to be specified. - * Useful for fixing a value within unit testing. - * - * Will default to PHP time() value if null. - */ - public static $timestamp = null; - - public static $supported_algs = array( - 'HS256' => array('hash_hmac', 'SHA256'), - 'HS512' => array('hash_hmac', 'SHA512'), - 'HS384' => array('hash_hmac', 'SHA384'), - 'RS256' => array('openssl', 'SHA256'), - ); - - /** - * Decodes a JWT string into a PHP object. - * - * @param string $jwt The JWT - * @param string|array $key The key, or map of keys. - * If the algorithm used is asymmetric, this is the public key - * @param array $allowed_algs List of supported verification algorithms - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * - * @return object The JWT's payload as a PHP object - * - * @throws UnexpectedValueException Provided JWT was invalid - * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed - * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' - * @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat' - * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim - * - * @uses jsonDecode - * @uses urlsafeB64Decode - */ - public static function decode($jwt, $key, $allowed_algs = array()) - { - $timestamp = is_null(static::$timestamp) ? time() : static::$timestamp; - - if (empty($key)) { - throw new InvalidArgumentException('Key may not be empty'); - } - if (!is_array($allowed_algs)) { - throw new InvalidArgumentException('Algorithm not allowed'); - } - $tks = explode('.', $jwt); - if (count($tks) != 3) { - throw new UnexpectedValueException('Wrong number of segments'); - } - list($headb64, $bodyb64, $cryptob64) = $tks; - if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) { - throw new UnexpectedValueException('Invalid header encoding'); - } - if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { - throw new UnexpectedValueException('Invalid claims encoding'); - } - $sig = static::urlsafeB64Decode($cryptob64); - - if (empty($header->alg)) { - throw new UnexpectedValueException('Empty algorithm'); - } - if (empty(static::$supported_algs[$header->alg])) { - throw new UnexpectedValueException('Algorithm not supported'); - } - if (!in_array($header->alg, $allowed_algs)) { - throw new UnexpectedValueException('Algorithm not allowed'); - } - if (is_array($key) || $key instanceof \ArrayAccess) { - if (isset($header->kid)) { - $key = $key[$header->kid]; - } else { - throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); - } - } - - // Check the signature - if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) { - throw new SignatureInvalidException('Signature verification failed'); - } - - // Check if the nbf if it is defined. This is the time that the - // token can actually be used. If it's not yet that time, abort. - if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) { - throw new BeforeValidException( - 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->nbf) - ); - } - - // Check that this token has been created before 'now'. This prevents - // using tokens that have been created for later use (and haven't - // correctly used the nbf claim). - if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { - throw new BeforeValidException( - 'Cannot handle token prior to ' . date(DateTime::ISO8601, $payload->iat) - ); - } - - // Check if this token has expired. - if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { - throw new ExpiredException('Expired token'); - } - - return $payload; - } - - /** - * Converts and signs a PHP object or array into a JWT string. - * - * @param object|array $payload PHP object or array - * @param string $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * @param mixed $keyId - * @param array $head An array with header elements to attach - * - * @return string A signed JWT - * - * @uses jsonEncode - * @uses urlsafeB64Encode - */ - public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null) - { - $header = array('typ' => 'JWT', 'alg' => $alg); - if ($keyId !== null) { - $header['kid'] = $keyId; - } - if ( isset($head) && is_array($head) ) { - $header = array_merge($head, $header); - } - $segments = array(); - $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); - $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); - $signing_input = implode('.', $segments); - - $signature = static::sign($signing_input, $key, $alg); - $segments[] = static::urlsafeB64Encode($signature); - - return implode('.', $segments); - } - - /** - * Sign a string with a given key and algorithm. - * - * @param string $msg The message to sign - * @param string|resource $key The secret key - * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * - * @return string An encrypted message - * - * @throws DomainException Unsupported algorithm was specified - */ - public static function sign($msg, $key, $alg = 'HS256') - { - if (empty(static::$supported_algs[$alg])) { - throw new DomainException('Algorithm not supported'); - } - list($function, $algorithm) = static::$supported_algs[$alg]; - switch($function) { - case 'hash_hmac': - return hash_hmac($algorithm, $msg, $key, true); - case 'openssl': - $signature = ''; - $success = openssl_sign($msg, $signature, $key, $algorithm); - if (!$success) { - throw new DomainException("OpenSSL unable to sign data"); - } else { - return $signature; - } - } - } - - /** - * Verify a signature with the message, key and method. Not all methods - * are symmetric, so we must have a separate verify and sign method. - * - * @param string $msg The original message (header and body) - * @param string $signature The original signature - * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key - * @param string $alg The algorithm - * - * @return bool - * - * @throws DomainException Invalid Algorithm or OpenSSL failure - */ - private static function verify($msg, $signature, $key, $alg) - { - if (empty(static::$supported_algs[$alg])) { - throw new DomainException('Algorithm not supported'); - } - - list($function, $algorithm) = static::$supported_algs[$alg]; - switch($function) { - case 'openssl': - $success = openssl_verify($msg, $signature, $key, $algorithm); - if (!$success) { - throw new DomainException("OpenSSL unable to verify data: " . openssl_error_string()); - } else { - return $signature; - } - case 'hash_hmac': - default: - $hash = hash_hmac($algorithm, $msg, $key, true); - if (function_exists('hash_equals')) { - return hash_equals($signature, $hash); - } - $len = min(static::safeStrlen($signature), static::safeStrlen($hash)); - - $status = 0; - for ($i = 0; $i < $len; $i++) { - $status |= (ord($signature[$i]) ^ ord($hash[$i])); - } - $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash)); - - return ($status === 0); - } - } - - /** - * Decode a JSON string into a PHP object. - * - * @param string $input JSON string - * - * @return object Object representation of JSON string - * - * @throws DomainException Provided string was invalid JSON - */ - public static function jsonDecode($input) - { - if (version_compare(PHP_VERSION, '5.4.0', '>=') && !(defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { - /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you - * to specify that large ints (like Steam Transaction IDs) should be treated as - * strings, rather than the PHP default behaviour of converting them to floats. - */ - $obj = json_decode($input, false, 512, JSON_BIGINT_AS_STRING); - } else { - /** Not all servers will support that, however, so for older versions we must - * manually detect large ints in the JSON string and quote them (thus converting - *them to strings) before decoding, hence the preg_replace() call. - */ - $max_int_length = strlen((string) PHP_INT_MAX) - 1; - $json_without_bigints = preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input); - $obj = json_decode($json_without_bigints); - } - - if (function_exists('json_last_error') && $errno = json_last_error()) { - static::handleJsonError($errno); - } elseif ($obj === null && $input !== 'null') { - throw new DomainException('Null result with non-null input'); - } - return $obj; - } - - /** - * Encode a PHP object into a JSON string. - * - * @param object|array $input A PHP object or array - * - * @return string JSON representation of the PHP object or array - * - * @throws DomainException Provided object could not be encoded to valid JSON - */ - public static function jsonEncode($input) - { - $json = json_encode($input); - if (function_exists('json_last_error') && $errno = json_last_error()) { - static::handleJsonError($errno); - } elseif ($json === 'null' && $input !== null) { - throw new DomainException('Null result with non-null input'); - } - return $json; - } - - /** - * Decode a string with URL-safe Base64. - * - * @param string $input A Base64 encoded string - * - * @return string A decoded string - */ - public static function urlsafeB64Decode($input) - { - $remainder = strlen($input) % 4; - if ($remainder) { - $padlen = 4 - $remainder; - $input .= str_repeat('=', $padlen); - } - return base64_decode(strtr($input, '-_', '+/')); - } - - /** - * Encode a string with URL-safe Base64. - * - * @param string $input The string you want encoded - * - * @return string The base64 encode of what you passed in - */ - public static function urlsafeB64Encode($input) - { - return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); - } - - /** - * Helper method to create a JSON error. - * - * @param int $errno An error number from json_last_error() - * - * @return void - */ - private static function handleJsonError($errno) - { - $messages = array( - JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', - JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', - JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON' - ); - throw new DomainException( - isset($messages[$errno]) - ? $messages[$errno] - : 'Unknown JSON error: ' . $errno - ); - } - - /** - * Get the number of bytes in cryptographic strings. - * - * @param string - * - * @return int - */ - private static function safeStrlen($str) - { - if (function_exists('mb_strlen')) { - return mb_strlen($str, '8bit'); - } - return strlen($str); - } -} diff --git a/3rdparty/jwt/LICENSE.txt b/3rdparty/jwt/LICENSE.txt deleted file mode 100644 index cb0c49b..0000000 --- a/3rdparty/jwt/LICENSE.txt +++ /dev/null @@ -1,30 +0,0 @@ -Copyright (c) 2011, Neuman Vong - -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - - * Neither the name of Neuman Vong nor the names of other - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/3rdparty/jwt/SignatureInvalidException.php b/3rdparty/jwt/SignatureInvalidException.php deleted file mode 100644 index 27332b2..0000000 --- a/3rdparty/jwt/SignatureInvalidException.php +++ /dev/null @@ -1,7 +0,0 @@ -get("jwt_secret"))) { + if (!empty($plugin->getDocumentServerSecret())) { if (!empty($data["token"])) { try { - $payload = \Firebase\JWT\JWT::decode($data["token"], $plugin->get("jwt_secret"), array("HS256")); + $payload = JWT::decode($data["token"], new Key($plugin->getDocumentServerSecret(), "HS256")); } catch (\UnexpectedValueException $e) { $result["status"] = "error"; $result["error"] = "403 Access denied"; return $result; } } else { - $token = substr(getallheaders()[AppConfig::JwtHeader()], strlen("Bearer ")); + $token = substr(getallheaders()[$plugin->getJwtHeader()], strlen("Bearer ")); try { - $decodeToken = \Firebase\JWT\JWT::decode($token, $plugin->get("jwt_secret"), array("HS256")); + $decodeToken = JWT::decode($token, new Key($plugin->getDocumentServerSecret(), "HS256")); $payload = $decodeToken->payload; } catch (\UnexpectedValueException $e) { $result["status"] = "error"; @@ -151,6 +153,7 @@ function track(): array case TrackerStatus_Corrupted: $downloadUri = $data["url"]; + $downloadUri = $plugin->replaceDocumentServerUrlToInternal($downloadUri); if (!empty($docId) && !empty($courseCode)) { $docInfo = DocumentManager::get_document_data_by_id($docId, $courseCode, false, $sessionId); @@ -225,10 +228,10 @@ function download() global $sessionId; global $courseInfo; - if (!empty($plugin->get("jwt_secret"))) { - $token = substr(getallheaders()[AppConfig::JwtHeader()], strlen("Bearer ")); + if (!empty($plugin->getDocumentServerSecret())) { + $token = substr(getallheaders()[$plugin->getJwtHeader()], strlen("Bearer ")); try { - $payload = \Firebase\JWT\JWT::decode($token, $plugin->get("jwt_secret"), array("HS256")); + $payload = JWT::decode($token, new Key($plugin->getDocumentServerSecret(), "HS256")); } catch (\UnexpectedValueException $e) { $result["status"] = "error"; @@ -255,6 +258,7 @@ function download() @header("Content-Disposition: attachment; filename=" . $docInfo["title"]); readfile($filePath); + exit(); } /** diff --git a/create.php b/create.php index c8c81c9..eaed39d 100644 --- a/create.php +++ b/create.php @@ -1,7 +1,7 @@ get("document_server_url"); +$documentServerUrl = $plugin->getDocumentServerUrl(); if (empty($documentServerUrl)) { die ("Document server isn't configured"); return; @@ -49,6 +51,9 @@ $sessionId = api_get_session_id(); $courseId = api_get_course_int_id(); $courseInfo = api_get_course_info(); +if (empty($courseInfo)) { + api_not_allowed(true); +} $courseCode = $courseInfo["code"]; $docInfo = DocumentManager::get_document_data_by_id($docId, $courseCode, false, $sessionId); @@ -61,6 +66,10 @@ $key = FileUtility::getKey($courseCode, $docId); $fileUrl = FileUtility::getFileUrl($courseId, $userId, $docId, $sessionId, $groupId); +if (!empty($plugin->getStorageUrl())) { + $fileUrl = str_replace(api_get_path(WEB_PATH), $plugin->getStorageUrl(), $fileUrl); +} + $config = [ "type" => "desktop", "documentType" => $docType, @@ -144,13 +153,19 @@ if ($canEdit && $accessRights && !$isReadonly) { $config["editorConfig"]["mode"] = "edit"; - $config["editorConfig"]["callbackUrl"] = getCallbackUrl( + + $callback = getCallbackUrl( $docId, $userId, $courseId, $sessionId, $groupId ); + + if (!empty($plugin->getStorageUrl())) { + $callback = str_replace(api_get_path(WEB_PATH), $plugin->getStorageUrl(), $callback); + } + $config["editorConfig"]["callbackUrl"] = $callback; } else { $canView = in_array($extension, FileUtility::$can_view_types); if ($canView) { @@ -161,8 +176,8 @@ } $config["document"]["permissions"]["edit"] = $accessRights && !$isReadonly; -if (!empty($plugin->get("jwt_secret"))) { - $token = \Firebase\JWT\JWT::encode($config, $plugin->get("jwt_secret")); +if (!empty($plugin->getDocumentServerSecret())) { + $token = JWT::encode($config, $plugin->getDocumentServerSecret(), "HS256"); $config["token"] = $token; } @@ -242,17 +257,17 @@ function getCallbackUrl(int $docId, int $userId, int $courseId, int $sessionId, var connectEditor = function () { var config = ; + var errorPage = ; + var docsVersion = DocsAPI.DocEditor.version().split("."); if ((config.document.fileType === "docxf" || config.document.fileType === "oform") - && DocsAPI.DocEditor.version().split(".")[0] < 7) { - get_lang("UpdateOnlyoffice"), - "error" - ) - ); - ?>; + && docsVersion[0] < 7) { + window.location.href = errorPage + "?status=" + 1; + return; + } + if (docsVersion[0] < 6 + || docsVersion[0] == 6 && docsVersion[1] == 0) { + window.location.href = errorPage + "?status=" + 2; return; } diff --git a/error.php b/error.php new file mode 100644 index 0000000..b7db5ce --- /dev/null +++ b/error.php @@ -0,0 +1,45 @@ +get_lang($message), + "error" + ) +); + +Display::display_header(); \ No newline at end of file diff --git a/install.php b/install.php index 9a8287f..1f934b5 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ +
+
+
+ +
+
+
+
+

{{banner_title}}

+

{{banner_main_text}}

+
+ +
+
\ No newline at end of file diff --git a/lib/appConfig.php b/lib/appConfig.php index 96bd4ea..47260e3 100644 --- a/lib/appConfig.php +++ b/lib/appConfig.php @@ -1,7 +1,7 @@ "https://onlinedocs.onlyoffice.com/", + "HEADER" => "AuthorizationJWT", + "SECRET" => "sn2puSUF7muF5Jas", + "TRIAL" => 30 + ]; + + /** + * Get demo params + * + * @return array + */ + public static function GetDemoParams() + { + return self::DEMO_PARAM; + } + + /** + * Get link to Docs Cloud + * + * @return string + */ + public function GetLinkToDocs() { + return self::linkToDocs; + } } diff --git a/lib/crypt.php b/lib/crypt.php index 5b06527..005480b 100644 --- a/lib/crypt.php +++ b/lib/crypt.php @@ -1,7 +1,7 @@ getMessage(); } diff --git a/lib/documentService.php b/lib/documentService.php new file mode 100644 index 0000000..57324ec --- /dev/null +++ b/lib/documentService.php @@ -0,0 +1,511 @@ +plugin = $plugin; + $this->newSettings = $newSettings; + } + + + /** + * Request to Document Server with turn off verification + * + * @param string $url - request address + * @param array $method - request method + * @param array $opts - request options + * + * @return string + */ + public function request($url, $method = 'GET', $opts = []) { + if (substr($url, 0, strlen('https')) === 'https') { + $opts['verify'] = false; + } + if (!array_key_exists('timeout', $opts)) { + $opts['timeout'] = 60; + } + + $curl_info = [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => $opts['timeout'], + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_POSTFIELDS => $opts['body'], + CURLOPT_HTTPHEADER => $opts['headers'], + ]; + + if ($opts == []) { + unset($curl_info[CURLOPT_POSTFIELDS]); + } + + $curl = curl_init(); + curl_setopt_array($curl, $curl_info); + $response = curl_exec($curl); + curl_close($curl); + + return $response; + } + + /** + * Generate an error code table of convertion + * + * @param int $errorCode - Error code + * + * @throws Exception + */ + private function processConvServResponceError($errorCode) { + $errorMessageTemplate = $this->plugin->get_lang('docServiceError'); + $errorMessage = ''; + + switch ($errorCode) { + case -20: + $errorMessage = $errorMessageTemplate . ': Error encrypt signature'; + break; + case -8: + $errorMessage = $errorMessageTemplate . ': Invalid token'; + break; + case -7: + $errorMessage = $errorMessageTemplate . ': Error document request'; + break; + case -6: + $errorMessage = $errorMessageTemplate . ': Error while accessing the conversion result database'; + break; + case -5: + $errorMessage = $errorMessageTemplate . ': Incorrect password'; + break; + case -4: + $errorMessage = $errorMessageTemplate . ': Error while downloading the document file to be converted.'; + break; + case -3: + $errorMessage = $errorMessageTemplate . ': Conversion error'; + break; + case -2: + $errorMessage = $errorMessageTemplate . ': Timeout conversion error'; + break; + case -1: + $errorMessage = $errorMessageTemplate . ': Unknown error'; + break; + case 0: + break; + default: + $errorMessage = $errorMessageTemplate . ': ErrorCode = ' . $errorCode; + break; + } + + throw new \Exception($errorMessage); + } + + /** + * Generate an error code table of command + * + * @param string $errorCode - Error code + * + * @throws Exception + */ + private function processCommandServResponceError($errorCode) { + $errorMessageTemplate = $this->plugin->get_lang('docServiceError'); + $errorMessage = ''; + + switch ($errorCode) { + case 6: + $errorMessage = $errorMessageTemplate . ': Invalid token'; + break; + case 5: + $errorMessage = $errorMessageTemplate . ': Command not correсt'; + break; + case 3: + $errorMessage = $errorMessageTemplate . ': Internal server error'; + break; + case 0: + return; + default: + $errorMessage = $errorMessageTemplate . ': ErrorCode = ' . $errorCode; + break; + } + + throw new \Exception($errorMessage); + } + + /** + * Create temporary file for convert service testing + * + * @return array + */ + private function createTempFile() { + $fileUrl = null; + $fileName = 'convert.docx'; + $fileExt = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); + $baseName = strtolower(pathinfo($fileName, PATHINFO_FILENAME)); + $templatePath = TemplateManager::getEmptyTemplate($fileExt); + $folderPath = api_get_path(SYS_PLUGIN_PATH).$this->plugin->getPluginName(); + $filePath = $folderPath . '/' . $fileName; + + if ($fp = @fopen($filePath, 'w')) { + $content = file_get_contents($templatePath); + fputs($fp, $content); + fclose($fp); + chmod($filePath, api_get_permissions_for_new_files()); + $fileUrl = api_get_path(WEB_PLUGIN_PATH).$this->plugin->getPluginName().'/'.$fileName; + } + + return [ + 'fileUrl' => $fileUrl, + 'filePath' => $filePath + ]; + } + + + /** + * Request for conversion to a service + * + * @param string $document_uri - Uri for the document to convert + * @param string $from_extension - Document extension + * @param string $to_extension - Extension to which to convert + * @param string $document_revision_id - Key for caching on service + * @param bool - $is_async - Perform conversions asynchronously + * @param string $region - Region + * + * @throws Exception + * + * @return array + */ + public function sendRequestToConvertService($document_uri, $from_extension, $to_extension, $document_revision_id, $is_async, $region = null) { + if (!empty($this->getValue('document_server_internal'))) { + $documentServerUrl = $this->getValue('document_server_internal'); + } else { + $documentServerUrl = $this->getValue('document_server_url'); + } + + if (empty($documentServerUrl)) { + throw new \Exception($this->plugin->get_lang('pluginIsNotConfigured')); + } + + $urlToConverter = $documentServerUrl . 'ConvertService.ashx'; + + if (empty($document_revision_id)) { + $document_revision_id = $document_uri; + } + + $document_revision_id = FileUtility::GenerateRevisionId($document_revision_id); + + if (empty($from_extension)) { + $from_extension = pathinfo($document_uri)['extension']; + } else { + $from_extension = trim($from_extension, '.'); + } + + $data = [ + 'async' => $is_async, + 'url' => $document_uri, + 'outputtype' => trim($to_extension, '.'), + 'filetype' => $from_extension, + 'title' => $document_revision_id . '.' . $from_extension, + 'key' => $document_revision_id + ]; + + if (!is_null($region)) { + $data['region'] = $region; + } + + $opts = [ + 'timeout' => '120', + 'headers' => [ + 'Content-type' => 'application/json' + ], + 'body' => json_encode($data) + ]; + + if (!empty($this->getValue('jwt_secret'))) { + $params = [ + 'payload' => $data + ]; + $token = JWT::encode($params, $this->getValue('jwt_secret'), 'HS256'); + $opts['headers'][$this->getValue('jwt_header')] = 'Bearer ' . $token; + $token = JWT::encode($data, $this->getValue('jwt_secret'), 'HS256'); + $data['token'] = $token; + $opts['body'] = json_encode($data); + } + + $response_xml_data = $this->request($urlToConverter, 'POST', $opts); + libxml_use_internal_errors(true); + + if (!function_exists('simplexml_load_file')) { + throw new \Exception($this->plugin->get_lang('cantReadXml')); + } + + $response_data = simplexml_load_string($response_xml_data); + + if (!$response_data) { + $exc = $this->plugin->get_lang('badResponseErrors'); + foreach(libxml_get_errors() as $error) { + $exc = $exc . '\t' . $error->message; + } + throw new \Exception ($exc); + } + + return $response_data; + } + + /** + * Request health status + * + * @throws Exception + * + * @return bool + */ + public function healthcheckRequest() { + if (!empty($this->getValue('document_server_internal'))) { + $documentServerUrl = $this->getValue('document_server_internal'); + } else { + $documentServerUrl = $this->getValue('document_server_url'); + } + + if (empty($documentServerUrl)) { + throw new \Exception($this->plugin->get_lang('appIsNotConfigured')); + } + + $urlHealthcheck = $documentServerUrl . 'healthcheck'; + $response = $this->request($urlHealthcheck); + return $response === 'true'; + } + + /** + * The method is to convert the file to the required format and return the result url + * + * @param string $document_uri - Uri for the document to convert + * @param string $from_extension - Document extension + * @param string $to_extension - Extension to which to convert + * @param string $document_revision_id - Key for caching on service + * @param string $region - Region + * + * @return string + */ + public function getConvertedUri($document_uri, $from_extension, $to_extension, $document_revision_id, $region = null) { + $responceFromConvertService = $this->sendRequestToConvertService($document_uri, $from_extension, $to_extension, $document_revision_id, false, $region); + $errorElement = $responceFromConvertService->Error; + if ($errorElement->count() > 0) { + $this->processConvServResponceError($errorElement . ''); + } + + $isEndConvert = $responceFromConvertService->EndConvert; + + if ($isEndConvert !== null && strtolower($isEndConvert) === 'true') { + return $responceFromConvertService->FileUrl; + } + + return ''; + } + + /** + * Send command + * + * @param string $method - type of command + * + * @return array + */ + public function commandRequest($method) { + //$documentServerUrl = $this->plugin->getDocumentServerInternalUrl(); + if (!empty($this->getValue('document_server_internal'))) { + $documentServerUrl = $this->getValue('document_server_internal'); + } else { + $documentServerUrl = $this->getValue('document_server_url'); + } + + + if (empty($documentServerUrl)) { + throw new \Exception($this->plugin->get_lang('cantReadXml')); + } + + $urlCommand = $documentServerUrl . 'coauthoring/CommandService.ashx'; + $data = [ + 'c' => $method + ]; + $opts = [ + 'headers' => [ + 'Content-type' => 'application/json' + ], + 'body' => json_encode($data) + ]; + + if (!empty($this->getValue('jwt_secret'))) { + $params = [ + 'payload' => $data + ]; + $token = JWT::encode($params, $this->getValue('jwt_secret'), 'HS256'); + $opts['headers'][$this->getValue('jwt_header')] = 'Bearer ' . $token; + + $token = JWT::encode($data, $this->getValue('jwt_secret'), 'HS256'); + $data['token'] = $token; + $opts['body'] = json_encode($data); + } + + $response = $this->request($urlCommand, 'POST', $opts); + $data = json_decode($response); + $this->processCommandServResponceError($data->error); + + return $data; + } + + /** + * Checking document service location + * + * @return array + */ + public function checkDocServiceUrl() { + $version = null; + try { + if (preg_match('/^https:\/\//i', api_get_path(WEB_PATH)) + && preg_match('/^http:\/\//i', $this->getValue('document_server_url'))) { + throw new \Exception($this->plugin->get_lang('mixedContent')); + } + } catch (\Exception $e) { + return [$e->getMessage(), $version]; + } + + try { + $healthcheckResponse = $this->healthcheckRequest(); + + if (!$healthcheckResponse) { + throw new \Exception($this->plugin->get_lang('badHealthcheckStatus')); + } + } catch (\Exception $e) { + return [$e->getMessage(), $version]; + } + + try { + $commandResponse = $this->commandRequest('version'); + + if (empty($commandResponse)) { + throw new \Exception($this->plugin->get_lang('errorOccuredDocService')); + } + + $version = $commandResponse->version; + $versionF = floatval($version); + + if ($versionF > 0.0 && $versionF <= 6.0) { + throw new \Exception($this->plugin->get_lang('notSupportedVersion')); + } + } catch (\Exception $e) { + return [$e->getMessage(), $version]; + } + + $convertedFileUri = null; + + try { + $emptyFile = $this->createTempFile(); + + if ($emptyFile['fileUrl'] !== null) { + if (!empty($this->getValue('storage_url'))) { + $emptyFile['fileUrl'] = str_replace(api_get_path(WEB_PATH), $this->getValue('storage_url'), $emptyFile['fileUrl']); + } + $convertedFileUri = $this->getConvertedUri($emptyFile['fileUrl'], 'docx', 'docx', 'check_' . rand()); + } + + unlink($emptyFile['filePath']); + } catch (\Exception $e) { + if (isset($emptyFile['filePath'])) { + unlink($emptyFile['filePath']); + } + return [$e->getMessage(), $version]; + } + + try { + $this->request($convertedFileUri); + } catch (\Exception $e) { + return [$e->getMessage(), $version]; + } + + return ['', $version]; + } + + /** + * Get setting value (from data base or submited form) + * + * @return string + */ + private function getValue($value) { + $result = null; + + if (!isset($this->newSettings)) { + switch ($value) { + case 'document_server_url': + $result = $this->plugin->getDocumentServerUrl(); + break; + case 'jwt_secret': + $result = $this->plugin->getDocumentServerSecret(); + break; + case 'jwt_header': + $result = $this->plugin->getJwtHeader(); + break; + case 'document_server_internal': + $result = $this->plugin->getDocumentServerInternalUrl(); + break; + case 'storage_url': + $result = $this->plugin->getStorageUrl(); + break; + default: + } + } else { + $result = isset($this->newSettings[$value]) ? (string)$this->newSettings[$value] : null; + if ($value !== 'jwt_secret' && $value !== 'jwt_header') { + if ($result !== null && $result !== "/") { + $result = rtrim($result, "/"); + if (strlen($result) > 0) { + $result = $result . "/"; + } + } + } else if ($value === 'jwt_header' && empty($this->newSettings[$value])) { + $result = 'Authorization'; + } + } + return $result; + } + +} \ No newline at end of file diff --git a/lib/fileUtility.php b/lib/fileUtility.php index c92a98e..d2586f9 100644 --- a/lib/fileUtility.php +++ b/lib/fileUtility.php @@ -1,7 +1,7 @@ "boolean", "document_server_url" => "text", - "jwt_secret" => "text" + "jwt_secret" => "text", + "jwt_header" => "text", + "document_server_internal" => "text", + "storage_url" => "text" ] ); } @@ -95,4 +104,212 @@ public function uninstallHook() $viewObserver = OnlyofficeItemViewObserver::create(); HookDocumentItemView::create()->detach($viewObserver); } + + /** + * Get status of demo server + * + * @return bool + */ + public function useDemo() { + return $this->getDemoData()["enabled"] === true; + } + + /** + * Get demo data + * + * @return array + */ + public function getDemoData() { + $data = api_get_setting('onlyoffice_connect_demo_data')[0]; + + if (empty($data)) { + $data = [ + "available" => true, + "enabled" => false + ]; + api_add_setting(json_encode($data), 'onlyoffice_connect_demo_data', null, 'setting', 'Plugins'); + return $data; + } + $data = json_decode($data, true); + + if (isset($data['start'])) { + $overdue = $data["start"]; + $overdue += 24*60*60*AppConfig::GetDemoParams()["TRIAL"]; + if ($overdue > time()) { + $data["available"] = true; + $data["enabled"] = $data["enabled"] === true; + } else { + $data["available"] = false; + $data["enabled"] = false; + api_set_setting('onlyoffice_connect_demo_data', json_encode($data)); + } + } + return $data; + } + + /** + * Switch on demo server + * + * @param bool $value - select demo + * + * @return bool + */ + public function selectDemo($value) { + $data = $this->getDemoData(); + + if ($value === true && !$data["available"]) { + return false; + } + + $data["enabled"] = $value === true; + + if (!isset($data["start"])) { + $data["start"] = time(); + } + api_set_setting('onlyoffice_connect_demo_data', json_encode($data)); + return true; + } + + /** + * Get the document server url + * + * @param bool $origin - take origin + * + * @return string + */ + public function getDocumentServerUrl($origin = false) + { + if (!$origin && $this->useDemo()) { + return AppConfig::GetDemoParams()["ADDR"]; + } + + $url = $this->get("document_server_url"); + return self::processUrl($url); + } + + /** + * Get the document service address available from Chamilo from the application configuration + * + * @param bool $origin - take origin + * + * @return string + */ + public function getDocumentServerInternalUrl($origin = false) { + if (!$origin && $this->useDemo()) { + return $this->getDocumentServerUrl(); + } + + $url = $this->get('document_server_internal'); + if (empty($url)) { + $url = AppConfig::InternalUrl(); + } + if (!$origin && empty($url)) { + $url = $this->getDocumentServerUrl(); + } + return self::processUrl($url); + } + + /** + * Replace domain in document server url with internal address from configuration + * + * @param string $url - document server url + * + * @return string + */ + public function replaceDocumentServerUrlToInternal($url) { + $documentServerUrl = $this->getDocumentServerInternalUrl(); + if (!empty($documentServerUrl)) { + $from = $this->getDocumentServerUrl(); + + if (!preg_match("/^https?:\/\//i", $from)) { + $parsedUrl = parse_url($url); + $from = $parsedUrl["scheme"] . "://" . $parsedUrl["host"] . (array_key_exists("port", $parsedUrl) ? (":" . $parsedUrl["port"]) : "") . $from; + } + + if ($from !== $documentServerUrl) + { + $url = str_replace($from, $documentServerUrl, $url); + } + } + return $url; + } + + /** + * Get the Chamilo address available from document server from the application configuration + * + * @return string + */ + public function getStorageUrl() { + $storageUrl = $this->get('storage_url'); + if (empty($storageUrl)) { + $storageUrl = AppConfig::StorageUrl(); + } + return self::processUrl($storageUrl); + } + + /** + * Get the document service secret key from the application configuration + * + * @param bool $origin - take origin + * + * @return string + */ + public function getDocumentServerSecret($origin = false) { + if (!$origin && $this->useDemo()) { + return AppConfig::GetDemoParams()["SECRET"]; + } + return $this->get("jwt_secret"); + } + + /** + * Get the jwt header setting + * + * @param bool $origin - take origin + * + * @return string + */ + public function getJwtHeader($origin = false) { + if (!$origin && $this->useDemo()) { + return AppConfig::GetDemoParams()["HEADER"]; + } + + $header = $this->get('jwt_header'); + if (empty($header)) { + $header = AppConfig::JwtHeader() ? AppConfig::JwtHeader() : "Authorization"; + } + return $header; + } + + /** + * Get link to plugin settings + * + * @return string + */ + public function getConfigLink() { + return api_get_path(WEB_PATH)."main/admin/configure_plugin.php?name=".$this->pluginName; + } + + /** + * Get plugin name + * + * @return string + */ + public function getPluginName() { + return $this->pluginName; + } + + /** + * Add backslash to url if it's needed + * + * @return string + */ + public function processUrl($url) { + if ($url !== null && $url !== "/") { + $url = rtrim($url, "/"); + if (strlen($url) > 0) { + $url = $url . "/"; + } + } + return $url; + } } diff --git a/lib/onlyofficeSettingsFormBuilder.php b/lib/onlyofficeSettingsFormBuilder.php new file mode 100644 index 0000000..12b10cd --- /dev/null +++ b/lib/onlyofficeSettingsFormBuilder.php @@ -0,0 +1,145 @@ + $param) { + $tpl->assign($key, $param); + } + } + $parsedTemplate = $tpl->fetch(self::ONLYOFFICE_LAYOUT_DIR.$templateName.'.tpl'); + return $parsedTemplate; + } + + /** + * Display error messahe + * + * @param string $errorMessage - error message + * @param string $location - header location + * + * @return void + */ + private function displayError($errorMessage, $location = null) { + Display::addFlash( + Display::return_message( + $errorMessage, + 'error' + ) + ); + if ($location !== null) { + header('Location: '.$location); + exit; + } + } + + /** + * Build OnlyofficePlugin settings form + * + * @param OnlyofficePlugin $plugin - OnlyofficePlugin + * + * @return FormValidator + */ + public function buildSettingsForm($plugin) { + $demoData = $plugin->getDemoData(); + $plugin_info = $plugin->get_info(); + $message = ''; + $connectDemoCheckbox = $plugin_info['settings_form']->createElement( + 'checkbox', + 'connect_demo', + '', + $plugin->get_lang('connect_demo') + ); + if (!$demoData['available'] === true) { + $message = $plugin->get_lang('demoPeriodIsOver'); + $connectDemoCheckbox->setAttribute('disabled'); + } else { + if ($plugin->useDemo()) { + $message = $plugin->get_lang('demoUsingMessage'); + $connectDemoCheckbox->setChecked(true); + } else { + $message = $plugin->get_lang('demoPrevMessage'); + } + } + $demoServerMessageHtml = Display::return_message( + $message, + 'info' + ); + $bannerTemplate = self::buildTemplate('get_docs_cloud_banner', [ + 'docs_cloud_link' => AppConfig::GetLinkToDocs(), + 'banner_title' => $plugin->get_lang('DocsCloudBannerTitle'), + 'banner_main_text' => $plugin->get_lang('DocsCloudBannerMain'), + 'banner_button_text' => $plugin->get_lang('DocsCloudBannerButton'), + ]); + $plugin_info['settings_form']->insertElementBefore($connectDemoCheckbox, 'submit_button'); + $demoServerMessage = $plugin_info['settings_form']->createElement('html', $demoServerMessageHtml); + $plugin_info['settings_form']->insertElementBefore($demoServerMessage, 'submit_button'); + $banner = $plugin_info['settings_form']->createElement('html', $bannerTemplate); + $plugin_info['settings_form']->insertElementBefore($banner, 'submit_button'); + return $plugin_info['settings_form']; + } + + /** + * Validate OnlyofficePlugin settings form + * + * @param OnlyofficePlugin $plugin - OnlyofficePlugin + * + * @return OnlyofficePlugin + */ + public function validateSettingsForm($plugin) { + $errorMsg = null; + $plugin_info = $plugin->get_info(); + $result = $plugin_info['settings_form']->getSubmitValues(); + if (!$plugin->selectDemo((bool)$result['connect_demo'] === true)) { + $errorMsg = $plugin->get_lang('demoPeriodIsOver'); + self::displayError($errorMsg, $plugin->getConfigLink()); + } + $documentserver = $plugin->getDocumentServerUrl(); + if (!empty($documentserver)) { + if ((bool)$result['connect_demo'] === false) { + $documentService = new DocumentService($plugin, $result); + list ($error, $version) = $documentService->checkDocServiceUrl(); + + if (!empty($error)) { + $errorMsg = $plugin->get_lang('connectionError').'('.$error.')'.(!empty($version) ? '(Version '.$version.')' : ''); + self::displayError($errorMsg); + } + } + } + return $plugin; + } +} \ No newline at end of file diff --git a/lib/onlyofficeTools.php b/lib/onlyofficeTools.php index 2393dd9..16597df 100644 --- a/lib/onlyofficeTools.php +++ b/lib/onlyofficeTools.php @@ -1,7 +1,7 @@ "el-GR", "en" => "en-US", "es" => "es-ES", + "eu" => "eu-ES", "fr" => "fr-FR", + "gl" => "gl-ES", "it" => "it-IT", "ja" => "ja-JP", "ko" => "ko-KR", "lv" => "lv-LV", "nl" => "nl-NL", + "ms" => "ms-MY", "pl" => "pl-PL", "pt" => "pt-PT", "pt-BR" => "pt-BR", "ru" => "ru-RU", "sk" => "sk-SK", "sv" => "sv-SE", + "tr" => "tr-TR", "uk" => "uk-UA", "vi" => "vi-VN", - "zh" => "zh-CN" + "zh" => "zh-CN", + "zh-TW" => "zh-TW" ]; /** diff --git a/plugin.php b/plugin.php index 21ce375..becbf15 100644 --- a/plugin.php +++ b/plugin.php @@ -1,7 +1,7 @@ get_info(); \ No newline at end of file +$plugin = OnlyofficePlugin::create(); +$plugin_info = $plugin->get_info(); +$plugin_info['settings_form'] = OnlyofficeSettingsFormBuilder::buildSettingsForm($plugin); +if ($plugin_info['settings_form']->validate()) { + $plugin = OnlyofficeSettingsFormBuilder::validateSettingsForm($plugin); +} \ No newline at end of file diff --git a/resources/css/docsCloudBanner.css b/resources/css/docsCloudBanner.css new file mode 100644 index 0000000..957e563 --- /dev/null +++ b/resources/css/docsCloudBanner.css @@ -0,0 +1,94 @@ +#onlyofficeDocsCloudBannerWrapper { + max-width: 596px; + min-height: 108px; + text-align: center; + background-image: url("/plugin/onlyoffice/resources/get-editors-background.svg"); + background-repeat: no-repeat; + background-color: #FFFFFF; + border: 1px solid #EFEFEF; + border-radius: 3px; + display: flex; + flex-direction: row; + margin-bottom: 10px; +} + +#onlyofficeDocsCloudBannerPicWrapper { + height: 100%; + width: 20%; + padding: 10px 0 0 20px; +} + +#onlyofficeDocsCloudBannerPic { + content: url("/plugin/onlyoffice/resources/get-editors-pic.svg"); +} + +#onlyofficeDocsCloudBannerContent { + height: 100%; + width: 80%; + color: #000000; + display: flex; + flex-direction: row; + margin: auto; +} + +#onlyofficeDocsCloudBannerContentText { + width: 60%; + height: 100%; + color: #000; + font-size: 14px; + text-align: justify; + padding-left: 15px; +} + +#onlyofficeDocsCloudBannerContentText h2 { + margin: 0; + color: #000000; +} + +#onlyofficeDocsCloudBannerContentButtonWrapper { + width: 40%; + height: 100%; + margin: auto; +} + +#onlyofficeDocsCloudBannerContentButton { + background-color: #EDEDED; + color: #000; + border: 1px solid #DBDBDB; +} + +@media (max-width: 580px) { + #onlyofficeDocsCloudBannerPicWrapper { + padding: 20px 0 0 5px; + width: 30%; + } + #onlyofficeDocsCloudBannerContent { + flex-direction: column; + width: 70%; + } + #onlyofficeDocsCloudBannerContentText { + width: 100%; + padding-right: 5px; + } + #onlyofficeDocsCloudBannerContentButtonWrapper { + width: 100%; + padding: 15px 0 15px 15px; + text-align: left; + } + #onlyofficeDocsCloudBannerContent { + flex-direction: column; + } +} + +@media (max-width: 335px) { + #onlyofficeDocsCloudBannerWrapper { + flex-direction: column; + } + #onlyofficeDocsCloudBannerPicWrapper { + width: 100%; + padding: unset; + } + #onlyofficeDocsCloudBannerContent { + width: 100%; + } +} \ No newline at end of file diff --git a/resources/get-editors-background.svg b/resources/get-editors-background.svg new file mode 100644 index 0000000..ff6f91d --- /dev/null +++ b/resources/get-editors-background.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/resources/get-editors-pic.svg b/resources/get-editors-pic.svg new file mode 100644 index 0000000..939d3d2 --- /dev/null +++ b/resources/get-editors-pic.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/uninstall.php b/uninstall.php index 2426322..d031793 100644 --- a/uninstall.php +++ b/uninstall.php @@ -1,7 +1,7 @@