generated from Nodarx/template
Add WOPI discovery service and connection diagnostics
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user