'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; } }