generated from Nodarx/template
feat: implement initial read only access
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Console;
|
||||
|
||||
use KTXM\ServiceWopi\Host\SessionStore;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(name: 'wopi:cleanup', description: 'Remove expired WOPI sessions and discovery cache records')]
|
||||
class CleanupCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly SessionStore $store) { parent::__construct(); }
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->store->cleanup(time());
|
||||
$output->writeln('Expired WOPI records removed.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Console;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
|
||||
use KTXM\ServiceWopi\Host\Configuration;
|
||||
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\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'wopi:configure', description: 'Configure tenant office viewing and verify server discovery')]
|
||||
class ConfigureCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly TenantService $tenants, private readonly DiscoveryClient $discovery)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Controllers;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use KTXM\ServiceWopi\Host\HostException;
|
||||
use KTXM\ServiceWopi\Host\WopiService;
|
||||
|
||||
class OfficeController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(private readonly TenantContextInterface $tenant, private readonly IdentityContextInterface $identity, private readonly WopiService $wopi) {}
|
||||
|
||||
#[AuthenticatedRoute('/capabilities', name: 'wopi.capabilities', methods: ['GET'], permissions: ['service_wopi.view'])]
|
||||
public function capabilities(): JsonResponse
|
||||
{
|
||||
return $this->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']); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Controllers;
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AnonymousPrefixRoute;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXM\ServiceWopi\Host\HostException;
|
||||
use KTXM\ServiceWopi\Host\WopiService;
|
||||
|
||||
class WopiController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(private readonly TenantContextInterface $tenant, private readonly WopiService $wopi) {}
|
||||
|
||||
private const HEADERS = ['Cache-Control' => '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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ final readonly class DiscoveryDocument
|
||||
public const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** @param list<Action> $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);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Host;
|
||||
|
||||
use KTXM\ServiceWopi\Discovery\ServerAddress;
|
||||
|
||||
final readonly class Configuration
|
||||
{
|
||||
public ServerAddress $office;
|
||||
public ServerAddress $origin;
|
||||
public int $lifetime;
|
||||
public int $maxBytes;
|
||||
|
||||
public function __construct(array $settings, array $domains = [])
|
||||
{
|
||||
if (($settings['enabled'] ?? false) !== true) {
|
||||
throw new HostException('Office viewing is not configured.', 503);
|
||||
}
|
||||
try {
|
||||
$this->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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Host;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use KTXF\Resource\Identifier\ResourceIdentifier;
|
||||
use KTXM\DocumentsManager\Manager;
|
||||
use KTXM\ProviderLocalDocuments\Providers\Personal\PersonalService;
|
||||
|
||||
class Documents
|
||||
{
|
||||
public function __construct(private readonly Manager $manager, private readonly UserAccountsStore $users, private readonly TenantService $tenants) {}
|
||||
|
||||
public function authorize(string $tenant, string $user): void
|
||||
{
|
||||
$account = $this->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)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Host;
|
||||
|
||||
class HostException extends \RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status = 400)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Host;
|
||||
|
||||
use phpseclib3\Crypt\RSA;
|
||||
use phpseclib3\Math\BigInteger;
|
||||
|
||||
class ProofValidator
|
||||
{
|
||||
public function valid(array $keys, string $token, string $url, string $timestamp, string $proof, string $oldProof, int $now): bool
|
||||
{
|
||||
if (!preg_match('/^[0-9]{1,18}$/D', $timestamp)) { return false; }
|
||||
$ticks = (int) $timestamp;
|
||||
// .NET ticks: 100 ns since 0001-01-01. Bound both stale and future requests.
|
||||
$seconds = intdiv($ticks, 10000000) - 62135596800;
|
||||
if (abs($seconds - $now) > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Host;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
|
||||
class SessionStore
|
||||
{
|
||||
public function __construct(private readonly DataStore $db) {}
|
||||
|
||||
public function fileId(string $tenant, string $user, string $resource): string
|
||||
{
|
||||
// Personal storage identity includes the owner. Shared aliases are not yet supported.
|
||||
$key = 'file:' . hash('sha256', json_encode([$tenant, $user, $resource], JSON_THROW_ON_ERROR));
|
||||
$files = $this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace KTXM\ServiceWopi\Host;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
|
||||
use KTXM\ServiceWopi\Discovery\DiscoveryDocument;
|
||||
use KTXM\ServiceWopi\Discovery\ServerAddress;
|
||||
|
||||
class WopiService
|
||||
{
|
||||
private const WOPI_PATH_PREFIX = '/m/service_wopi/wopi/files/';
|
||||
|
||||
public const FORMATS = [
|
||||
'docx' => '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;
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXM\ServiceWopi\Console\ConfigureCommand;
|
||||
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
|
||||
use KTXM\ServiceWopi\Discovery\DiscoveryDocument;
|
||||
use KTXM\ServiceWopi\Discovery\ServerAddress;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
final class ConfigureCommandTest extends TestCase
|
||||
{
|
||||
public function testSavesOnlyValidatedWopiSettingsAndPreservesLimits(): void
|
||||
{
|
||||
$tenants = $this->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(
|
||||
'<wopi-discovery><net-zone name="external-https"><app name="Word"><action name="view" ext="docx" urlsrc="https://office.test/view?"/></app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>',
|
||||
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(
|
||||
'<wopi-discovery><net-zone name="external-https"><app name="Word"><action name="view" ext="docx" urlsrc="https://office.test/view?"/></app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>',
|
||||
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],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXM\ServiceWopi\Controllers\WopiController;
|
||||
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
|
||||
use KTXM\ServiceWopi\Host\Configuration;
|
||||
use KTXM\ServiceWopi\Host\Documents;
|
||||
use KTXM\ServiceWopi\Host\HostException;
|
||||
use KTXM\ServiceWopi\Host\WopiService;
|
||||
use KTXM\ServiceWopi\Host\ProofValidator;
|
||||
use KTXM\ServiceWopi\Host\SessionStore;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class MemorySessions extends SessionStore
|
||||
{
|
||||
public array $records = [];
|
||||
public array $cacheRecords = [];
|
||||
public function __construct() {}
|
||||
public function fileId(string $tenant, string $user, string $resource): string { return str_repeat('a', 32); }
|
||||
public function save(string $token, array $session): void { $this->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('<wopi-discovery><net-zone name="external-https"><app name="Word"><action name="view" ext="docx" urlsrc="https://office.test/view?"/><action name="edit" ext="xlsx" urlsrc="https://office.test/edit?"/></app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>');
|
||||
$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([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use KTXM\ServiceWopi\Host\ProofValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ProofTest extends TestCase
|
||||
{
|
||||
public function testProofBytesAndRotationWithIndependentOpenSslSignatures(): void
|
||||
{
|
||||
$randomFile = tempnam(sys_get_temp_dir(), 'wopi-test-random-');
|
||||
$previousRandomFile = getenv('RANDFILE');
|
||||
putenv('RANDFILE=' . $randomFile);
|
||||
try {
|
||||
$private = openssl_pkey_new(['private_key_bits' => 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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user