Add WOPI discovery service and connection diagnostics

This commit is contained in:
Sebastian Krupinski
2026-09-06 21:05:47 -04:00
committed by Sebastian Krupinski
parent 069f7068c8
commit 661e0e1213
14 changed files with 1238 additions and 2 deletions
+16
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
# template
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
// Usable before module installation/configuration; does not boot the application.
$root = dirname(__DIR__, 3);
$loader = require $root . '/vendor/autoload.php';
$loader->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());
+18
View File
@@ -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
}
}
Generated
+759
View File
@@ -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"
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace KTXM\ServiceWopi\Console;
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
use KTXM\ServiceWopi\Discovery\ServerAddress;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(name: 'wopi:check', description: 'Check document server discovery without sending documents or credentials')]
class CheckCommand extends Command
{
public function __construct(private readonly DiscoveryClient $client) { parent::__construct(); }
protected function configure(): void
{
$this->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;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace KTXM\ServiceWopi\Discovery;
final readonly class Action
{
/** @param list<string> $requirements */
public function __construct(
public string $extension,
public string $name,
public string $url,
public array $requirements,
) {}
/** @param list<string> $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;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace KTXM\ServiceWopi\Discovery;
use RuntimeException;
class DiscoveryClient
{
public function fetch(ServerAddress $server): DiscoveryDocument
{
$curl = curl_init($server->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);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KTXM\ServiceWopi\Discovery;
use DOMDocument;
use DOMElement;
use DOMXPath;
use RuntimeException;
final readonly class DiscoveryDocument
{
public const MAX_BYTES = 2 * 1024 * 1024;
/** @param list<Action> $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('/<!\s*(DOCTYPE|ENTITY)/i', $xml)) {
throw new RuntimeException('Discovery XML is empty, oversized, or contains a forbidden document type/entity.');
}
$previous = libxml_use_internal_errors(true);
try {
$document = new DOMDocument();
if (!$document->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);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace KTXM\ServiceWopi\Discovery;
use InvalidArgumentException;
final readonly class ServerAddress
{
public string $url;
public string $origin;
public function __construct(string $url)
{
$parts = parse_url($url);
if ($parts === false || strtolower($parts['scheme'] ?? '') !== 'https'
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|| isset($parts['query']) || isset($parts['fragment'])
|| preg_match('/[\x00-\x20\x7f\\\\]/', $url)
|| filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new InvalidArgumentException('Use an HTTPS server URL without credentials, query parameters, or a fragment.');
}
$this->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.');
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace KTXM\ServiceWopi;
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract;
use KTXM\ServiceWopi\Console\CheckCommand;
class Module extends ModuleInstanceAbstract
{
public function handle(): string { return 'service_wopi'; }
public function label(): string { return 'WOPI Service'; }
public function author(): string { return 'Ktrix'; }
public function version(): string { return '0.0.1'; }
public function description(): string { return 'WOPI discovery and connection diagnostics'; }
public function configure(ModuleContextInterface $context): void
{
if ($context instanceof ConsoleModuleContextInterface) {
$context->registerCommand(CheckCommand::class);
}
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
use KTXM\ServiceWopi\Console\CheckCommand;
use KTXM\ServiceWopi\Discovery\Action;
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
use KTXM\ServiceWopi\Discovery\DiscoveryDocument;
use KTXM\ServiceWopi\Discovery\ServerAddress;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class DiscoveryTest extends TestCase
{
private function xml(string $action = 'view', string $url = 'https://office.test/editor?', string $requires = ''): string
{
return '<wopi-discovery><net-zone name="external-https"><app name="Word">'
. '<action ext="docx" name="' . $action . '" requires="' . $requires
. '" urlsrc="' . htmlspecialchars($url, ENT_XML1 | ENT_QUOTES) . '"/>'
. '</app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>';
}
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?<ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&',
));
$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 [
[''], ['<html><body>Welcome</body></html>'], ['<wopi-discovery>'],
['<!DOCTYPE wopi-discovery [<!ENTITY x SYSTEM "file:///etc/passwd">]><wopi-discovery>&x;</wopi-discovery>'],
['<wopi-discovery><net-zone name="external-http"><app/></net-zone></wopi-discovery>'],
[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?<ui=UI_LLCC&><future=UNKNOWN&>'],
['https://office.test/editor?<WOPISrc=WOPI_SOURCE&><ui=UI_LLCC&>'],
];
}
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']));
}
}
+4
View File
@@ -0,0 +1,4 @@
<?php
$loader = require dirname(__DIR__, 4) . '/vendor/autoload.php';
$loader->addPsr4('KTXM\\ServiceWopi\\', dirname(__DIR__, 2) . '/lib/');
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="bootstrap.php" failOnWarning="true" failOnNotice="true" failOnDeprecation="true" cacheDirectory="/tmp/ktrix-wopi-phpunit">
<testsuites><testsuite name="WOPI"><directory>Unit</directory></testsuite></testsuites>
</phpunit>