From 92e85aed4715df261f8d824780e0c649ce495e65 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 7 Sep 2026 21:58:35 -0400 Subject: [PATCH] feat: implement initial read only access Signed-off-by: Sebastian Krupinski --- lib/Console/CleanupCommand.php | 21 ++++ lib/Console/ConfigureCommand.php | 83 +++++++++++++ lib/Controllers/OfficeController.php | 51 ++++++++ lib/Controllers/WopiController.php | 67 ++++++++++ lib/Discovery/DiscoveryClient.php | 7 +- lib/Discovery/DiscoveryDocument.php | 10 +- lib/Host/Configuration.php | 32 +++++ lib/Host/Documents.php | 57 +++++++++ lib/Host/HostException.php | 11 ++ lib/Host/ProofValidator.php | 38 ++++++ lib/Host/SessionStore.php | 64 ++++++++++ lib/Host/WopiService.php | 123 ++++++++++++++++++ lib/Module.php | 11 +- tests/php/Unit/ConfigureCommandTest.php | 89 +++++++++++++ tests/php/Unit/HostTest.php | 159 ++++++++++++++++++++++++ tests/php/Unit/ProofTest.php | 42 +++++++ tests/php/bootstrap.php | 2 + 17 files changed, 863 insertions(+), 4 deletions(-) create mode 100644 lib/Console/CleanupCommand.php create mode 100644 lib/Console/ConfigureCommand.php create mode 100644 lib/Controllers/OfficeController.php create mode 100644 lib/Controllers/WopiController.php create mode 100644 lib/Host/Configuration.php create mode 100644 lib/Host/Documents.php create mode 100644 lib/Host/HostException.php create mode 100644 lib/Host/ProofValidator.php create mode 100644 lib/Host/SessionStore.php create mode 100644 lib/Host/WopiService.php create mode 100644 tests/php/Unit/ConfigureCommandTest.php create mode 100644 tests/php/Unit/HostTest.php create mode 100644 tests/php/Unit/ProofTest.php diff --git a/lib/Console/CleanupCommand.php b/lib/Console/CleanupCommand.php new file mode 100644 index 0000000..d28ebe5 --- /dev/null +++ b/lib/Console/CleanupCommand.php @@ -0,0 +1,21 @@ +store->cleanup(time()); + $output->writeln('Expired WOPI records removed.'); + return Command::SUCCESS; + } +} diff --git a/lib/Console/ConfigureCommand.php b/lib/Console/ConfigureCommand.php new file mode 100644 index 0000000..21a51ff --- /dev/null +++ b/lib/Console/ConfigureCommand.php @@ -0,0 +1,83 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier or domain') + ->addArgument('office-server', InputArgument::REQUIRED, 'Office server HTTPS base URL') + ->addOption('origin-server', null, InputOption::VALUE_REQUIRED, 'Application HTTPS origin reachable from the office server (defaults to the tenant domain)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + try { + $tenantArgument = $input->getArgument('tenant'); + $tenant = $this->tenants->fetchById($tenantArgument); + $matchedByDomain = $tenant === null; + $tenant ??= $this->tenants->fetchByDomain($tenantArgument); + if ($tenant === null || !$tenant->getEnabled()) { + $io->error('An enabled tenant is required.'); + return Command::FAILURE; + } + $origin = $input->getOption('origin-server'); + if ($origin === null) { + $domains = array_values(array_unique($tenant->getDomains()?->getArrayCopy() ?? [])); + if ($matchedByDomain) { + $origin = 'https://' . $tenantArgument; + } elseif (count($domains) === 1) { + $origin = 'https://' . $domains[0]; + } else { + $io->error('Specify --origin-server or select the tenant by domain; its origin server cannot be determined unambiguously.'); + return Command::FAILURE; + } + } + $wopi = $this->tenants->fetchServiceConfiguration($tenant->getIdentifier(), 'wopi') ?? []; + unset($wopi['origin_server']); + $wopi = array_replace($wopi, ['enabled' => true, 'office_server' => $input->getArgument('office-server'), 'origin_server' => $origin]); + $config = new Configuration($wopi); + $document = $this->discovery->fetch($config->office); + $viewable = array_filter($document->actions, static fn ($action) => $action->extension === 'docx' && $action->name === 'view' && $action->supportedBy([])); + if (!$document->hasProofKeys || $viewable === []) { + $io->error('The office server must advertise DOCX viewing and proof keys. Settings were not changed.'); + return Command::FAILURE; + } + $wopi['office_server'] = $config->office->url; + $wopi['origin_server'] = $config->origin->origin; + if ($input->getOption('origin-server') === null && count(array_unique($tenant->getDomains()?->getArrayCopy() ?? [])) === 1) { + unset($wopi['origin_server']); + } + // Preserve the selected origin when a tenant has multiple aliases. + if (!$this->tenants->storeServiceConfiguration($tenant->getIdentifier(), 'wopi', $wopi)) { + $io->error('Could not save tenant office settings.'); + return Command::FAILURE; + } + $io->success('Office viewing configured in the tenant database. Reload the application to register the viewer.'); + $io->note('The office server must resolve the application hostname and trust its HTTPS certificate. Discovery does not test that return connection.'); + return Command::SUCCESS; + } catch (\Throwable) { + $io->error('Configuration failed. Check the tenant, HTTPS URLs, and office discovery.'); + return Command::FAILURE; + } + } +} diff --git a/lib/Controllers/OfficeController.php b/lib/Controllers/OfficeController.php new file mode 100644 index 0000000..b033c32 --- /dev/null +++ b/lib/Controllers/OfficeController.php @@ -0,0 +1,51 @@ +respond(fn () => $this->wopi->capabilities($this->tenant->requireIdentifier(), $this->identity->requireIdentifier())); + } + + #[AuthenticatedRoute('/sessions', name: 'wopi.sessions', methods: ['POST'], permissions: ['service_wopi.view'])] + public function launch(Request $request): JsonResponse + { + return $this->respond(function () use ($request): array { + $tenant = $this->tenant->requireIdentifier(); + $config = $this->wopi->configuration($tenant); + // Require same-origin browser JSON POST in addition to framework authentication. + if ($request->headers->get('Origin') !== $config->origin->origin + || !str_starts_with(strtolower($request->headers->get('Content-Type', '')), 'application/json')) { + throw new HostException('Invalid launch origin or content type.', 403); + } + $data = json_decode($request->getContent(), true, 32, JSON_THROW_ON_ERROR); + $resource = $data['resource'] ?? null; + if (!is_string($resource) || $resource === '' || strlen($resource) > 2048) { + throw new HostException('A document resource is required.'); + } + return $this->wopi->launch($tenant, $this->identity->requireIdentifier(), $resource); + }); + } + + private function respond(callable $operation): JsonResponse + { + try { return new JsonResponse($operation(), 200, ['Cache-Control' => 'no-store']); } + catch (HostException $error) { return new JsonResponse(['message' => $error->getMessage()], $error->status, ['Cache-Control' => 'no-store']); } + catch (\JsonException) { return new JsonResponse(['message' => 'Invalid JSON request.'], 400, ['Cache-Control' => 'no-store']); } + catch (\Throwable) { return new JsonResponse(['message' => 'Office viewing is temporarily unavailable.'], 503, ['Cache-Control' => 'no-store']); } + } +} diff --git a/lib/Controllers/WopiController.php b/lib/Controllers/WopiController.php new file mode 100644 index 0000000..8a50e68 --- /dev/null +++ b/lib/Controllers/WopiController.php @@ -0,0 +1,67 @@ + 'no-store', 'X-Content-Type-Options' => 'nosniff']; + + #[AnonymousPrefixRoute('/wopi/files/', name: 'wopi.unsupported', methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])] + public function unsupported(Request $request): Response + { + return $request->getMethod() === 'GET' + ? new Response('', 404, self::HEADERS) + : new Response('', 405, self::HEADERS + ['Allow' => 'GET']); + } + + #[AnonymousRoute('/wopi/files/{id}', name: 'wopi.retrieve_meta')] + public function retrieveMeta(Request $request, string $id): Response + { + return $this->respond($request, $id, static function (array $session, array $file, array $headers): Response { + return new JsonResponse([ + 'BaseFileName' => $file['label'], 'Size' => $file['size'], 'Version' => $file['version'], + 'OwnerId' => $session['user'], 'UserId' => $session['user'], + 'ReadOnly' => true, 'UserCanWrite' => false, 'SupportsUpdate' => false, + 'SupportsLocks' => false, 'UserCanNotWriteRelative' => true, + ], 200, $headers); + }); + } + + #[AnonymousRoute('/wopi/files/{id}/contents', name: 'wopi.retrieve_contents')] + public function retrieveContents(Request $request, string $id): Response + { + return $this->respond($request, $id, static fn (array $session, array $file, array $headers): Response => + new Response($file['content'], 200, $headers + ['Content-Type' => 'application/octet-stream', 'Content-Length' => (string) $file['size']]) + ); + } + + private function respond(Request $request, string $id, callable $response): Response + { + $headers = self::HEADERS; + if ($request->getMethod() !== 'GET') { return $this->unsupported($request); } + if (!preg_match('/^[a-f0-9]{32}$/D', $id)) { return new Response('', 404, $headers); } + try { + if (!$this->tenant->enabled()) { throw new HostException('Document access is unavailable.', 404); } + ['session' => $session, 'file' => $file] = $this->wopi->access($this->tenant->requireIdentifier(), $id, $request); + $headers['X-WOPI-ItemVersion'] = $file['version']; + return $response($session, $file, $headers); + } catch (HostException $error) { + return new JsonResponse(['message' => $error->getMessage()], $error->status, $headers); + } catch (\Throwable) { + // Never log the request, query string, file contents, or access token. + return new JsonResponse(['message' => 'Document access is unavailable.'], 503, $headers); + } + } +} diff --git a/lib/Discovery/DiscoveryClient.php b/lib/Discovery/DiscoveryClient.php index 40b67b3..dff3519 100644 --- a/lib/Discovery/DiscoveryClient.php +++ b/lib/Discovery/DiscoveryClient.php @@ -9,6 +9,11 @@ use RuntimeException; class DiscoveryClient { public function fetch(ServerAddress $server): DiscoveryDocument + { + return DiscoveryDocument::parse($this->fetchXml($server), $server); + } + + public function fetchXml(ServerAddress $server): string { $curl = curl_init($server->discoveryUrl()); $xml = ''; @@ -36,7 +41,7 @@ class DiscoveryClient 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); + return $xml; } finally { curl_close($curl); } diff --git a/lib/Discovery/DiscoveryDocument.php b/lib/Discovery/DiscoveryDocument.php index 4e0e55f..d173540 100644 --- a/lib/Discovery/DiscoveryDocument.php +++ b/lib/Discovery/DiscoveryDocument.php @@ -14,7 +14,7 @@ final readonly class DiscoveryDocument public const MAX_BYTES = 2 * 1024 * 1024; /** @param list $actions */ - private function __construct(public array $actions, public bool $hasProofKeys) {} + private function __construct(public array $actions, public bool $hasProofKeys, public array $proofKeys = []) {} public static function parse(string $xml, ServerAddress $server): self { @@ -52,8 +52,14 @@ final readonly class DiscoveryDocument 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); + $keys = []; + if ($proof instanceof DOMElement) { + foreach (['modulus', 'exponent', 'oldmodulus', 'oldexponent'] as $attribute) { + $keys[$attribute] = $proof->getAttribute($attribute); + } + } return new self($actions, $proof instanceof DOMElement - && $proof->getAttribute('modulus') !== '' && $proof->getAttribute('exponent') !== ''); + && $proof->getAttribute('modulus') !== '' && $proof->getAttribute('exponent') !== '', $keys); } finally { libxml_clear_errors(); libxml_use_internal_errors($previous); diff --git a/lib/Host/Configuration.php b/lib/Host/Configuration.php new file mode 100644 index 0000000..f5a838f --- /dev/null +++ b/lib/Host/Configuration.php @@ -0,0 +1,32 @@ +office = new ServerAddress($settings['office_server'] ?? ''); + $this->origin = new ServerAddress($settings['origin_server'] ?? (count(array_unique($domains)) === 1 ? 'https://' . reset($domains) : '')); + } catch (\InvalidArgumentException|\TypeError $error) { + throw new HostException('Office viewing requires valid HTTPS server and application URLs.', 503); + } + // WOPI routes are mounted under /m/service_wopi, so the origin must be a bare HTTPS origin the route path can be appended to. + if ($this->origin->url !== $this->origin->origin) { + throw new HostException('The WOPI origin server URL must be an HTTPS origin without a path.', 503); + } + $this->lifetime = max(300, min(14400, (int) ($settings['token_lifetime'] ?? 3600))); + $this->maxBytes = max(1, min(32 * 1024 * 1024, (int) ($settings['max_bytes'] ?? 16 * 1024 * 1024))); + } +} diff --git a/lib/Host/Documents.php b/lib/Host/Documents.php new file mode 100644 index 0000000..4b46e8e --- /dev/null +++ b/lib/Host/Documents.php @@ -0,0 +1,57 @@ +users->fetchByIdentifier($tenant, $user); + if (!$this->tenants->fetchById($tenant)?->getEnabled() || !($account['enabled'] ?? false)) { + throw new HostException('Document access is unavailable.', 404); + } + $roles = (array) ($account['roles'] ?? []); + $permissions = (array) ($account['permissions'] ?? []); + if (!array_intersect(['admin', 'system.admin'], $roles) + && !array_intersect(['*', 'service_wopi.*', 'service_wopi.view'], $permissions)) { + throw new HostException('Document access is unavailable.', 404); + } + } + + /** @return array{label: string, content: string, version: string, size: int} */ + public function read(string $tenant, string $user, string $resource, int $maxBytes): array + { + $this->authorize($tenant, $user); + $id = ResourceIdentifier::fromString($resource); + if (!$id instanceof EntityIdentifier || $id->provider() !== 'default' || $id->service() !== 'personal') { + throw new HostException('This storage provider is not available for office viewing.', 404); + } + $service = $this->manager->serviceFetch($tenant, $user, $id->provider(), $id->service()); + if (!$service instanceof PersonalService || !$service->getEnabled()) { + throw new HostException('Document access is unavailable.', 404); + } + $entity = $this->manager->entityFetchBulk($tenant, $user, $id)[$resource] ?? null; + if ($entity === null) { throw new HostException('Document access is unavailable.', 404); } + if ($entity->getProperties()->size() > $maxBytes) { throw new HostException('Document exceeds the office viewing size limit.', 413); } + $stream = $service->entityReadStream($id); + if (!is_resource($stream)) { throw new HostException('Document access is unavailable.', 404); } + try { + $content = stream_get_contents($stream, $maxBytes + 1); + } finally { + fclose($stream); + } + if ($content === false) { throw new HostException('Could not read the document.', 503); } + if (strlen($content) > $maxBytes) { throw new HostException('Document exceeds the office viewing size limit.', 413); } + $label = $entity->getProperties()->getLabel(); + return ['label' => $label, 'content' => $content, 'version' => hash('sha256', $label . "\0" . $content), 'size' => strlen($content)]; + } +} diff --git a/lib/Host/HostException.php b/lib/Host/HostException.php new file mode 100644 index 0000000..6fdf69f --- /dev/null +++ b/lib/Host/HostException.php @@ -0,0 +1,11 @@ + 1200) { return false; } + $url = strtoupper($url); + $data = pack('N', strlen($token)) . $token . pack('N', strlen($url)) . $url + . pack('N', 8) . pack('J', $ticks); + return $this->verify($keys, '', $proof, $data) + || $this->verify($keys, '', $oldProof, $data) + || $this->verify($keys, 'old', $proof, $data); + } + + private function verify(array $keys, string $prefix, string $signature, string $data): bool + { + $signature = base64_decode($signature, true); + $modulus = base64_decode($keys[$prefix . 'modulus'] ?? '', true); + $exponent = base64_decode($keys[$prefix . 'exponent'] ?? '', true); + if (!$signature || !$modulus || !$exponent || strlen($modulus) > 1024 || strlen($exponent) > 8) { return false; } + try { + $key = RSA::loadPublicKey(['n' => new BigInteger($modulus, 256), 'e' => new BigInteger($exponent, 256)]); + return $key->withPadding(RSA::SIGNATURE_PKCS1)->withHash('sha256')->verify($data, $signature); + } catch (\Throwable) { + return false; + } + } +} diff --git a/lib/Host/SessionStore.php b/lib/Host/SessionStore.php new file mode 100644 index 0000000..17fe4c5 --- /dev/null +++ b/lib/Host/SessionStore.php @@ -0,0 +1,64 @@ +db->selectCollection('service_wopi_files'); + $files->updateOne(['_id' => $key], ['$setOnInsert' => [ + 'tenant' => $tenant, 'user' => $user, 'resource' => $resource, 'file' => bin2hex(random_bytes(16)), + ]], ['upsert' => true]); + return $files->findOne(['_id' => $key])['file']; + } + + public function save(string $token, array $session): void + { + $this->db->selectCollection('service_wopi_sessions')->insertOne([ + '_id' => 'token:' . hash('sha256', $token), ...$session, + ]); + } + + public function find(string $tenant, string $file, string $token, int $now): ?array + { + return $this->db->selectCollection('service_wopi_sessions')->findOne([ + '_id' => 'token:' . hash('sha256', $token), 'tenant' => $tenant, + 'file' => $file, 'expires' => ['$gt' => $now], 'mode' => 'view', + ]); + } + + public function cached(string $tenant, string $server, int $now): ?string + { + $entry = $this->db->selectCollection('service_wopi_discovery')->findOne([ + '_id' => $this->cacheKey($tenant, $server), 'expires' => ['$gt' => $now], + ]); + return $entry['xml'] ?? null; + } + + public function cache(string $tenant, string $server, string $xml, int $expires): void + { + $this->db->selectCollection('service_wopi_discovery')->updateOne( + ['_id' => $this->cacheKey($tenant, $server)], + ['$set' => ['tenant' => $tenant, 'xml' => $xml, 'expires' => $expires]], ['upsert' => true], + ); + } + + public function cleanup(int $now): void + { + foreach (['service_wopi_sessions', 'service_wopi_discovery'] as $collection) { + $this->db->selectCollection($collection)->deleteMany(['expires' => ['$lte' => $now]]); + } + } + + private function cacheKey(string $tenant, string $server): string + { + return 'discovery:' . hash('sha256', json_encode([$tenant, $server], JSON_THROW_ON_ERROR)); + } +} diff --git a/lib/Host/WopiService.php b/lib/Host/WopiService.php new file mode 100644 index 0000000..ec69773 --- /dev/null +++ b/lib/Host/WopiService.php @@ -0,0 +1,123 @@ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ]; + + public function __construct( + private readonly TenantService $tenants, + private readonly SessionStore $store, + private readonly Documents $documents, + private readonly DiscoveryClient $client, + private readonly ProofValidator $proof, + ) {} + + public function configuration(string $tenant): Configuration + { + $configuration = $this->tenants->fetchServiceConfiguration($tenant, 'wopi') ?? []; + $domains = $this->tenants->fetchById($tenant)?->getDomains()?->getArrayCopy() ?? []; + return new Configuration($configuration, $domains); + } + + public function capabilities(string $tenant, string $user): array + { + $this->documents->authorize($tenant, $user); + $config = $this->configuration($tenant); + $discovery = $this->discovery($tenant, $config->office); + if (!$discovery->hasProofKeys) { throw new HostException('The office server must publish proof keys.', 503); } + $formats = []; + foreach ($discovery->actions as $action) { + if ($action->name === 'view' && $action->supportedBy([]) && isset(self::FORMATS[$action->extension])) { + $formats[$action->extension] = self::FORMATS[$action->extension]; + } + } + return ['mode' => 'view', 'mimeTypes' => array_values($formats), 'storage' => [['provider' => 'default', 'service' => 'personal']]]; + } + + public function launch(string $tenant, string $user, string $resource): array + { + $config = $this->configuration($tenant); + $file = $this->documents->read($tenant, $user, $resource, $config->maxBytes); + $extension = strtolower(pathinfo($file['label'], PATHINFO_EXTENSION)); + if (!isset(self::FORMATS[$extension])) { throw new HostException('This document format is not available for office viewing.', 415); } + $discovery = $this->discovery($tenant, $config->office); + if (!$discovery->hasProofKeys) { throw new HostException('The office server must publish proof keys.', 503); } + $selected = null; + foreach ($discovery->actions as $action) { + if ($action->extension === $extension && $action->name === 'view' && $action->supportedBy([])) { + $selected = $action; + break; + } + } + if ($selected === null) { throw new HostException('The office server cannot view this document format.', 415); } + $id = $this->store->fileId($tenant, $user, $resource); + $token = bin2hex(random_bytes(32)); + $expires = time() + $config->lifetime; + $this->store->save($token, [ + 'tenant' => $tenant, 'user' => $user, 'resource' => $resource, 'file' => $id, + 'mode' => 'view', 'expires' => $expires, 'server' => $config->office->url, + 'host' => $config->origin->origin, 'maxBytes' => $config->maxBytes, + 'version' => $file['version'], + ]); + return [ + 'actionUrl' => $selected->launchUrl($config->origin->origin . self::WOPI_PATH_PREFIX . $id), + 'accessToken' => $token, 'accessTokenTtl' => $expires * 1000, + 'officeOrigin' => $config->office->origin, 'mode' => 'view', + ]; + } + + /** @return array{session: array, file: array} */ + public function access(string $tenant, string $fileId, Request $request): array + { + $token = $request->query->all()['access_token'] ?? ''; + if (!is_string($token) || !preg_match('/^[a-f0-9]{64}$/D', $token)) { + throw new HostException('Invalid access token.', 401); + } + $session = $this->store->find($tenant, $fileId, $token, time()); + if ($session === null) { throw new HostException('Invalid access token.', 401); } + // Exact origin and raw query reconstruction avoids proxy-normalized proof URLs. + $uri = $request->getRequestUri(); + if (!str_starts_with($uri, self::WOPI_PATH_PREFIX . $fileId)) { throw new HostException('Invalid file path.', 404); } + $url = $session['host'] . $uri; + $server = new ServerAddress($session['server']); + $valid = fn (DiscoveryDocument $discovery): bool => $this->proof->valid( + $discovery->proofKeys, $token, $url, + $request->headers->get('X-WOPI-TimeStamp', ''), + $request->headers->get('X-WOPI-Proof', ''), + $request->headers->get('X-WOPI-ProofOld', ''), time(), + ); + $discovery = $this->discovery($tenant, $server); + if (!$valid($discovery) && !$valid($this->discovery($tenant, $server, true))) { + throw new HostException('Invalid WOPI proof.', 500); + } + $file = $this->documents->read($tenant, $session['user'], $session['resource'], $session['maxBytes']); + if (!hash_equals($session['version'], $file['version'])) { + throw new HostException('The document changed. Reopen it to view the current version.', 404); + } + return ['session' => $session, 'file' => $file]; + } + + private function discovery(string $tenant, ServerAddress $server, bool $refresh = false): DiscoveryDocument + { + $xml = $refresh ? null : $this->store->cached($tenant, $server->url, time()); + if ($xml !== null) { return DiscoveryDocument::parse($xml, $server); } + $xml = $this->client->fetchXml($server); + $document = DiscoveryDocument::parse($xml, $server); + $this->store->cache($tenant, $server->url, $xml, time() + 3600); + return $document; + } +} diff --git a/lib/Module.php b/lib/Module.php index f0d9721..6fe050f 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -8,6 +8,8 @@ use KTXF\Module\Configuration\ConsoleModuleContextInterface; use KTXF\Module\Configuration\ModuleContextInterface; use KTXF\Module\ModuleInstanceAbstract; use KTXM\ServiceWopi\Console\CheckCommand; +use KTXM\ServiceWopi\Console\CleanupCommand; +use KTXM\ServiceWopi\Console\ConfigureCommand; class Module extends ModuleInstanceAbstract { @@ -15,12 +17,19 @@ class Module extends ModuleInstanceAbstract 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 description(): string { return 'Read-only WOPI document access and discovery'; } + + public function permissions(): array + { + return ['service_wopi.view' => ['label' => 'View office documents', 'description' => 'Open documents in the configured office server', 'group' => 'Documents']]; + } public function configure(ModuleContextInterface $context): void { if ($context instanceof ConsoleModuleContextInterface) { $context->registerCommand(CheckCommand::class); + $context->registerCommand(CleanupCommand::class); + $context->registerCommand(ConfigureCommand::class); } } } diff --git a/tests/php/Unit/ConfigureCommandTest.php b/tests/php/Unit/ConfigureCommandTest.php new file mode 100644 index 0000000..da84139 --- /dev/null +++ b/tests/php/Unit/ConfigureCommandTest.php @@ -0,0 +1,89 @@ +createMock(TenantService::class); + $tenant = (new TenantObject())->setIdentifier('tenant-id')->setEnabled(true); + $tenants->method('fetchById')->willReturn($tenant); + $tenants->method('fetchServiceConfiguration')->willReturn(['max_bytes' => 1048576]); + $tenants->expects(self::once())->method('storeServiceConfiguration')->with('tenant-id', 'wopi', ['max_bytes' => 1048576, 'enabled' => true, 'office_server' => 'https://office.test', 'origin_server' => 'https://app.test', + ])->willReturn(true); + $discovery = $this->createStub(DiscoveryClient::class); + $discovery->method('fetch')->willReturn(DiscoveryDocument::parse( + '', + new ServerAddress('https://office.test'), + )); + $command = new CommandTester(new ConfigureCommand($tenants, $discovery)); + self::assertSame(0, $command->execute(['tenant' => 'tenant-id', 'office-server' => 'https://office.test/', '--origin-server' => 'https://app.test/'])); + self::assertStringNotContainsString('https://office.test', $command->getDisplay()); + } + + public function testDoesNotWriteIfDiscoveryFails(): void + { + $tenants = $this->createMock(TenantService::class); + $tenants->method('fetchById')->willReturn((new TenantObject())->setIdentifier('tenant-id')->setEnabled(true)); + $tenants->method('fetchServiceConfiguration')->willReturn([]); + $tenants->expects(self::never())->method('storeServiceConfiguration'); + $discovery = $this->createStub(DiscoveryClient::class); + $discovery->method('fetch')->willThrowException(new RuntimeException('private upstream details')); + $command = new CommandTester(new ConfigureCommand($tenants, $discovery)); + self::assertSame(1, $command->execute(['tenant' => 'tenant-id', 'office-server' => 'https://office.test', '--origin-server' => 'https://app.test'])); + self::assertStringNotContainsString('private upstream details', $command->getDisplay()); + } + #[\PHPUnit\Framework\Attributes\DataProvider('originCases')] + public function testOriginResolution(array $domains, string $selector, ?string $override, ?string $expected): void + { + $tenant = (new TenantObject())->setIdentifier('tenant-id')->setEnabled(true) + ->setDomains(new \KTXC\Models\Tenant\DomainCollection($domains)); + $tenants = $this->createMock(TenantService::class); + $tenants->method('fetchById')->willReturn($selector === 'tenant-id' ? $tenant : null); + $tenants->method('fetchByDomain')->willReturn($tenant); + $tenants->method('fetchServiceConfiguration')->willReturn([]); + $discovery = $this->createMock(DiscoveryClient::class); + if ($expected === null) { + $tenants->expects(self::never())->method('storeServiceConfiguration'); + $discovery->expects(self::never())->method('fetch'); + } else { + $tenants->expects(self::once())->method('storeServiceConfiguration')->with('tenant-id', 'wopi', array_merge( + ['enabled' => true, 'office_server' => 'https://office.test'], + $override !== null || count($domains) !== 1 ? ['origin_server' => $expected] : [], + ))->willReturn(true); + $discovery->expects(self::once())->method('fetch')->willReturn(DiscoveryDocument::parse( + '', + new ServerAddress('https://office.test'), + )); + } + $arguments = ['tenant' => $selector, 'office-server' => 'https://office.test']; + if ($override !== null) { + $arguments['--origin-server'] = $override; + } + $command = new CommandTester(new ConfigureCommand($tenants, $discovery)); + self::assertSame($expected === null ? 1 : 0, $command->execute($arguments)); + } + + public static function originCases(): array + { + return [ + 'single domain by ID' => [['app.test'], 'tenant-id', null, 'https://app.test'], + 'selected domain among aliases' => [['app.test', 'alias.test'], 'alias.test', null, 'https://alias.test'], + 'ambiguous ID' => [['app.test', 'alias.test'], 'tenant-id', null, null], + 'no domains' => [[], 'tenant-id', null, null], + 'explicit override' => [['app.test', 'alias.test'], 'tenant-id', 'https://origin.test:8443', 'https://origin.test:8443'], + 'HTTP rejected' => [['app.test'], 'tenant-id', 'http://origin.test', null], + 'path rejected' => [['app.test'], 'tenant-id', 'https://origin.test/path', null], + ]; + } + +} diff --git a/tests/php/Unit/HostTest.php b/tests/php/Unit/HostTest.php new file mode 100644 index 0000000..6563767 --- /dev/null +++ b/tests/php/Unit/HostTest.php @@ -0,0 +1,159 @@ +records[hash('sha256', $token)] = $session; } + public function find(string $tenant, string $file, string $token, int $now): ?array + { + $s = $this->records[hash('sha256', $token)] ?? null; + return $s && $s['tenant'] === $tenant && $s['file'] === $file && $s['expires'] > $now && $s['mode'] === 'view' ? $s : null; + } + public function cached(string $tenant, string $server, int $now): ?string { return $this->cacheRecords[$tenant . $server] ?? null; } + public function cache(string $tenant, string $server, string $xml, int $expires): void { $this->cacheRecords[$tenant . $server] = $xml; } +} + +final class HostTest extends TestCase +{ + public function testServiceConfigurationTakesPrecedenceAndDerivesDomain(): void + { + $tenants = $this->createMock(TenantService::class); + $tenants->method('fetchServiceConfiguration')->willReturn(['enabled' => true, 'office_server' => 'https://office.test']); + $tenants->expects(self::never())->method('fetchSettings'); + $tenants->method('fetchById')->willReturn((new \KTXC\Models\Tenant\TenantObject())->setDomains(new \KTXC\Models\Tenant\DomainCollection(['app.test']))); + $wopi = new WopiService($tenants, new MemorySessions(), $this->createStub(Documents::class), $this->createStub(DiscoveryClient::class), $this->createStub(ProofValidator::class)); + self::assertSame('https://app.test', $wopi->configuration('tenant')->origin->origin); + } + + public function testServicesSurviveTenantConfigurationRoundTrip(): void + { + $services = ['wopi' => ['enabled' => true, 'office_server' => 'https://office.test'], 'other' => ['enabled' => false]]; + $config = (new \KTXC\Models\Tenant\TenantConfiguration())->jsonDeserialize(['services' => $services]); + self::assertSame($services, $config->jsonSerialize()['services']); + } + + public function testAmbiguousRuntimeOriginFailsClosed(): void + { + $this->expectException(HostException::class); + new Configuration(['enabled' => true, 'office_server' => 'https://office.test'], ['app.test', 'alias.test']); + } + + public function testRouterSelectsSeparateAbsoluteHandlers(): void + { + $reflection = new \ReflectionClass(\KTXC\Routing\Router::class); + $router = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('logger')->setValue($router, new \Psr\Log\NullLogger()); + $reflection->getMethod('extract')->invoke($router, dirname(__DIR__, 3) . '/lib/Controllers/WopiController.php', '/m/service_wopi'); + $reflection->getProperty('initialized')->setValue($router, true); + $id = str_repeat('a', 32); + foreach (['' => 'retrieveMeta', '/contents' => 'retrieveContents', '/unknown' => 'unsupported'] as $suffix => $handler) { + $route = $router->match(Request::create('https://app.test/m/service_wopi/wopi/files/' . $id . $suffix)); + self::assertSame($handler, $route?->classMethodName); + self::assertFalse($route->authenticated); + if ($suffix !== '/unknown') { self::assertSame($id, $route->params['id']); } + } + self::assertSame('unsupported', $router->match(Request::create('https://app.test/m/service_wopi/wopi/files/' . $id . '/contents', 'POST'))?->classMethodName); + } + + private function setupWopi(bool $proofValid = true): array + { + $tenants = $this->createStub(TenantService::class); + $tenants->method('fetchServiceConfiguration')->willReturn(['enabled' => true, 'office_server' => 'https://office.test', 'origin_server' => 'https://app.test']); + $store = new MemorySessions(); + $documents = $this->createStub(Documents::class); + $documents->method('read')->willReturn(['label' => 'file.docx', 'size' => 4, 'content' => "a\0bc", 'version' => 'version-1']); + $client = $this->createStub(DiscoveryClient::class); + $client->method('fetchXml')->willReturn(''); + $proof = $this->createStub(ProofValidator::class); + $proof->method('valid')->willReturn($proofValid); + return [new WopiService($tenants, $store, $documents, $client, $proof), $store]; + } + + private function request(string $token, string $suffix = ''): Request + { + return Request::create('https://app.test/m/service_wopi/wopi/files/' . str_repeat('a', 32) . $suffix . '?access_token=' . $token); + } + + public function testLaunchAndReadOnlyEndpoints(): void + { + [$wopi, $store] = $this->setupWopi(); + self::assertSame([WopiService::FORMATS['docx']], $wopi->capabilities('tenant', 'user')['mimeTypes']); + $launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file'); + self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $launch['accessToken']); + self::assertStringNotContainsString($launch['accessToken'], json_encode($store->records)); + self::assertStringNotContainsString($launch['accessToken'], $launch['actionUrl']); + self::assertSame('view', $launch['mode']); + self::assertGreaterThan(time() * 1000, $launch['accessTokenTtl']); + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('enabled')->willReturn(true); + $tenant->method('requireIdentifier')->willReturn('tenant'); + $controller = new WopiController($tenant, $wopi); + $metadata = $controller->retrieveMeta($this->request($launch['accessToken']), str_repeat('a', 32)); + self::assertSame(200, $metadata->getStatusCode()); + $data = json_decode($metadata->getContent(), true); + self::assertTrue($data['ReadOnly']); + self::assertFalse($data['UserCanWrite']); + self::assertFalse($data['SupportsLocks']); + $content = $controller->retrieveContents($this->request($launch['accessToken'], '/contents'), str_repeat('a', 32)); + self::assertSame("a\0bc", $content->getContent()); + self::assertStringContainsString('no-store', $content->headers->get('Cache-Control')); + self::assertSame('4', $content->headers->get('Content-Length')); + $post = Request::create('https://app.test/m/service_wopi/wopi/files/' . str_repeat('a', 32) . '/contents', 'POST'); + self::assertSame(405, $controller->unsupported($post)->getStatusCode()); + } + + public function testRejectsExpiredTokensAndCrossTenantAccess(): void + { + [$wopi, $store] = $this->setupWopi(); + $launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file'); + foreach (['other-tenant', 'tenant'] as $tenant) { + if ($tenant === 'tenant') $store->records[hash('sha256', $launch['accessToken'])]['expires'] = time(); + try { + $wopi->access($tenant, str_repeat('a', 32), $this->request($launch['accessToken'])); + self::fail('Expected token rejection'); + } catch (HostException $error) { self::assertSame(401, $error->status); } + } + } + + public function testRejectsProofFailure(): void + { + [$wopi] = $this->setupWopi(false); + $launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file'); + $this->expectException(HostException::class); + $this->expectExceptionMessage('Invalid WOPI proof'); + $wopi->access('tenant', str_repeat('a', 32), $this->request($launch['accessToken'])); + } + + public function testRejectsChangedFile(): void + { + [$wopi, $store] = $this->setupWopi(); + $launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file'); + $store->records[hash('sha256', $launch['accessToken'])]['version'] = 'old-version'; + $this->expectException(HostException::class); + $this->expectExceptionMessage('document changed'); + $wopi->access('tenant', str_repeat('a', 32), $this->request($launch['accessToken'])); + } + + public function testConfigurationRequiresExplicitEnablementAndHttps(): void + { + $this->expectException(HostException::class); + new Configuration([]); + } +} diff --git a/tests/php/Unit/ProofTest.php b/tests/php/Unit/ProofTest.php new file mode 100644 index 0000000..56f200a --- /dev/null +++ b/tests/php/Unit/ProofTest.php @@ -0,0 +1,42 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + } finally { + putenv($previousRandomFile === false ? 'RANDFILE' : 'RANDFILE=' . $previousRandomFile); + unlink($randomFile); + } + $rsa = openssl_pkey_get_details($private)['rsa']; + $keys = ['modulus' => base64_encode($rsa['n']), 'exponent' => base64_encode($rsa['e'])]; + $token = 'secret-token'; + $url = 'https://app.test/wopi/files/test?access_token=secret-token'; + $now = time(); + $timestamp = ($now + 62135596800) * 10000000; + $upper = strtoupper($url); + $data = pack('N', strlen($token)) . $token . pack('N', strlen($upper)) . $upper + . pack('N', 8) . pack('N2', intdiv($timestamp, 4294967296), $timestamp % 4294967296); + openssl_sign($data, $signed, $private, OPENSSL_ALGO_SHA256); + $signature = base64_encode($signed); + $validator = new ProofValidator(); + self::assertTrue($validator->valid($keys, $token, $url, (string) $timestamp, $signature, '', $now)); + self::assertTrue($validator->valid($keys, $token, $url, (string) $timestamp, '', $signature, $now)); + self::assertTrue($validator->valid(['oldmodulus' => $keys['modulus'], 'oldexponent' => $keys['exponent']], $token, $url, (string) $timestamp, $signature, '', $now)); + self::assertFalse($validator->valid($keys, 'wrong-token', $url, (string) $timestamp, $signature, '', $now)); + self::assertFalse($validator->valid($keys, $token, $url . '&extra=1', (string) $timestamp, $signature, '', $now)); + self::assertFalse($validator->valid($keys, $token, $url, (string) $timestamp, $signature, '', $now + 1201)); + self::assertFalse($validator->valid($keys, $token, $url, (string) $timestamp, $signature, '', $now - 1201)); + self::assertFalse($validator->valid([], $token, $url, (string) $timestamp, $signature, '', $now)); + self::assertFalse($validator->valid($keys, $token, $url, 'not-a-timestamp', $signature, '', $now)); + } +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php index 245108c..65aa83b 100644 --- a/tests/php/bootstrap.php +++ b/tests/php/bootstrap.php @@ -2,3 +2,5 @@ $loader = require dirname(__DIR__, 4) . '/vendor/autoload.php'; $loader->addPsr4('KTXM\\ServiceWopi\\', dirname(__DIR__, 2) . '/lib/'); +$modules = new KTXC\Module\ModuleAutoloader(dirname(__DIR__, 3)); +$modules->register();