$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); } } }