From fbb5fa26ea9e97a4d34533813233b58720652d12 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:25:28 +0200 Subject: [PATCH 01/49] chore: bump max-version to 35 for NC35 compatibility Signed-off-by: James Manuel --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index f78827d..3002a6a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ office https://github.com/nextcloud/office - + From feb2cf75b61a548330160887ff3d0ceeff694edb Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:36:22 +0200 Subject: [PATCH 02/49] feat: add database migration creating the office_wopi token table Signed-off-by: James Manuel --- .../Version1000Date20260522000000.php | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 lib/Migration/Version1000Date20260522000000.php diff --git a/lib/Migration/Version1000Date20260522000000.php b/lib/Migration/Version1000Date20260522000000.php new file mode 100644 index 0000000..756f92d --- /dev/null +++ b/lib/Migration/Version1000Date20260522000000.php @@ -0,0 +1,81 @@ +hasTable('office_wopi')) { + return null; + } + + $table = $schema->createTable('office_wopi'); + + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('owner_uid', 'string', [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('editor_uid', 'string', [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('guest_displayname', 'string', [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('fileid', 'bigint', [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('version', 'string', [ + 'notnull' => false, + 'length' => 1024, + 'default' => '0', + ]); + $table->addColumn('canwrite', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + $table->addColumn('server_host', 'string', [ + 'notnull' => true, + 'default' => 'localhost', + ]); + $table->addColumn('token', 'string', [ + 'notnull' => false, + 'length' => 32, + 'default' => '', + ]); + $table->addColumn('expiry', 'bigint', [ + 'notnull' => false, + 'length' => 20, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['token'], 'office_wopi_token_idx'); + $table->addIndex(['fileid'], 'office_wopi_fileid_idx'); + + return $schema; + } +} From 27744db946d73916cecb9c800b88e1330ea31624 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:36:27 +0200 Subject: [PATCH 03/49] feat: add ExpiredTokenException for WOPI token validation Signed-off-by: James Manuel --- lib/Exception/ExpiredTokenException.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 lib/Exception/ExpiredTokenException.php diff --git a/lib/Exception/ExpiredTokenException.php b/lib/Exception/ExpiredTokenException.php new file mode 100644 index 0000000..c6cc399 --- /dev/null +++ b/lib/Exception/ExpiredTokenException.php @@ -0,0 +1,13 @@ + Date: Fri, 22 May 2026 23:36:35 +0200 Subject: [PATCH 04/49] feat: add UnknownTokenException for WOPI token lookup failures Signed-off-by: James Manuel --- lib/Exception/UnknownTokenException.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 lib/Exception/UnknownTokenException.php diff --git a/lib/Exception/UnknownTokenException.php b/lib/Exception/UnknownTokenException.php new file mode 100644 index 0000000..9a2fd17 --- /dev/null +++ b/lib/Exception/UnknownTokenException.php @@ -0,0 +1,13 @@ + Date: Fri, 22 May 2026 23:36:42 +0200 Subject: [PATCH 05/49] feat: add Wopi entity mapping the office_wopi table Signed-off-by: James Manuel --- lib/Db/Wopi.php | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 lib/Db/Wopi.php diff --git a/lib/Db/Wopi.php b/lib/Db/Wopi.php new file mode 100644 index 0000000..ed43db5 --- /dev/null +++ b/lib/Db/Wopi.php @@ -0,0 +1,93 @@ +addType('ownerUid', Types::STRING); + $this->addType('editorUid', Types::STRING); + $this->addType('guestDisplayname', Types::STRING); + $this->addType('fileid', Types::INTEGER); + $this->addType('version', Types::STRING); + $this->addType('canwrite', Types::BOOLEAN); + $this->addType('serverHost', Types::STRING); + $this->addType('token', Types::STRING); + $this->addType('expiry', Types::INTEGER); + } + + public function isGuest(): bool { + return $this->guestDisplayname !== null && $this->editorUid === null; + } + + public function isExpired(): bool { + return $this->expiry !== null && $this->expiry < time(); + } + + /** + * Return the UID that should be used for file access. + * Guests use the file owner's UID for NC file operations. + */ + public function getUserForFileAccess(): string { + return $this->isGuest() ? (string)$this->ownerUid : (string)$this->editorUid; + } +} From f07d0411ecc74e31ee6028f1a2dbfc4a6479e89a Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:36:49 +0200 Subject: [PATCH 06/49] feat: add WopiMapper for token persistence and lookup Signed-off-by: James Manuel --- lib/Db/WopiMapper.php | 148 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 lib/Db/WopiMapper.php diff --git a/lib/Db/WopiMapper.php b/lib/Db/WopiMapper.php new file mode 100644 index 0000000..d326c5a --- /dev/null +++ b/lib/Db/WopiMapper.php @@ -0,0 +1,148 @@ + */ +class WopiMapper extends QBMapper { + private const TOKEN_TTL = 36000; // 10 hours + + public function __construct( + IDBConnection $db, + private ISecureRandom $random, + private LoggerInterface $logger, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'office_wopi', Wopi::class); + } + + /** + * Generate and persist a WOPI token for an authenticated user. + */ + public function generateFileToken( + int $fileId, + string $ownerUid, + string $editorUid, + string $version, + bool $canWrite, + string $serverHost, + ): Wopi { + $token = $this->random->generate(32, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS); + + /** @var Wopi $wopi */ + $wopi = $this->insert(Wopi::fromParams([ + 'fileid' => $fileId, + 'ownerUid' => $ownerUid, + 'editorUid' => $editorUid, + 'version' => $version, + 'canwrite' => $canWrite, + 'serverHost' => $serverHost, + 'token' => $token, + 'expiry' => $this->newExpiry(), + ])); + + return $wopi; + } + + /** + * Generate and persist a WOPI token for a guest (share link) editor. + */ + public function generateGuestToken( + int $fileId, + string $ownerUid, + string $guestDisplayname, + string $version, + bool $canWrite, + string $serverHost, + ): Wopi { + $token = $this->random->generate(32, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS); + + /** @var Wopi $wopi */ + $wopi = $this->insert(Wopi::fromParams([ + 'fileid' => $fileId, + 'ownerUid' => $ownerUid, + 'editorUid' => null, + 'guestDisplayname' => $guestDisplayname, + 'version' => $version, + 'canwrite' => $canWrite, + 'serverHost' => $serverHost, + 'token' => $token, + 'expiry' => $this->newExpiry(), + ])); + + return $wopi; + } + + /** + * Look up and validate a WOPI token. + * + * @throws UnknownTokenException + * @throws ExpiredTokenException + */ + public function getWopiForToken( + #[\SensitiveParameter] + string $token, + ): Wopi { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('office_wopi') + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false) { + throw new UnknownTokenException('Could not find token.'); + } + + // Redact the token value before logging to avoid credential exposure in log files. + $safeRow = $row; + $safeRow['token'] = '***'; + $this->logger->debug('Loaded WOPI token record: {row}.', ['row' => $safeRow]); + + /** @var Wopi $wopi */ + $wopi = Wopi::fromRow($row); + + if ($wopi->isExpired()) { + throw new ExpiredTokenException('Provided token is expired.'); + } + + return $wopi; + } + + /** + * Return IDs of tokens that expired more than 60 seconds ago, for cleanup jobs. + * + * @return int[] + */ + public function getExpiredTokenIds(?int $limit = null, ?int $offset = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from('office_wopi') + ->where($qb->expr()->lt('expiry', $qb->createNamedParameter(time() - 60, IQueryBuilder::PARAM_INT))) + ->setFirstResult($offset) + ->setMaxResults($limit); + + return array_column($qb->executeQuery()->fetchAll(), 'id'); + } + + private function newExpiry(): int { + return $this->timeFactory->getTime() + self::TOKEN_TTL; + } +} From 9fffd03656556b815b4a79f4ab2850adb3a7e6d7 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:36:56 +0200 Subject: [PATCH 07/49] feat: add DiscoveryService to fetch and cache WOPI discovery XML Signed-off-by: James Manuel --- lib/Service/DiscoveryService.php | 137 +++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 lib/Service/DiscoveryService.php diff --git a/lib/Service/DiscoveryService.php b/lib/Service/DiscoveryService.php new file mode 100644 index 0000000..3d01ed3 --- /dev/null +++ b/lib/Service/DiscoveryService.php @@ -0,0 +1,137 @@ +cacheFactory->createDistributed('office'); + $cached = $cache->get(self::CACHE_KEY); + if ($cached !== null) { + return $cached; + } + + try { + return $this->fetch(); + } catch (\Throwable $e) { + $this->logger->error('Failed to fetch WOPI discovery: ' . $e->getMessage(), ['exception' => $e]); + return null; + } + } + + /** + * Fetch discovery XML from the editor server and cache it. + * + * @throws \Exception if the request fails + */ + public function fetch(): string { + $client = $this->clientService->newClient(); + $url = $this->getEditorUrl() . '/hosting/discovery'; + + $response = $client->get($url, $this->getRequestOptions()); + $body = (string)$response->getBody(); + + $cache = $this->cacheFactory->createDistributed('office'); + $cache->set(self::CACHE_KEY, $body, self::CACHE_TTL); + + return $body; + } + + public function resetCache(): void { + $cache = $this->cacheFactory->createDistributed('office'); + $cache->remove(self::CACHE_KEY); + } + + /** + * Return the urlsrc template for the given file extension and action name. + * + * @param string $extension e.g. 'docx' + * @param string $action e.g. 'edit' or 'view' + * @return string|null urlsrc template string, or null if not found + */ + public function getUrlSrc(string $extension, string $action = 'edit'): ?string { + $xml = $this->get(); + if ($xml === null) { + return null; + } + + try { + $parsed = new SimpleXMLElement($xml); + } catch (\Exception $e) { + $this->logger->error('Failed to parse WOPI discovery XML: ' . $e->getMessage()); + return null; + } + + $actions = $parsed->xpath( + sprintf('//app/action[@ext="%s" and @name="%s"]', $extension, $action) + ); + + if (empty($actions)) { + return null; + } + + return (string)$actions[0]['urlsrc']; + } + + /** + * Build the final editor URL by substituting the wopisrc template parameter. + * + * The WOPI urlsrc is a template like: + * http://editor/hosting/wopi/word/edit?& + * This method replaces with the actual WOPISrc value. + * + * @param string $urlsrc Raw urlsrc from discovery XML + * @param string $wopiSrc The WOPI host URL (our CheckFileInfo endpoint) + * @param string $token WOPI access token + */ + public function buildEditorUrl(string $urlsrc, string $wopiSrc, string $token): string { + $url = preg_replace('/<[^>]+>/', '', $urlsrc); + $url = rtrim($url, '?&'); + $url .= '&wopisrc=' . urlencode($wopiSrc); + $url .= '&access_token=' . urlencode($token); + return $url; + } + + private function getEditorUrl(): string { + return rtrim($this->appConfig->getValueString('office', 'wopi_url', ''), '/'); + } + + private function getRequestOptions(): array { + $options = [ + 'timeout' => 45, + 'nextcloud' => ['allow_local_address' => true], + ]; + + if ($this->appConfig->getValueString('office', 'disable_certificate_verification') === 'yes') { + $options['verify'] = false; + } + + return $options; + } +} From 832bd30793f375af380222c1d87e4a850f09f545 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:37:03 +0200 Subject: [PATCH 08/49] feat: add TokenManager to generate WOPI access tokens for files Signed-off-by: James Manuel --- lib/TokenManager.php | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 lib/TokenManager.php diff --git a/lib/TokenManager.php b/lib/TokenManager.php new file mode 100644 index 0000000..e777374 --- /dev/null +++ b/lib/TokenManager.php @@ -0,0 +1,105 @@ +rootFolder->getUserFolder((string)$this->userId); + + $file = $userFolder->getFirstNodeById($fileId); + + if (!$file instanceof File || !$file->isReadable()) { + throw new NotPermittedException(); + } + + $canWrite = $file->isUpdateable(); + + $owner = $file->getOwner(); + $ownerUid = $owner !== null ? $owner->getUID() : (string)$this->userId; + + // Fire the read event so audit logging picks it up. + $this->eventDispatcher->dispatchTyped(new BeforeNodeReadEvent($file)); + + $serverHost = $this->urlGenerator->getAbsoluteURL('/'); + $version = (string)$file->getMtime(); + + return $this->wopiMapper->generateFileToken( + fileId: $fileId, + ownerUid: $ownerUid, + editorUid: (string)$this->userId, + version: $version, + canWrite: $canWrite, + serverHost: $serverHost, + ); + } + + /** + * Generate a WOPI token for a guest opening a file via a share link. + * + * @param int $fileId NC file ID (must be reachable via $ownerUid) + * @param string $ownerUid File owner whose storage is used for I/O + * @param string $guestName Display name shown in the editor + * @param bool $canWrite Whether the share allows editing + */ + public function generateGuestToken( + int $fileId, + string $ownerUid, + string $guestName, + bool $canWrite, + ): Wopi { + $ownerFolder = $this->rootFolder->getUserFolder($ownerUid); + $file = $ownerFolder->getFirstNodeById($fileId); + + if (!$file instanceof File || !$file->isReadable()) { + throw new NotPermittedException(); + } + + $this->eventDispatcher->dispatchTyped(new BeforeNodeReadEvent($file)); + + $serverHost = $this->urlGenerator->getAbsoluteURL('/'); + $version = (string)$file->getMtime(); + + return $this->wopiMapper->generateGuestToken( + fileId: $fileId, + ownerUid: $ownerUid, + guestDisplayname: $guestName, + version: $version, + canWrite: $canWrite, + serverHost: $serverHost, + ); + } +} From b40a9aa7374829848951b2878954382fd3f10e13 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:37:09 +0200 Subject: [PATCH 09/49] feat: add WopiController implementing CheckFileInfo, GetFile and PutFile Signed-off-by: James Manuel --- lib/Controller/WopiController.php | 304 ++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 lib/Controller/WopiController.php diff --git a/lib/Controller/WopiController.php b/lib/Controller/WopiController.php new file mode 100644 index 0000000..4a8e604 --- /dev/null +++ b/lib/Controller/WopiController.php @@ -0,0 +1,304 @@ +wopiMapper->getWopiForToken($access_token); + $file = $this->getFileForToken($wopi); + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($wopi->getFileid() !== $fileId) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $user = $this->userManager->get($wopi->getEditorUid() ?? ''); + $displayName = $wopi->isGuest() + ? ($wopi->getGuestDisplayname() ?? 'Guest') + : ($user?->getDisplayName() ?? $wopi->getEditorUid() ?? ''); + + $canWrite = (bool)$wopi->getCanwrite(); + + try { + $locks = $this->lockManager->getLocks($wopi->getFileid()); + foreach ($locks as $lock) { + if ($lock->getType() === \OCP\Files\Lock\ILock::TYPE_USER && $lock->getOwner() !== $wopi->getEditorUid()) { + $canWrite = false; + break; + } + } + } catch (NoLockProviderException|PreConditionNotMetException) { + } + + return new JSONResponse([ + 'BaseFileName' => $file->getName(), + 'Size' => $file->getSize(), + 'Version' => (string)$file->getMTime(), + 'UserId' => $wopi->isGuest() ? 'Guest-' . substr(md5($wopi->getToken()), 0, 8) : $wopi->getEditorUid(), + 'OwnerId' => $wopi->getOwnerUid(), + 'UserFriendlyName' => $displayName, + 'UserCanWrite' => $canWrite, + 'UserCanNotWriteRelative' => $wopi->isGuest(), + 'PostMessageOrigin' => $wopi->getServerHost(), + 'LastModifiedTime' => $this->toISO8601($file->getMTime()), + 'SupportsRename' => !$wopi->isGuest(), + 'UserCanRename' => !$wopi->isGuest(), + 'EnableInsertRemoteImage' => !$wopi->isGuest(), + 'EnableShare' => !$wopi->isGuest(), + 'HideUserList' => '', + 'EnableOwnerTermination' => $canWrite && !$wopi->isGuest(), + 'HasContentRange' => true, + 'SupportsLocks' => $this->lockManager->isLockProviderAvailable(), + // ServerPrivateInfo is intentionally empty — credentials must never travel via CheckFileInfo. + 'ServerPrivateInfo' => [], + ]); + } + + /** + * WOPI GetFile — returns the binary content of the file. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'GET', url: 'wopi/files/{fileId}/contents')] + public function getFile( + int $fileId, + #[\SensitiveParameter] + string $access_token, + ): Http\Response { + try { + $wopi = $this->wopiMapper->getWopiForToken($access_token); + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($wopi->getFileid() !== $fileId) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + try { + $file = $this->getFileForToken($wopi); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + if ($file->getSize() === 0) { + return new Http\Response(); + } + + $rangeHeader = $this->request->getHeader('Range'); + if ($rangeHeader !== '') { + return $this->getFileRange($file, $rangeHeader); + } + + return new StreamResponse($file->fopen('rb')); + } + + /** + * WOPI PutFile — saves binary content sent by the editor. + * + * Nextcloud's advisory locking is acquired around the write so other + * processes see a consistent file. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: 'wopi/files/{fileId}/contents')] + public function putFile( + int $fileId, + #[\SensitiveParameter] + string $access_token, + ): JSONResponse { + try { + $wopi = $this->wopiMapper->getWopiForToken($access_token); + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($wopi->getFileid() !== $fileId) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + if (!$wopi->getCanwrite()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + try { + $file = $this->getFileForToken($wopi); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + try { + $content = fopen('php://input', 'rb'); + + $freespace = $file->getStorage()->free_space($file->getInternalPath()); + $contentLength = (int)$this->request->getHeader('Content-Length'); + if ($freespace >= 0 && $contentLength > $freespace) { + return new JSONResponse(['message' => 'Not enough storage'], Http::STATUS_INSUFFICIENT_STORAGE); + } + + $this->writeWithLock($wopi, $file, fn () => $file->putContent($content)); + } catch (LockedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['message' => 'File locked'], Http::STATUS_CONFLICT); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse(['LastModifiedTime' => $this->toISO8601($file->getMTime())]); + } + + private function getFileForToken(Wopi $wopi): File { + $uid = $wopi->getUserForFileAccess(); + $userFolder = $this->rootFolder->getUserFolder($uid); + $nodes = $userFolder->getById($wopi->getFileid()); + + if (empty($nodes)) { + throw new NotFoundException('File not found for WOPI token'); + } + + // Prefer nodes with write permission when multiple exist (e.g. same file mounted in several places) + usort($nodes, fn ($a, $b) => ($b->getPermissions() & \OCP\Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & \OCP\Constants::PERMISSION_UPDATE)); + + $node = array_shift($nodes); + if (!$node instanceof File) { + throw new NotFoundException('WOPI token points to a directory, not a file'); + } + + return $node; + } + + private function getFileRange(File $file, string $rangeHeader): Http\Response { + $size = $file->getSize(); + if (preg_match('/bytes=(\d+)-(\d+)?/', $rangeHeader, $m)) { + $start = (int)$m[1]; + $end = isset($m[2]) ? (int)$m[2] : $size - 1; + // Clamp end to actual file size to produce a correct Content-Range header (RFC 7233). + $end = min($end, $size - 1); + $length = $end - $start + 1; + + $fp = $file->fopen('rb'); + $rangeStream = fopen('php://temp', 'w+b'); + stream_copy_to_stream($fp, $rangeStream, $length, $start); + fclose($fp); + fseek($rangeStream, 0); + + $response = new StreamResponse($rangeStream); + $response->setStatus(Http::STATUS_PARTIAL_CONTENT); + $response->addHeader('Content-Range', "bytes {$start}-{$end}/{$size}"); + $response->addHeader('Content-Length', (string)$length); + $response->addHeader('Accept-Ranges', 'bytes'); + return $response; + } + + return new StreamResponse($file->fopen('rb')); + } + + /** + * Write file content while acquiring an advisory ILockManager lock if available. + * Falls back to writing without a lock when no provider is registered. + */ + private function writeWithLock(Wopi $wopi, File $file, callable $write): void { + try { + $this->lockManager->runInScope( + new LockContext($file, \OCP\Files\Lock\ILock::TYPE_APP, 'office'), + $write, + ); + } catch (NoLockProviderException|PreConditionNotMetException) { + $write(); + } catch (OwnerLockedException $e) { + throw new LockedException($file->getPath(), $e); + } + } + + private function toISO8601(int $timestamp): string { + return (new \DateTime('@' . $timestamp))->format('Y-m-d\TH:i:s.000\Z'); + } +} From 6324175ef5eaece82e91d6461ae613cc99e5d1bf Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:43:01 +0200 Subject: [PATCH 10/49] =?UTF-8?q?=E2=9C=A8=20feat:=20register=20TokenManag?= =?UTF-8?q?er=20in=20DI=20container=20with=20session=20user=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- lib/AppInfo/Application.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e94c5b0..e471fff 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -4,10 +4,16 @@ namespace OCA\Office\AppInfo; +use OCA\Office\TokenManager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; +use OCP\IURLGenerator; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; class Application extends App implements IBootstrap { public const APP_ID = 'office'; @@ -18,6 +24,16 @@ public function __construct() { } public function register(IRegistrationContext $context): void { + $context->registerService(TokenManager::class, static function ($c) { + return new TokenManager( + $c->get(IRootFolder::class), + $c->get(\OCA\Office\Db\WopiMapper::class), + $c->get(IURLGenerator::class), + $c->get(IEventDispatcher::class), + $c->get(LoggerInterface::class), + $c->get(IUserSession::class)->getUser()?->getUID(), + ); + }); } public function boot(IBootContext $context): void { From f615de62ec3739506bc96e4f1f3ba0bf4e26a3c6 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 11/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Admin=20setting?= =?UTF-8?q?s=20class=20registering=20the=20office=20settings=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- lib/Settings/Admin.php | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 lib/Settings/Admin.php diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php new file mode 100644 index 0000000..2186ed3 --- /dev/null +++ b/lib/Settings/Admin.php @@ -0,0 +1,37 @@ + $this->appConfig->getValueString(Application::APP_ID, 'wopi_url', ''), + 'disable_certificate_verification' => $this->appConfig->getValueString(Application::APP_ID, 'disable_certificate_verification', 'no'), + ]); + } + + public function getSection(): string { + return 'connected-accounts'; + } + + public function getPriority(): int { + return 50; + } +} From 46bc89162923debb280d8ba5495a03d7c8f3fff8 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 12/49] =?UTF-8?q?=E2=9C=A8=20feat:=20register=20Admin=20se?= =?UTF-8?q?ttings=20in=20Application=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- lib/AppInfo/Application.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e471fff..811532d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -4,6 +4,7 @@ namespace OCA\Office\AppInfo; +use OCA\Office\Settings\Admin; use OCA\Office\TokenManager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -24,6 +25,8 @@ public function __construct() { } public function register(IRegistrationContext $context): void { + $context->registerSettings(Admin::class); + $context->registerService(TokenManager::class, static function ($c) { return new TokenManager( $c->get(IRootFolder::class), From 73b9062105386c208af300d1a99e5d80f59c1f36 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 13/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20SettingsControl?= =?UTF-8?q?ler=20with=20admin=20get/set=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- lib/Controller/SettingsController.php | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 lib/Controller/SettingsController.php diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 0000000..63dd2e9 --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,54 @@ + $this->appConfig->getValueString(Application::APP_ID, 'wopi_url', ''), + 'disable_certificate_verification' => $this->appConfig->getValueString(Application::APP_ID, 'disable_certificate_verification', 'no'), + ]); + } + + /** + * Persist admin settings. + */ + #[AuthorizedAdminSetting(settings: Settings\Admin::class)] + #[ApiRoute(verb: 'POST', url: '/settings/admin')] + public function setAdmin(string $wopi_url, string $disable_certificate_verification = 'no'): DataResponse { + $this->appConfig->setValueString(Application::APP_ID, 'wopi_url', rtrim($wopi_url, '/')); + $this->appConfig->setValueString(Application::APP_ID, 'disable_certificate_verification', $disable_certificate_verification === 'yes' ? 'yes' : 'no'); + + return new DataResponse([]); + } +} From db593ebd5970bcaac338445019e1116a06cd15c6 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 14/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20admin=20setting?= =?UTF-8?q?s=20PHP=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- templates/settings/admin.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 templates/settings/admin.php diff --git a/templates/settings/admin.php b/templates/settings/admin.php new file mode 100644 index 0000000..f020960 --- /dev/null +++ b/templates/settings/admin.php @@ -0,0 +1,23 @@ + + +
From 4cbb069c3f26808d144c748bbec497000bf4a789 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 15/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20AdminSettings?= =?UTF-8?q?=20Vue=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- src/settings/AdminSettings.vue | 90 ++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/settings/AdminSettings.vue diff --git a/src/settings/AdminSettings.vue b/src/settings/AdminSettings.vue new file mode 100644 index 0000000..409e2b4 --- /dev/null +++ b/src/settings/AdminSettings.vue @@ -0,0 +1,90 @@ + + + + + From 91138ead355bf970204d30e4186e869b922ff464 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 16/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20settings-admin?= =?UTF-8?q?=20entry=20point=20mounting=20AdminSettings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- src/settings-admin.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/settings-admin.ts diff --git a/src/settings-admin.ts b/src/settings-admin.ts new file mode 100644 index 0000000..2aaaf30 --- /dev/null +++ b/src/settings-admin.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import AdminSettings from './settings/AdminSettings.vue' + +const app = createApp(AdminSettings) +app.mount('#office-settings-admin') From 1d89ba7a6b87f5dcc71f0d37b0033b707389bf38 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 17/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20EditorControlle?= =?UTF-8?q?r=20generating=20WOPI=20token=20and=20editor=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- lib/Controller/EditorController.php | 96 +++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 lib/Controller/EditorController.php diff --git a/lib/Controller/EditorController.php b/lib/Controller/EditorController.php new file mode 100644 index 0000000..ecb4ee3 --- /dev/null +++ b/lib/Controller/EditorController.php @@ -0,0 +1,96 @@ +rootFolder->getUserFolder((string)$this->userId); + $file = $userFolder->getFirstNodeById($fileId); + + if (!$file instanceof File) { + return new JSONResponse(['error' => 'File not found'], \OCP\AppFramework\Http::STATUS_NOT_FOUND); + } + + $extension = pathinfo($file->getName(), PATHINFO_EXTENSION); + $urlsrc = $this->discoveryService->getUrlSrc($extension, 'edit') + ?? $this->discoveryService->getUrlSrc($extension, 'view'); + + if ($urlsrc === null) { + return new JSONResponse( + ['error' => 'File type not supported by the editor'], + \OCP\AppFramework\Http::STATUS_UNSUPPORTED_MEDIA_TYPE + ); + } + + $wopi = $this->tokenManager->generateToken($fileId); + + $wopiSrc = $this->urlGenerator->linkToRouteAbsolute( + 'office.wopi.checkFileInfo', + ['fileId' => $fileId] + ); + + $editorUrl = $this->discoveryService->buildEditorUrl($urlsrc, $wopiSrc, $wopi->getToken()); + + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['error' => 'File not accessible'], \OCP\AppFramework\Http::STATUS_FORBIDDEN); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['error' => 'Internal error'], \OCP\AppFramework\Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $response = new TemplateResponse(Application::APP_ID, 'editor', [], 'blank'); + $response->setParams([ + 'editorUrl' => $editorUrl, + 'postMessageOrigin' => $wopi->getServerHost(), + 'fileName' => $file->getName(), + ]); + return $response; + } +} From 5c2eaff4ce61bf0952d013bcf00e480515191a17 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 18/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20editor=20PHP=20?= =?UTF-8?q?template=20passing=20editor=20URL=20to=20Vue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- templates/editor.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 templates/editor.php diff --git a/templates/editor.php b/templates/editor.php new file mode 100644 index 0000000..a855ba3 --- /dev/null +++ b/templates/editor.php @@ -0,0 +1,25 @@ + + +
From 5cdca91cec2017f87399a5abd9d6fe100af09f15 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Fri, 22 May 2026 23:47:33 +0200 Subject: [PATCH 19/49] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Editor=20Vue=20?= =?UTF-8?q?component=20rendering=20the=20WOPI=20iframe=20with=20origin=20v?= =?UTF-8?q?alidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: James Manuel --- src/views/Editor.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/views/Editor.vue diff --git a/src/views/Editor.vue b/src/views/Editor.vue new file mode 100644 index 0000000..6008c58 --- /dev/null +++ b/src/views/Editor.vue @@ -0,0 +1,70 @@ + + +