diff --git a/README.md b/README.md index 15f0a1f..d3b5879 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ # Office -A Nextcloud app that provides a dedicated hub for office documents. Users can browse, -filter, search, and create Documents, Spreadsheets, Presentations, and Diagrams from -a single page — without going through the Files app. +A Nextcloud app that integrates Euro-Office as a WOPI host, providing a full-page +editor and a document hub. Nextcloud acts as the WOPI host (file storage, token +authority, lock manager); Euro-Office acts as the WOPI client (rendering, editing). --- ## Features -- **Overview page** at `/apps/office` — categorised file list with sidebar navigation -- **Filters** — All / Mine / Shared with me -- **Search** — within the active category, with an "Open in Files" escape hatch -- **View toggle** — Grid (thumbnail previews) or List, persisted per user -- **Template creator** — create new files from editor-provided templates -- **Editor integration** — opens files directly in the configured office editor +- **Full-page editor** at `/apps/office/open?fileId=N` +- **Document overview** — browse, filter, search, and create office documents +- **WOPI host implementation** — see [WOPI spec compliance](#wopi-spec-compliance) below +- **Files app integration** — DEFAULT file action for all MIME types advertised by the editor +- **Public share support** — guest tokens for link-share access +- **Range reads** — partial file delivery via HTTP Range for large documents +- **Conflict-free close** — editor close returns the user to the overview via `history.back()` --- @@ -22,8 +23,9 @@ a single page — without going through the Files app. ### Requirements - [nextcloud-docker-dev](https://github.com/juliushaertl/nextcloud-docker-dev) -- NC ≥ 33 -- Node 24 / npm 11 +- NC ≥ 31 +- Node ≥ 24 / npm ≥ 11 +- Euro-Office server reachable from the NC container ### 1. Mount the app into the container @@ -41,8 +43,14 @@ Restart the container after saving. ### 2. Enable the app ```bash -docker exec -u www-data nextcloud-docker-dev-nextcloud-1 \ - php occ app:enable office +docker compose exec --user www-data nextcloud php occ app:enable office +``` + +If the Euro-Office connector app is also installed, disable it to prevent it from +competing for the DEFAULT file action: + +```bash +docker compose exec --user www-data nextcloud php occ app:disable eurooffice ``` ### 3. Build the frontend @@ -90,6 +98,48 @@ recompiled assets as a `chore(assets): Recompile assets` commit. --- +## How it works + +### WOPI flow + +``` +Browser NC (WOPI host) Euro-Office (WOPI client) + | | | + | GET /apps/office/open | | + |------------------------->| | + | | mint WOPI token (TokenManager) | + | | build editor URL with wopisrc | + | editor iframe / page | | + |<-------------------------| | + | | GET /wopi/files/{id}?token=... | + | |<----------------------------------| + | | CheckFileInfo response | + | |---------------------------------->| + | | GET /wopi/files/{id}/contents | + | |<----------------------------------| + | | file bytes | + | |---------------------------------->| + | ← editing session → | | + | | POST /wopi/files/{id}/contents | + | |<----------------------------------| + | | 204 No Content | + | |---------------------------------->| +``` + +### Key classes + +| Class | Responsibility | +|---|---| +| `EditorController` | Renders editor page; mints WOPI token; builds editor URL from discovery XML | +| `WopiController` | WOPI protocol endpoint — handles all `/wopi/files/` requests | +| `TokenManager` | Creates and validates WOPI tokens; manages token TTL and guest vs user access | +| `DiscoveryService` | Fetches and caches the editor's discovery XML; resolves MIME → action URL | +| `ShareController` | Issues guest tokens for public share links | +| `WopiMapper` / `WopiLockMapper` | Persistence for WOPI tokens and file locks | +| `CleanupJob` | Background job — expires stale locks and tokens | + +--- + ## Editor integration The overview opens files via NC's file shortlink (`/f/{fileid}`), which @@ -103,7 +153,61 @@ present, navigates directly to that URL instead of `/f/{fileid}`. --- -## Architecture +## WOPI spec compliance + +### Operations + +| Operation | `X-WOPI-Override` | Status | Notes | +|---|---|---|---| +| CheckFileInfo | — | ✅ | `GET /wopi/files/{id}` | +| GetFile | — | ✅ | `GET /wopi/files/{id}/contents`; HTTP Range supported | +| PutFile | — | ✅ | `POST /wopi/files/{id}/contents`; lock-enforced, optimistic version check, quota check | +| Lock | `LOCK` | ✅ | | +| Unlock | `UNLOCK` | ✅ | | +| RefreshLock | `REFRESH_LOCK` | ✅ | | +| GetLock | `GET_LOCK` | ✅ | | +| UnlockAndRelock | `LOCK` + `X-WOPI-OldLock` | ✅ | | +| RenameFile | `RENAME_FILE` | ✅ | Authenticated users only; conflict returns 400 + `X-WOPI-InvalidFileNameError` | +| PutRelativeFile | `PUT_RELATIVE_FILE` | ⏳ Phase 6 | `UserCanNotWriteRelative: true` suppresses Save As in the editor UI | +| DeleteFile | `DELETE` | — | Deletion is handled by NC outside WOPI | + +### CheckFileInfo capability flags + +| Flag | Value | Notes | +|---|---|---| +| `SupportsUpdate` | `true` | PutFile is implemented | +| `SupportsLocks` | dynamic | `true` when an NC lock provider is available | +| `SupportsGetLock` | `true` | GetLock is implemented | +| `SupportsExtendedLockLength` | `true` | `lock_id` column is `VARCHAR(1024)` | +| `SupportsRename` | per session | `true` for authenticated users; `false` for guests | +| `UserCanRename` | per session | `true` for authenticated users; `false` for guests | +| `UserCanNotWriteRelative` | `true` | PutRelativeFile deferred to Phase 6 | +| `UserCanWrite` | per token | stamped at token-issue time from file/share permissions | +| `IsAnonymousUser` | per session | `true` for guest (share-link) sessions | +| `HasContentRange` | `true` | partial file reads via HTTP Range are supported | +| `HideExportOption` | per token | derived from share `hide_download` flag | +| `DisablePrint` | per token | derived from share `hide_download` flag | +| `DisableExport` | per token | derived from share `hide_download` flag | + +--- + +## Public share support + +Share link visitors (`/s/{token}`) receive a guest WOPI token via `ShareController`. +File access, locking, and `CheckFileInfo` flags (`HideExportOption`, `DisablePrint`, +`UserCanWrite`, etc.) are all derived from the share's permissions and `hide_download` +flag at token-issue time. + +**Known gaps:** + +- **KG1** — Password-protected shares: users must authenticate at `/s/{token}` before + navigating to the editor. +- **KG2** — Authenticated users arriving through share links receive guest tokens. +- **KG3** — Federated/remote shares not tested. + +--- + +## Frontend architecture ``` /apps/office @@ -120,3 +224,14 @@ present, navigates directly to that URL instead of `/f/{fileid}`. ├── fileCategories.ts Mime → category mapping (pure) └── validateFilename.ts ``` + +--- + +## Backend architecture notes + +The WOPI token row (`oc_office_wopi`) is the authority for per-session flags +(`canwrite`, `hideDownload`, `ownerUid`). Flags are stamped at token-generation +time and not re-read on subsequent WOPI requests — this avoids a per-request +`IShareManager` lookup on every CheckFileInfo heartbeat, matching the richdocuments +pattern. Trade-off: share revocation mid-session is not enforced within the token TTL +(10 h). diff --git a/appinfo/info.xml b/appinfo/info.xml index 6e5846a..e931666 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -14,6 +14,12 @@ + + OCA\Office\BackgroundJob\CleanupJob + + + OCA\Office\Settings\Admin + office diff --git a/composer.json b/composer.json index b09b3a1..698daee 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,12 @@ "OCA\\Office\\": "lib/" } }, + "autoload-dev": { + "psr-4": { + "OCP\\": "vendor/nextcloud/ocp/OCP/", + "OCA\\Office\\Tests\\": "tests/" + } + }, "scripts": { "post-install-cmd": [ "@composer bin all install --ansi" @@ -24,7 +30,8 @@ "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", "psalm": "psalm --threads=1 --no-cache", - "test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky", + "test:unit": "phpunit -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky", + "test:integration": "echo 'Integration tests run in-container only: docker exec -u www-data -w /var/www/html/apps-extra/office nextcloud php vendor/bin/phpunit tests/integration -c tests/phpunit.integration.xml' && exit 1", "openapi": "generate-spec", "rector": "rector && composer cs:fix" }, diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 68a6009..712c986 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -5,10 +5,12 @@ namespace OCA\Office\AppInfo; use OCA\Office\Listener\AppMenuActionListener; +use OCA\Office\Listener\LoadAdditionalScriptsListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent; use OCP\Navigation\Events\LoadAdditionalEntriesEvent; final class Application extends App implements IBootstrap { @@ -22,6 +24,7 @@ public function __construct() { #[\Override] public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalEntriesEvent::class, AppMenuActionListener::class); + $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class); } #[\Override] diff --git a/lib/BackgroundJob/CleanupJob.php b/lib/BackgroundJob/CleanupJob.php new file mode 100644 index 0000000..67124bf --- /dev/null +++ b/lib/BackgroundJob/CleanupJob.php @@ -0,0 +1,40 @@ +setInterval(3600); // run hourly + } + + protected function run(mixed $argument): void { + $tokenIds = $this->wopiMapper->getExpiredTokenIds(self::BATCH_SIZE); + if (!empty($tokenIds)) { + $this->wopiMapper->deleteByIds($tokenIds); + } + + $lockIds = $this->wopiLockMapper->getExpiredLockIds(self::BATCH_SIZE); + if (!empty($lockIds)) { + $this->wopiLockMapper->deleteByIds($lockIds); + } + } +} diff --git a/lib/Controller/EditorController.php b/lib/Controller/EditorController.php new file mode 100644 index 0000000..ab81c01 --- /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', [], 'base'); + $response->setParams([ + 'editorUrl' => $editorUrl, + 'postMessageOrigin' => $wopi->getServerHost(), + 'fileName' => $file->getName(), + ]); + return $response; + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 0000000..5604c23 --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,77 @@ + $this->appConfig->getValueString(Application::APP_ID, 'wopi_url', ''), + 'public_wopi_url' => $this->appConfig->getValueString(Application::APP_ID, 'public_wopi_url', ''), + 'callback_url' => $this->appConfig->getValueString(Application::APP_ID, 'callback_url', ''), + 'disable_certificate_verification' => $this->appConfig->getValueString(Application::APP_ID, 'disable_certificate_verification', 'no'), + ]); + } + + /** + * Persist admin settings. + */ + #[AuthorizedAdminSetting(settings: \OCA\Office\Settings\Admin::class)] + #[FrontpageRoute(verb: 'POST', url: '/settings/admin')] + public function setAdmin(string $wopi_url, string $public_wopi_url = '', string $callback_url = '', string $disable_certificate_verification = 'no'): DataResponse { + foreach (['wopi_url' => $wopi_url, 'public_wopi_url' => $public_wopi_url, 'callback_url' => $callback_url] as $field => $value) { + if ($value !== '') { + $parsed = parse_url($value); + if ($parsed === false || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) { + return new DataResponse(['error' => "$field must use the http or https scheme"], Http::STATUS_BAD_REQUEST); + } + if (isset($parsed['user']) || isset($parsed['pass'])) { + return new DataResponse(['error' => "$field must not contain credentials"], Http::STATUS_BAD_REQUEST); + } + } + } + + $this->appConfig->setValueString(Application::APP_ID, 'wopi_url', rtrim($wopi_url, '/')); + $this->appConfig->setValueString(Application::APP_ID, 'public_wopi_url', rtrim($public_wopi_url, '/')); + $this->appConfig->setValueString(Application::APP_ID, 'callback_url', rtrim($callback_url, '/')); + $this->appConfig->setValueString(Application::APP_ID, 'disable_certificate_verification', $disable_certificate_verification === 'yes' ? 'yes' : 'no'); + + // The discovery document depends on wopi_url; a settings change must not + // serve actions cached from the previous editor server for up to CACHE_TTL. + $this->discoveryService->resetCache(); + + return new DataResponse([]); + } +} diff --git a/lib/Controller/ShareController.php b/lib/Controller/ShareController.php new file mode 100644 index 0000000..f4143bf --- /dev/null +++ b/lib/Controller/ShareController.php @@ -0,0 +1,208 @@ +shareManager->getShareByToken($shareToken); + } catch (ShareNotFound $e) { + $this->logger->debug('Share token not found: ' . $e->getMessage()); + return new JSONResponse(['error' => 'Share not found'], Http::STATUS_NOT_FOUND); + } + + // Password-protected share: NC core sets 'public_link_authenticated' in the session + // once the user has entered the password at /s/{token}. Check both legacy (string) + // and current (array of share IDs) formats, matching richdocuments' pattern. + // Authenticated users bypass the password check — they have a full NC session. + // getPassword() returns null (not '') for a share with no password at all - both + // must be treated as "no password", or every passwordless share incorrectly + // redirects unauthenticated guests away instead of letting them through. + if ($share->getPassword() !== null && $share->getPassword() !== '' && !$this->userSession->isLoggedIn()) { + $authenticated = $this->session->get('public_link_authenticated'); + $isAuthenticated = (is_array($authenticated) && in_array($share->getId(), $authenticated, true)) + || $authenticated === $share->getId(); + + if (!$isAuthenticated) { + // Redirect to the share page to complete the password challenge. + // After authentication, the user must reopen the file action. + $sharePageUrl = $this->urlGenerator->linkToRoute( + 'files_sharing.sharecontroller.showShare', + ['token' => $shareToken], + ); + return new RedirectResponse($sharePageUrl); + } + } + + if (($share->getPermissions() & \OCP\Constants::PERMISSION_READ) === 0) { + return new JSONResponse(['error' => 'Share is not readable'], Http::STATUS_FORBIDDEN); + } + + try { + $file = $this->resolveFile($share, $fileId, $path); + } catch (NotFoundException $e) { + return new JSONResponse(['error' => 'File not found'], Http::STATUS_NOT_FOUND); + } catch (NotPermittedException $e) { + return new JSONResponse(['error' => 'Access denied'], Http::STATUS_FORBIDDEN); + } + + $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'], + Http::STATUS_UNSUPPORTED_MEDIA_TYPE, + ); + } + + $canWrite = (bool)($share->getPermissions() & \OCP\Constants::PERMISSION_UPDATE); + $ownerUid = $share->getShareOwner(); + + try { + if ($this->userSession->isLoggedIn()) { + // Authenticated user visiting a share link: issue a full user token via their + // own folder. hideDownload is intentionally not applied — the share's download + // restriction targets unauthenticated third parties, not collaborators with + // direct NC access. + $wopi = $this->tokenManager->generateToken($file->getId()); + } else { + $hideDownload = $share->getHideDownload(); + + // Sanitize guestName: strip control chars, cap at 64 chars, default to 'Guest'. + // Authenticated user's display name overrides guestName param to prevent spoofing. + $guestName = trim(preg_replace('/[\x00-\x1f\x7f]/u', '', $guestName ?? '')); + $guestName = mb_substr($guestName, 0, 64); + $displayName = $guestName !== '' ? $guestName : 'Guest'; + + $wopi = $this->tokenManager->generateGuestToken( + fileId: $file->getId(), + ownerUid: $ownerUid, + guestName: $displayName, + canWrite: $canWrite, + hideDownload: $hideDownload, + ); + } + } catch (NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['error' => 'File not accessible'], Http::STATUS_FORBIDDEN); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['error' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $wopiSrc = $this->urlGenerator->linkToRouteAbsolute( + 'office.wopi.checkFileInfo', + ['fileId' => $file->getId()], + ); + + $editorUrl = $this->discoveryService->buildEditorUrl($urlsrc, $wopiSrc, $wopi->getToken()); + + $response = new TemplateResponse(Application::APP_ID, 'editor', [], 'base'); + $response->setParams([ + 'editorUrl' => $editorUrl, + 'postMessageOrigin' => $wopi->getServerHost(), + 'fileName' => $file->getName(), + ]); + return $response; + } + + /** + * Resolve the target file from a share. + * + * For file shares: returns the shared node directly. + * For folder shares: resolves by $fileId or $path within the shared folder. + * NC throws NotFoundException on path traversal attempts — no extra guard needed. + * + * @throws NotFoundException + * @throws NotPermittedException + */ + private function resolveFile(IShare $share, ?int $fileId, ?string $path): File { + $node = $share->getNode(); + + if ($node instanceof File) { + return $node; + } + + if (!$node instanceof Folder) { + throw new NotFoundException('Share points to an unsupported node type'); + } + + if ($path !== null) { + $resolved = $node->get($path); + } elseif ($fileId !== null) { + // getFirstNodeById scoped to $node — cannot escape the shared folder boundary. + $resolved = $node->getFirstNodeById($fileId); + } else { + throw new NotFoundException('Folder share requires fileId or path parameter'); + } + + if (!$resolved instanceof File) { + throw new NotFoundException('Resolved node is not a file'); + } + + return $resolved; + } +} diff --git a/lib/Controller/WopiController.php b/lib/Controller/WopiController.php new file mode 100644 index 0000000..fc682f2 --- /dev/null +++ b/lib/Controller/WopiController.php @@ -0,0 +1,601 @@ +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) { + } + + $hideDownload = $wopi->getHideDownload(); + + $isGuest = $wopi->isGuest(); + + return new JSONResponse([ + 'BaseFileName' => $file->getName(), + 'Size' => $file->getSize(), + 'Version' => (string)$file->getMTime(), + 'UserId' => $isGuest ? 'Guest-' . substr(md5($wopi->getToken()), 0, 8) : $wopi->getEditorUid(), + 'OwnerId' => $wopi->getOwnerUid(), + 'UserFriendlyName' => $displayName, + 'IsAnonymousUser' => $isGuest, + 'UserCanWrite' => $canWrite, + // PutRelativeFile (Save As) deferred to Phase 6. + 'UserCanNotWriteRelative' => true, + 'PostMessageOrigin' => $wopi->getServerHost(), + 'LastModifiedTime' => $this->toISO8601($file->getMTime()), + 'SupportsUpdate' => true, + 'SupportsLocks' => $this->lockManager->isLockProviderAvailable(), + 'SupportsGetLock' => true, + 'SupportsExtendedLockLength' => true, + 'SupportsRename' => !$isGuest, + 'UserCanRename' => !$isGuest, + 'EnableInsertRemoteImage' => !$isGuest, + 'EnableShare' => !$isGuest, + 'HideExportOption' => $hideDownload, + 'DisablePrint' => $hideDownload, + 'DisableExport' => $hideDownload, + 'HideUserList' => '', + 'EnableOwnerTermination' => $canWrite && !$isGuest, + 'HasContentRange' => true, + // 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) { + $empty = new Http\Response(); + $empty->addHeader('Content-Type', 'application/octet-stream'); + return $empty; + } + + $rangeHeader = $this->request->getHeader('Range'); + if ($rangeHeader !== '') { + return $this->getFileRange($file, $rangeHeader); + } + + $resource = $file->fopen('rb'); + if ($resource === false) { + return new JSONResponse(['message' => 'Could not open file for reading'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $response = new StreamResponse($resource); + $response->addHeader('Content-Type', 'application/octet-stream'); + return $response; + } + + /** + * 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); + } + + // Enforce WOPI lock: if a non-expired lock exists the client must supply the matching lock ID. + $lockId = $this->request->getHeader('X-WOPI-Lock'); + $existingLock = $this->wopiLockMapper->findByFileId($fileId); + if ($existingLock !== null && !$existingLock->isExpired()) { + if ($lockId !== $existingLock->getLockId()) { + return $this->lockConflict($existingLock->getLockId(), 'File is locked'); + } + } + + // Per WOPI spec §3.3.5.3: a non-empty file must be locked before it can be saved. + if (($existingLock === null || $existingLock->isExpired()) && $file->getSize() > 0) { + return $this->lockConflict('', 'A lock is required to save a non-empty file'); + } + + // Enforce optimistic version check: reject out-of-band edits that would be silently overwritten. + $clientVersion = $this->request->getHeader('X-WOPI-ItemVersion'); + if ($clientVersion !== '' && $clientVersion !== (string)$file->getMTime()) { + $response = new JSONResponse(['message' => 'Version mismatch'], Http::STATUS_CONFLICT); + $response->addHeader('X-WOPI-ItemVersion', (string)$file->getMTime()); + return $response; + } + + try { + $content = fopen('php://input', 'rb'); + try { + $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, static function () use ($file, $content): void { + $file->putContent($content); + }); + } finally { + // putContent() may close the stream internally; guard to avoid a warning. + if (is_resource($content)) { + fclose($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())]); + } + + /** + * WOPI lock operations — all arrive as POST wopi/files/{fileId} with + * an X-WOPI-Override header selecting the operation. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: 'wopi/files/{fileId}')] + public function executeOperation( + 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); + } + + if (!$wopi->getCanwrite()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $override = $this->request->getHeader('X-WOPI-Override'); + $lockId = $this->request->getHeader('X-WOPI-Lock'); + + return match ($override) { + 'LOCK' => $this->handleLock($fileId, $lockId), + 'UNLOCK' => $this->handleUnlock($fileId, $lockId), + 'REFRESH_LOCK' => $this->handleRefreshLock($fileId, $lockId), + 'GET_LOCK' => $this->handleGetLock($fileId), + 'RENAME_FILE' => $this->handleRenameFile($fileId, $wopi), + default => new JSONResponse(['error' => 'Unsupported WOPI operation'], Http::STATUS_NOT_IMPLEMENTED), + }; + } + + private function handleLock(int $fileId, string $lockId): Http\Response { + if ($lockId === '') { + return new JSONResponse(['error' => 'X-WOPI-Lock is required'], Http::STATUS_BAD_REQUEST); + } + + $oldLockId = $this->request->getHeader('X-WOPI-OldLock'); + $existing = $this->wopiLockMapper->findByFileId($fileId); + + if ($oldLockId !== '') { + // UnlockAndRelock: verify the old lock first, then replace with new. + if ($existing === null || $existing->isExpired() || $existing->getLockId() !== $oldLockId) { + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + return $this->lockConflict($current, 'Lock mismatch on UnlockAndRelock'); + } + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + if ($existing !== null && !$existing->isExpired()) { + if ($existing->getLockId() !== $lockId) { + return $this->lockConflict($existing->getLockId(), 'File is locked by another client'); + } + // Idempotent re-lock with same ID — refresh the TTL. + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + private function handleUnlock(int $fileId, string $lockId): Http\Response { + if ($lockId === '') { + return new JSONResponse(['error' => 'X-WOPI-Lock is required'], Http::STATUS_BAD_REQUEST); + } + + $existing = $this->wopiLockMapper->findByFileId($fileId); + + if ($existing === null || $existing->isExpired() || $existing->getLockId() !== $lockId) { + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + return $this->lockConflict($current, 'Lock mismatch'); + } + + $this->wopiLockMapper->delete($existing); + return $this->lockOkResponse(); + } + + private function handleRefreshLock(int $fileId, string $lockId): Http\Response { + if ($lockId === '') { + return new JSONResponse(['error' => 'X-WOPI-Lock is required'], Http::STATUS_BAD_REQUEST); + } + + $existing = $this->wopiLockMapper->findByFileId($fileId); + + if ($existing === null || $existing->isExpired() || $existing->getLockId() !== $lockId) { + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + return $this->lockConflict($current, 'Lock mismatch on RefreshLock'); + } + + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + private function handleGetLock(int $fileId): Http\Response { + $existing = $this->wopiLockMapper->findByFileId($fileId); + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + + $response = new JSONResponse([]); + $response->addHeader('X-WOPI-Lock', $current); + return $response; + } + + private function handleRenameFile(int $fileId, Wopi $wopi): Http\Response { + // Only authenticated users may rename; guests receive a 403. + if ($wopi->isGuest()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $requestedName = $this->request->getHeader('X-WOPI-RequestedName'); + if ($requestedName === '') { + return new JSONResponse(['error' => 'X-WOPI-RequestedName is required'], Http::STATUS_BAD_REQUEST); + } + + // X-WOPI-RequestedName is UTF-7 encoded; decode to UTF-8. + $newBaseName = (string)mb_convert_encoding($requestedName, 'UTF-8', 'UTF-7'); + + // Reject names that contain path separators or null bytes (path traversal guard). + // basename() only treats '/' as a separator on non-Windows PHP, so a + // backslash is checked explicitly too - Windows-separator names have no + // legitimate use in a rename, and NC storage treats '\' as an ordinary + // character rather than a path component. + if ($newBaseName === '' || $newBaseName !== basename($newBaseName) || str_contains($newBaseName, "\0") || str_contains($newBaseName, '\\')) { + $resp = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $resp->addHeader('X-WOPI-InvalidFileNameError', 'File name contains invalid characters'); + return $resp; + } + + // Lock check: an active lock must be matched by the client's X-WOPI-Lock. + $lockId = $this->request->getHeader('X-WOPI-Lock'); + $existing = $this->wopiLockMapper->findByFileId($fileId); + if ($existing !== null && !$existing->isExpired()) { + if ($lockId !== $existing->getLockId()) { + return $this->lockConflict($existing->getLockId(), 'File is locked'); + } + } + + try { + $file = $this->getFileForToken($wopi); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + $extension = pathinfo($file->getName(), PATHINFO_EXTENSION); + $newName = $extension !== '' ? $newBaseName . '.' . $extension : $newBaseName; + + // Reject if a file with the requested name already exists in the same directory. + try { + $file->getParent()->get($newName); + $response = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $response->addHeader('X-WOPI-InvalidFileNameError', 'A file with that name already exists'); + return $response; + } catch (NotFoundException) { + // Target name is free — proceed with rename. + } + + $newPath = $file->getParent()->getPath() . '/' . $newName; + + try { + try { + $this->lockManager->runInScope( + new LockContext($file, \OCP\Files\Lock\ILock::TYPE_APP, 'office'), + static function () use (&$file, $newPath): void { + $file = $file->move($newPath); + }, + ); + } catch (NoLockProviderException|PreConditionNotMetException) { + $file = $file->move($newPath); + } catch (OwnerLockedException) { + return $this->lockConflict('', 'File is locked by another user'); + } + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse(['Name' => $file->getName()]); + } + + private function lockOkResponse(): JSONResponse { + return new JSONResponse([]); + } + + private function lockConflict(string $currentLockId, string $reason): JSONResponse { + $response = new JSONResponse([], Http::STATUS_CONFLICT); + $response->addHeader('X-WOPI-Lock', $currentLockId); + $response->addHeader('X-WOPI-LockFailureReason', $reason); + return $response; + } + + 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, static function (\OCP\Files\Node $a, \OCP\Files\Node $b): int { + return ($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); + + // RFC 7233 §4.4: return 416 if range is unsatisfiable. + if ($start >= $size || $start > $end) { + $response = new Http\Response(); + $response->setStatus(416); + $response->addHeader('Content-Range', "bytes */{$size}"); + return $response; + } + + $length = (int)($end - $start + 1); + + $fp = $file->fopen('rb'); + if ($fp === false) { + $r = new Http\Response(); + $r->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + return $r; + } + try { + $rangeStream = fopen('php://temp', 'w+b'); + if ($rangeStream === false) { + $r = new Http\Response(); + $r->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + return $r; + } + try { + stream_copy_to_stream($fp, $rangeStream, $length, $start); + fseek($rangeStream, 0); + + $response = new StreamResponse($rangeStream); + $response->setStatus(Http::STATUS_PARTIAL_CONTENT); + $response->addHeader('Content-Type', 'application/octet-stream'); + $response->addHeader('Content-Range', "bytes {$start}-{$end}/{$size}"); + $response->addHeader('Content-Length', (string)$length); + $response->addHeader('Accept-Ranges', 'bytes'); + return $response; + } catch (\Throwable $e) { + fclose($rangeStream); + throw $e; + } + } finally { + fclose($fp); + } + } + + $resource = $file->fopen('rb'); + if ($resource === false) { + $r = new Http\Response(); + $r->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + return $r; + } + $response = new StreamResponse($resource); + $response->addHeader('Content-Type', 'application/octet-stream'); + return $response; + } + + /** + * 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'); + } +} diff --git a/lib/Db/Wopi.php b/lib/Db/Wopi.php new file mode 100644 index 0000000..dc591a3 --- /dev/null +++ b/lib/Db/Wopi.php @@ -0,0 +1,99 @@ +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('hideDownload', 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; + } +} diff --git a/lib/Db/WopiLock.php b/lib/Db/WopiLock.php new file mode 100644 index 0000000..d4a2121 --- /dev/null +++ b/lib/Db/WopiLock.php @@ -0,0 +1,42 @@ +addType('fileid', Types::INTEGER); + $this->addType('lockId', Types::STRING); + $this->addType('expiry', Types::INTEGER); + } + + public function isExpired(): bool { + return $this->expiry < time(); + } +} diff --git a/lib/Db/WopiLockMapper.php b/lib/Db/WopiLockMapper.php new file mode 100644 index 0000000..2d69299 --- /dev/null +++ b/lib/Db/WopiLockMapper.php @@ -0,0 +1,116 @@ + */ +class WopiLockMapper extends QBMapper { + // WOPI spec: locks are valid for 30 minutes; editor must refresh before expiry. + public const LOCK_TTL = 1800; + + public function __construct( + IDBConnection $db, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'office_wopi_locks', WopiLock::class); + } + + /** + * Return the current non-expired lock for a file, or null if none exists. + */ + public function findByFileId(int $fileId): ?WopiLock { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('office_wopi_locks') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + + try { + /** @var WopiLock $lock */ + $lock = $this->findEntity($qb); + return $lock; + } catch (\OCP\AppFramework\Db\DoesNotExistException) { + return null; + } + } + + /** + * Create or refresh a lock for a file. + * If a lock already exists for the file it is updated in-place. + */ + public function upsertLock(int $fileId, string $lockId): WopiLock { + $expiry = $this->timeFactory->getTime() + self::LOCK_TTL; + $existing = $this->findByFileId($fileId); + + if ($existing !== null) { + $existing->setLockId($lockId); + $existing->setExpiry($expiry); + /** @var WopiLock $updated */ + $updated = $this->update($existing); + return $updated; + } + + $lock = new WopiLock(); + $lock->setFileid($fileId); + $lock->setLockId($lockId); + $lock->setExpiry($expiry); + + try { + /** @var WopiLock $inserted */ + $inserted = $this->insert($lock); + return $inserted; + } catch (\OCP\DB\Exception $e) { + if ($e->getReason() !== \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + // A concurrent request raced us between SELECT and INSERT; update the row they created. + $raced = $this->findByFileId($fileId); + if ($raced === null) { + throw $e; + } + $raced->setLockId($lockId); + $raced->setExpiry($expiry); + return $this->update($raced); + } + } + + /** + * Return IDs of expired lock rows for the cleanup job. + * + * @return int[] + */ + public function getExpiredLockIds(int $limit = 500): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from('office_wopi_locks') + ->where($qb->expr()->lt('expiry', $qb->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT))) + ->setMaxResults($limit); + + return array_column($qb->executeQuery()->fetchAll(), 'id'); + } + + /** + * Delete lock rows by their primary-key IDs. + * + * @param int[] $ids + */ + public function deleteByIds(array $ids): void { + if (empty($ids)) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete('office_wopi_locks') + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + $qb->executeStatement(); + } +} diff --git a/lib/Db/WopiMapper.php b/lib/Db/WopiMapper.php new file mode 100644 index 0000000..ba56654 --- /dev/null +++ b/lib/Db/WopiMapper.php @@ -0,0 +1,165 @@ + */ +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, + bool $hideDownload, + 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, + 'hideDownload' => $hideDownload, + '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'); + } + + /** + * Delete WOPI rows by their primary-key IDs. + * + * @param int[] $ids + */ + public function deleteByIds(array $ids): void { + if (empty($ids)) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete('office_wopi') + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + $qb->executeStatement(); + } + + private function newExpiry(): int { + return $this->timeFactory->getTime() + self::TOKEN_TTL; + } +} 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 @@ + */ +class LoadAdditionalScriptsListener implements IEventListener { + public function __construct( + private DiscoveryService $discoveryService, + private IInitialState $initialState, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalScriptsEvent)) { + return; + } + + try { + $mimes = $this->discoveryService->getSupportedMimeTypes(); + } catch (\Throwable) { + $mimes = []; + } + + $this->initialState->provideInitialState('supported-mimes', $mimes); + + Util::addInitScript(Application::APP_ID, 'office-file-actions'); + } +} 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; + } +} diff --git a/lib/Migration/Version1001Date20260523000000.php b/lib/Migration/Version1001Date20260523000000.php new file mode 100644 index 0000000..9d1999a --- /dev/null +++ b/lib/Migration/Version1001Date20260523000000.php @@ -0,0 +1,54 @@ +hasTable('office_wopi_locks')) { + return null; + } + + $table = $schema->createTable('office_wopi_locks'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'unsigned' => true, + 'notnull' => true, + ]); + // One lock per file — fileid is unique. + $table->addColumn('fileid', Types::BIGINT, [ + 'notnull' => true, + ]); + // Opaque lock ID supplied by the WOPI client (up to 1024 chars per spec). + $table->addColumn('lock_id', Types::STRING, [ + 'length' => 1024, + 'notnull' => true, + ]); + // Unix timestamp when the lock expires (WOPI locks default to 30 minutes). + $table->addColumn('expiry', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['fileid'], 'office_wopi_locks_fileid_idx'); + + return $schema; + } +} diff --git a/lib/Migration/Version1002Date20260523000000.php b/lib/Migration/Version1002Date20260523000000.php new file mode 100644 index 0000000..c144347 --- /dev/null +++ b/lib/Migration/Version1002Date20260523000000.php @@ -0,0 +1,41 @@ +hasTable('office_wopi')) { + return null; + } + + $table = $schema->getTable('office_wopi'); + + if ($table->hasColumn('hide_download')) { + return null; + } + + $table->addColumn('hide_download', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + + return $schema; + } +} diff --git a/lib/Service/DiscoveryService.php b/lib/Service/DiscoveryService.php new file mode 100644 index 0000000..6d5a654 --- /dev/null +++ b/lib/Service/DiscoveryService.php @@ -0,0 +1,200 @@ +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 { + // Allowlist: file extensions are plain ASCII alnum — reject anything else to prevent XPath injection. + if (!preg_match('/^[a-zA-Z0-9]{1,20}$/', $extension)) { + return null; + } + + $xml = $this->get(); + if ($xml === null) { + return null; + } + + try { + // LIBXML_NONET prevents libxml from making network requests while parsing (e.g. external DTD subsets). + $parsed = new SimpleXMLElement($xml, LIBXML_NONET | LIBXML_NOCDATA); + } 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 ($actions === false || $actions === []) { + return null; + } + + return (string)$actions[0]['urlsrc']; + } + + /** + * Return all MIME types advertised by the editor's discovery XML. + * + * Returns an empty array if discovery XML is unavailable or unparseable. + * + * @return string[] + */ + public function getSupportedMimeTypes(): array { + $xml = $this->get(); + if ($xml === null) { + return []; + } + + try { + $parsed = new SimpleXMLElement($xml, LIBXML_NONET | LIBXML_NOCDATA); + } catch (\Exception $e) { + $this->logger->error('Failed to parse WOPI discovery XML: ' . $e->getMessage()); + return []; + } + + $names = $parsed->xpath('//app/@name'); + $mimes = []; + foreach ($names !== false ? $names : [] as $name) { + $mime = (string)$name; + if ($mime !== '' && str_contains($mime, '/')) { + $mimes[] = $mime; + } + } + + return array_values(array_unique($mimes)); + } + + /** + * 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 { + // Rewrite the internal wopi_url origin to the public_wopi_url origin so browsers can reach the editor. + // NC server fetches discovery via the internal Docker hostname (wopi_url); the browser must use the + // public hostname (public_wopi_url). Mirrors the eurooffice DocumentServerInternalUrl/DocumentServerUrl split. + $internalUrl = $this->getEditorUrl(); + $publicUrl = rtrim($this->appConfig->getValueString('office', 'public_wopi_url', ''), '/'); + if ($publicUrl !== '' && $internalUrl !== '' && str_starts_with($urlsrc, $internalUrl)) { + $urlsrc = $publicUrl . substr($urlsrc, strlen($internalUrl)); + } + + // Rewrite the WOPISrc origin to the callback_url origin so the editor server can reach + // Nextcloud for WOPI requests. WOPISrc is generated from the browser-facing request host, + // which may not be resolvable from inside the editor container. Mirrors the eurooffice + // "server address for internal requests" setting. + $callbackUrl = rtrim($this->appConfig->getValueString('office', 'callback_url', ''), '/'); + if ($callbackUrl !== '') { + $parts = parse_url($wopiSrc); + if (is_array($parts) && isset($parts['host'])) { + $origin = ($parts['scheme'] ?? 'http') . '://' . $parts['host'] + . (isset($parts['port']) ? ':' . $parts['port'] : ''); + $wopiSrc = $callbackUrl . substr($wopiSrc, strlen($origin)); + } + } + + // Strip WOPI template placeholders like leaving bare ? and & separators. + $url = preg_replace('/<[^>]+>/', '', $urlsrc); + $url = rtrim($url, '?&'); + // Preserve the ? already in the base URL if present; otherwise start the query string. + $separator = str_contains($url, '?') ? '&' : '?'; + $url .= $separator . 'wopisrc=' . urlencode($wopiSrc) . '&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; + } +} diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php new file mode 100644 index 0000000..1e22af3 --- /dev/null +++ b/lib/Settings/Admin.php @@ -0,0 +1,39 @@ + $this->appConfig->getValueString(Application::APP_ID, 'wopi_url', ''), + 'public_wopi_url' => $this->appConfig->getValueString(Application::APP_ID, 'public_wopi_url', ''), + 'callback_url' => $this->appConfig->getValueString(Application::APP_ID, 'callback_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; + } +} diff --git a/lib/TokenManager.php b/lib/TokenManager.php new file mode 100644 index 0000000..b0752aa --- /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, + bool $hideDownload, + ): 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, + hideDownload: $hideDownload, + serverHost: $serverHost, + ); + } +} diff --git a/package-lock.json b/package-lock.json index f69f833..d9f8418 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", "@nextcloud/vue": "^9.3.1", "vue": "^3.5.41", "vue-router": "^5.2.0" diff --git a/package.json b/package.json index 4eb27c8..0ce6021 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", "@nextcloud/vue": "^9.3.1", "vue": "^3.5.41", "vue-router": "^5.2.0" diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..873b67e --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + getCanwrite()]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + executeQuery()->fetchAll(), 'id')]]> + + + + + + + + + + + + + + + + + + + + + + + executeQuery()->fetchAll(), 'id')]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/psalm.xml b/psalm.xml index 36e7a87..1904e59 100644 --- a/psalm.xml +++ b/psalm.xml @@ -8,6 +8,7 @@ findUnusedBaselineEntry="true" findUnusedCode="true" phpVersion="8.3" + errorBaseline="psalm-baseline.xml" > diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..4adefb6 --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import Editor from './views/Editor.vue' + +const app = createApp(Editor) +app.mount('#office-editor') diff --git a/src/file-actions.ts b/src/file-actions.ts new file mode 100644 index 0000000..422991c --- /dev/null +++ b/src/file-actions.ts @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { DefaultType, Permission, registerFileAction } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' + +const supportedMimes: string[] = loadState('office', 'supported-mimes', []) + +registerFileAction({ + id: 'office-open', + + displayName: () => 'Open in Office', + + iconSvgInline: () => '', + + default: DefaultType.DEFAULT, + + order: 10, + + enabled: ({ nodes }) => { + if (nodes.length !== 1) { + return false + } + const node = nodes[0] + if ((node.permissions & Permission.READ) === 0) { + return false + } + return supportedMimes.includes(node.mime ?? '') + }, + + exec: async ({ nodes }) => { + const node = nodes[0] + const fileId = node.fileid + + if (fileId === undefined) { + return false + } + + if (isPublicShare()) { + const token = getSharingToken() + if (!token) { + return false + } + window.location.href = generateUrl('/apps/office/open/share/{token}', { token }) + + '?fileId=' + encodeURIComponent(String(fileId)) + } else { + window.location.href = generateUrl('/apps/office/open') + '?fileId=' + encodeURIComponent(String(fileId)) + } + + return true + }, +}) 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') diff --git a/src/settings/AdminSettings.vue b/src/settings/AdminSettings.vue new file mode 100644 index 0000000..b562da4 --- /dev/null +++ b/src/settings/AdminSettings.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/views/Editor.vue b/src/views/Editor.vue new file mode 100644 index 0000000..62d8fc1 --- /dev/null +++ b/src/views/Editor.vue @@ -0,0 +1,77 @@ + + +