diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e32b35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Installed dependencies +vendor/ + +# Local configuration and generated files +.env +.env.* +*.local +*.cache +.phpunit.cache/ +coverage/ +*.log + +# Editor settings +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md deleted file mode 100644 index 5b42e54..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# template - diff --git a/bin/check b/bin/check new file mode 100644 index 0000000..dc1c5e9 --- /dev/null +++ b/bin/check @@ -0,0 +1,13 @@ +#!/usr/bin/env php +addPsr4('KTXM\\ServiceWopi\\', dirname(__DIR__) . '/lib/'); +$application = new Symfony\Component\Console\Application('WOPI connection check'); +$application->addCommand(new KTXM\ServiceWopi\Console\CheckCommand(new KTXM\ServiceWopi\Discovery\DiscoveryClient())); +$application->setDefaultCommand('wopi:check', true); +exit($application->run()); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..dd790cc --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "ktrix/service-wopi", + "description": "WOPI document server integration for Ktrix", + "type": "library", + "license": "AGPL-3.0-or-later", + "require": { + "php": ">=8.3", + "ext-curl": "*", + "ext-dom": "*", + "ext-libxml": "*", + "symfony/console": "^7.0" + }, + "config": { + "vendor-dir": "lib/vendor", + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..812e237 --- /dev/null +++ b/composer.lock @@ -0,0 +1,759 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "fc0fe7b30fb89947e64b3b462ddfcca8", + "packages": [ + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.3", + "ext-curl": "*", + "ext-dom": "*", + "ext-libxml": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/lib/Console/CheckCommand.php b/lib/Console/CheckCommand.php new file mode 100644 index 0000000..6ec873a --- /dev/null +++ b/lib/Console/CheckCommand.php @@ -0,0 +1,53 @@ +addArgument('server', InputArgument::REQUIRED, 'HTTPS base URL of the document server'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + try { + $document = $this->client->fetch(new ServerAddress($input->getArgument('server'))); + $rows = []; + $hasDocxView = false; + foreach ($document->actions as $action) { + $rows[] = [$action->extension, $action->name, implode(', ', $action->requirements) ?: 'none']; + $hasDocxView = $hasDocxView || ($action->extension === 'docx' + && $action->name === 'view' && $action->supportedBy([])); + } + $io->table(['Extension', 'Server action', 'Additional host requirements'], $rows); + $io->text('Proof key metadata: ' . ($document->hasProofKeys ? 'present (not yet validated)' : 'absent')); + if (!$hasDocxView) { + $io->error('No DOCX view action usable by a basic read-only host.'); + return Command::FAILURE; + } + $io->success('Discovery supports the planned DOCX viewing flow.'); + $io->note('This check does not verify editor loading, WOPI callbacks, proof signatures, or saving.'); + return Command::SUCCESS; + } catch (Throwable $error) { + $io->error($error->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/lib/Discovery/Action.php b/lib/Discovery/Action.php new file mode 100644 index 0000000..f8e2a0e --- /dev/null +++ b/lib/Discovery/Action.php @@ -0,0 +1,42 @@ + $requirements */ + public function __construct( + public string $extension, + public string $name, + public string $url, + public array $requirements, + ) {} + + /** @param list $capabilities */ + public function supportedBy(array $capabilities): bool + { + $required = $this->requirements; + if (in_array($this->name, ['edit', 'editnew', 'convert'], true)) { + $required = array_merge($required, ['locks', 'update']); + } + return array_diff($required, $capabilities) === []; + } + + public function launchUrl(string $wopiSource): string + { + new ServerAddress($wopiSource); + $url = preg_replace_callback('/<([^=<>]+)=([^<>]*?)(\&?)>/', + static fn (array $match): string => $match[2] === 'WOPI_SOURCE' + ? $match[1] . '=' . rawurlencode($wopiSource) . $match[3] : '', + $this->url, + ); + $query = parse_url($url, PHP_URL_QUERY) ?? ''; + if (!preg_match('/(?:^|&)WOPISrc=/i', $query)) { + $url = rtrim($url, '?&'); + $url .= (str_contains($url, '?') ? '&' : '?') . 'WOPISrc=' . rawurlencode($wopiSource); + } + return $url; + } +} diff --git a/lib/Discovery/DiscoveryClient.php b/lib/Discovery/DiscoveryClient.php new file mode 100644 index 0000000..40b67b3 --- /dev/null +++ b/lib/Discovery/DiscoveryClient.php @@ -0,0 +1,44 @@ +discoveryUrl()); + $xml = ''; + curl_setopt_array($curl, [ + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => ['Accept: application/xml, text/xml'], + CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$xml): int { + if (strlen($xml) + strlen($chunk) > DiscoveryDocument::MAX_BYTES) { return 0; } + $xml .= $chunk; + return strlen($chunk); + }, + ]); + try { + $ok = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + if ($ok === false) { + throw new RuntimeException('Discovery request failed (cURL ' . curl_errno($curl) . '). Check DNS, TLS, connectivity, and response size.'); + } + if ($status !== 200) { + throw new RuntimeException('Discovery returned HTTP ' . $status + . ($status === 404 ? '. Enable WOPI and forward /hosting/discovery through the proxy.' : '. Expected HTTP 200; redirects are not followed.')); + } + return DiscoveryDocument::parse($xml, $server); + } finally { + curl_close($curl); + } + } +} diff --git a/lib/Discovery/DiscoveryDocument.php b/lib/Discovery/DiscoveryDocument.php new file mode 100644 index 0000000..4e0e55f --- /dev/null +++ b/lib/Discovery/DiscoveryDocument.php @@ -0,0 +1,62 @@ + $actions */ + private function __construct(public array $actions, public bool $hasProofKeys) {} + + public static function parse(string $xml, ServerAddress $server): self + { + if ($xml === '' || strlen($xml) > self::MAX_BYTES || preg_match('/loadXML($xml, LIBXML_NONET | LIBXML_NOBLANKS) + || $document->doctype !== null || $document->documentElement?->nodeName !== 'wopi-discovery') { + throw new RuntimeException('The server did not return valid WOPI discovery XML.'); + } + $xpath = new DOMXPath($document); + $nodes = $xpath->query('/wopi-discovery/net-zone[@name="external-https"]/app/action'); + // Euro-Office behind TLS termination can retain this zone label while + // advertising HTTPS action URLs. Validate every URL independently. + if ($nodes->length === 0) { + $nodes = $xpath->query('/wopi-discovery/net-zone[@name="external-http"]/app/action'); + } + $actions = []; + foreach ($nodes as $node) { + if (!$node instanceof DOMElement) { continue; } + $name = $node->getAttribute('name'); + $extension = strtolower($node->getAttribute('ext')); + if (!in_array($name, ['view', 'edit'], true) || !preg_match('/^[a-z0-9]+$/D', $extension)) { + continue; + } + $url = $node->getAttribute('urlsrc'); + $server->assertTrustedAction($url); + $requirements = preg_split('/\s*,\s*/', trim($node->getAttribute('requires')), -1, PREG_SPLIT_NO_EMPTY); + $actions[] = new Action($extension, $name, $url, $requirements); + } + if ($actions === []) { + throw new RuntimeException('Discovery has no external view/edit actions. Check WOPI enablement and forwarded HTTPS headers.'); + } + $proof = $xpath->query('/wopi-discovery/proof-key')->item(0); + return new self($actions, $proof instanceof DOMElement + && $proof->getAttribute('modulus') !== '' && $proof->getAttribute('exponent') !== ''); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } +} diff --git a/lib/Discovery/ServerAddress.php b/lib/Discovery/ServerAddress.php new file mode 100644 index 0000000..2578b38 --- /dev/null +++ b/lib/Discovery/ServerAddress.php @@ -0,0 +1,46 @@ +origin = 'https://' . strtolower($parts['host']) + . (isset($parts['port']) && $parts['port'] !== 443 ? ':' . $parts['port'] : ''); + $this->url = $this->origin . rtrim($parts['path'] ?? '', '/'); + } + + public function discoveryUrl(): string { return $this->url . '/hosting/discovery'; } + + public function assertTrustedAction(string $url): void + { + $plain = preg_replace('/<[^<>]*>/', '', $url); + $parts = parse_url($plain); + if ($parts === false || isset($parts['user']) || isset($parts['pass']) + || isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f\\\\<>]/', $plain) + || filter_var($plain, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException('Discovery contains an invalid action URL.'); + } + $origin = strtolower($parts['scheme'] ?? '') . '://' . strtolower($parts['host'] ?? '') + . (isset($parts['port']) && $parts['port'] !== 443 ? ':' . $parts['port'] : ''); + if ($origin !== $this->origin) { + throw new InvalidArgumentException('Discovery action URL is outside the configured HTTPS server origin.'); + } + } +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..f0d9721 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,26 @@ +registerCommand(CheckCommand::class); + } + } +} diff --git a/tests/php/Unit/DiscoveryTest.php b/tests/php/Unit/DiscoveryTest.php new file mode 100644 index 0000000..196ea42 --- /dev/null +++ b/tests/php/Unit/DiscoveryTest.php @@ -0,0 +1,151 @@ +' + . '' + . ''; + } + + public function testParsesActionsAndDoesNotPermitEditingWithoutHostCapabilities(): void + { + $server = new ServerAddress('https://OFFICE.test:443/'); + self::assertSame('https://office.test/hosting/discovery', $server->discoveryUrl()); + $document = DiscoveryDocument::parse($this->xml(), $server); + self::assertTrue($document->hasProofKeys); + self::assertTrue($document->actions[0]->supportedBy([])); + $edit = DiscoveryDocument::parse($this->xml('edit'), $server)->actions[0]; + self::assertFalse($edit->supportedBy([])); + self::assertFalse($edit->supportedBy(['locks'])); + self::assertTrue($edit->supportedBy(['locks', 'update'])); + $extra = DiscoveryDocument::parse($this->xml('view', requires: ' containers, future '), $server)->actions[0]; + self::assertFalse($extra->supportedBy([])); + } + + public function testAcceptsEuroOfficeExternalHttpLabelOnlyWithTrustedHttpsUrls(): void + { + $xml = str_replace('external-https', 'external-http', $this->xml( + url: 'https://office.test/editor?&', + )); + $document = DiscoveryDocument::parse($xml, new ServerAddress('https://office.test')); + $source = 'https://app.test/wopi/files/123'; + parse_str(parse_url($document->actions[0]->launchUrl($source), PHP_URL_QUERY), $parameters); + self::assertSame(['wopisrc' => $source], $parameters); + + $this->expectException(InvalidArgumentException::class); + DiscoveryDocument::parse(str_replace('https://office.test', 'http://office.test', $xml), new ServerAddress('https://office.test')); + } + + #[DataProvider('invalidServers')] + public function testRejectsInvalidServerAddresses(string $url): void + { + $this->expectException(InvalidArgumentException::class); + new ServerAddress($url); + } + + public static function invalidServers(): array + { + return array_map(static fn ($url) => [$url], [ + 'http://office.test', 'file:///etc/passwd', 'https://user:pass@office.test', + 'https://office.test?secret=x', 'https://office.test/#fragment', + "https://office.test/\r\n", 'https://office.test\\@other.test', + ]); + } + + #[DataProvider('invalidDocuments')] + public function testRejectsInvalidDiscovery(string $xml): void + { + $this->expectException(RuntimeException::class); + DiscoveryDocument::parse($xml, new ServerAddress('https://office.test')); + } + + public static function invalidDocuments(): array + { + return [ + [''], ['Welcome'], [''], + [']>&x;'], + [''], + [str_repeat('x', DiscoveryDocument::MAX_BYTES + 1)], + ]; + } + + #[DataProvider('untrustedActions')] + public function testRejectsUntrustedActions(string $url): void + { + $this->expectException(InvalidArgumentException::class); + DiscoveryDocument::parse($this->xml(url: $url), new ServerAddress('https://office.test')); + } + + public static function untrustedActions(): array + { + return array_map(static fn ($url) => [$url], [ + 'http://office.test/editor', 'https://evil.test/editor', + 'https://office.test.evil.test/editor', 'https://office.test:8443/editor', + 'https://user@office.test/editor', '//office.test/editor', + 'javascript:alert(1)', 'https://office.test/editor#fragment', + ]); + } + + #[DataProvider('launchTemplates')] + public function testBuildsLaunchUrlWithoutTokenAndRemovesUnknownPlaceholders(string $template): void + { + $action = DiscoveryDocument::parse($this->xml(url: $template), new ServerAddress('https://office.test'))->actions[0]; + $source = 'https://app.test/m/service_wopi/files/opaque-id'; + $url = $action->launchUrl($source); + self::assertStringNotContainsString('<', $url); + self::assertStringNotContainsString('access_token', $url); + parse_str(parse_url($url, PHP_URL_QUERY), $parameters); + self::assertSame($source, $parameters['WOPISrc']); + self::assertCount(1, $parameters); + } + + public static function launchTemplates(): array + { + return [ + ['https://office.test/editor'], + ['https://office.test/editor?'], + ['https://office.test/editor?'], + ['https://office.test/editor?'], + ]; + } + + public function testCommandReports404WithoutClaimingConnectionSuccess(): void + { + $client = $this->createStub(DiscoveryClient::class); + $client->method('fetch')->willThrowException(new RuntimeException('Discovery returned HTTP 404.')); + $command = new CommandTester(new CheckCommand($client)); + self::assertSame(1, $command->execute(['server' => 'https://office.test'])); + self::assertStringContainsString('HTTP 404', $command->getDisplay()); + } + + public function testCommandQualifiesViewingOnly(): void + { + $client = $this->createStub(DiscoveryClient::class); + $client->method('fetch')->willReturn(DiscoveryDocument::parse($this->xml(), new ServerAddress('https://office.test'))); + $command = new CommandTester(new CheckCommand($client)); + self::assertSame(0, $command->execute(['server' => 'https://office.test'])); + self::assertStringContainsString('does not verify', $command->getDisplay()); + } + + public function testEditOnlyDiscoveryDoesNotPassReadOnlyQualification(): void + { + $client = $this->createStub(DiscoveryClient::class); + $client->method('fetch')->willReturn(DiscoveryDocument::parse($this->xml('edit'), new ServerAddress('https://office.test'))); + $command = new CommandTester(new CheckCommand($client)); + self::assertSame(1, $command->execute(['server' => 'https://office.test'])); + } +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..245108c --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,4 @@ +addPsr4('KTXM\\ServiceWopi\\', dirname(__DIR__, 2) . '/lib/'); diff --git a/tests/php/phpunit.xml b/tests/php/phpunit.xml new file mode 100644 index 0000000..63fec40 --- /dev/null +++ b/tests/php/phpunit.xml @@ -0,0 +1,4 @@ + + + Unit +