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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user