Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
62cc903
feat: add database migration creating the office_wopi token table
moodyjmz May 22, 2026
57cd385
feat: add ExpiredTokenException for WOPI token validation
moodyjmz May 22, 2026
89f881a
feat: add UnknownTokenException for WOPI token lookup failures
moodyjmz May 22, 2026
b2410d9
feat: add Wopi entity mapping the office_wopi table
moodyjmz May 22, 2026
364d5d7
feat: add WopiMapper for token persistence and lookup
moodyjmz May 22, 2026
17b7e48
feat: add DiscoveryService to fetch and cache WOPI discovery XML
moodyjmz May 22, 2026
f7d285a
feat: add TokenManager to generate WOPI access tokens for files
moodyjmz May 22, 2026
17225a0
feat: add WopiController implementing CheckFileInfo, GetFile and PutFile
moodyjmz May 22, 2026
93cec9d
✨ feat: register TokenManager in DI container with session user injec…
moodyjmz May 22, 2026
5283d9e
✨ feat: add Admin settings class registering the office settings section
moodyjmz May 22, 2026
5ed24cf
✨ feat: register Admin settings in Application bootstrap
moodyjmz May 22, 2026
d4ff138
✨ feat: add SettingsController with admin get/set endpoints
moodyjmz May 22, 2026
eda1a67
✨ feat: add admin settings PHP template
moodyjmz May 22, 2026
fc54876
✨ feat: add AdminSettings Vue component
moodyjmz May 22, 2026
24763fe
✨ feat: add settings-admin entry point mounting AdminSettings
moodyjmz May 22, 2026
84dae5b
✨ feat: add EditorController generating WOPI token and editor URL
moodyjmz May 22, 2026
220426e
✨ feat: add editor PHP template passing editor URL to Vue
moodyjmz May 22, 2026
c3f409b
✨ feat: add Editor Vue component rendering the WOPI iframe with origi…
moodyjmz May 22, 2026
84bc518
✨ feat: add editor entry point mounting Editor component
moodyjmz May 22, 2026
acde8d0
🔧 chore: add editor and settings-admin entry points to vite config
moodyjmz May 22, 2026
30e82f7
🐛 fix: correct query-string separator in buildEditorUrl
moodyjmz May 22, 2026
50e5082
🔒 fix(security): prevent XPath injection via file extension in getUrlSrc
moodyjmz May 22, 2026
dcee5c1
🔒 fix(security): validate wopi_url scheme in setAdmin to limit SSRF s…
moodyjmz May 22, 2026
00092c7
🔒 fix(security): fix range-request handling and close file handles in…
moodyjmz May 22, 2026
f9ef09d
✨ feat: add hourly CleanupJob to purge expired WOPI tokens
moodyjmz May 22, 2026
b06c96f
✨ feat: add office_wopi_locks migration for WOPI lock storage
moodyjmz May 23, 2026
3da9df9
✨ feat: add WopiLock entity and WopiLockMapper
moodyjmz May 23, 2026
6edbf71
✨ feat: add WOPI lock operations (Lock, Unlock, RefreshLock, GetLock)
moodyjmz May 23, 2026
478ef46
🔒 fix(security): enforce WOPI lock and version checks on PutFile
moodyjmz May 23, 2026
cfb166d
♻️ refactor: extend CleanupJob to also purge expired WOPI locks
moodyjmz May 23, 2026
eb295ce
🔧 chore: move background-jobs and settings registration to info.xml
moodyjmz May 23, 2026
76c305f
🐛 fix: guard fclose() on php://input after putContent()
moodyjmz May 23, 2026
e79a205
🐛 fix: set Content-Type: application/octet-stream on WOPI GetFile res…
moodyjmz May 23, 2026
39a8b8a
🔒 fix(security): reject WOPI lock operations on read-only tokens
moodyjmz May 23, 2026
1fa66f5
✨ feat: add hide_download support to WOPI token row
moodyjmz May 23, 2026
0e5b312
✨ feat: add public ShareController for WOPI share-link access
moodyjmz May 23, 2026
80ceae7
✨ feat: propagate hideDownload flags in CheckFileInfo response
moodyjmz May 23, 2026
aed5d98
🔒 fix(security): sanitize guestName and document P3 known gaps
moodyjmz May 23, 2026
b742219
✨ feat: add DiscoveryService::getSupportedMimeTypes()
moodyjmz May 23, 2026
94d53a2
✨ feat: issue user token for authenticated visitors on share links
moodyjmz May 23, 2026
ab61728
✨ feat: inject file-actions script and MIME types on Files app pages
moodyjmz May 23, 2026
5b5582c
✨ feat: register Files app file action to open documents in Office
moodyjmz May 23, 2026
7ce6645
🐛 fix: use history.back() on UI_Close instead of window.close()
moodyjmz May 23, 2026
bb72b4e
📝 docs: replace scaffold README with WOPI backend developer guide
moodyjmz May 24, 2026
e4fca16
🐛 fix: resolve genuine Psalm errors across WOPI backend
moodyjmz May 24, 2026
bba5691
🔧 chore: upgrade Psalm to v6 and add baseline for false positives
moodyjmz May 24, 2026
2370274
🎨 style: apply PHP CS Fixer (import ordering, docblock alignment)
moodyjmz May 24, 2026
5fa6685
✨ feat(wopi): implement RenameFile and fix CheckFileInfo capability f…
moodyjmz May 24, 2026
263a10a
📝 docs: update README — WOPI spec compliance table, generic setup
moodyjmz May 24, 2026
90f41ff
🐛 fix: add missing Application import in LoadAdditionalScriptsListener
moodyjmz May 25, 2026
25c02c7
🔒 fix(wopi): close C-1 path traversal, H-3 unlocked writes, H-2 lock …
moodyjmz May 25, 2026
05e6154
🔧 fix(psalm): remove stale PossiblyUnusedMethod baseline entry for To…
moodyjmz May 25, 2026
54a3089
chore(psalm): bump phpVersion to 8.3 and regenerate baseline
moodyjmz Jul 1, 2026
54c57b5
chore(deps): register @nextcloud/sharing as a root dependency
moodyjmz Jul 1, 2026
040af21
style: fix eslint violations in AdminSettings and Editor
moodyjmz Jul 1, 2026
96be5a4
fix(settings): register admin settings routes as FrontpageRoute
moodyjmz Jul 1, 2026
1acda06
feat(wopi): add public_wopi_url and callback_url for split-URL deploy…
moodyjmz Jul 1, 2026
108ccd2
test(bootstrap): split standalone unit tests from in-server integrati…
moodyjmz Jul 1, 2026
2d89653
test(discovery): add DiscoveryService unit tests and discovery fixtures
moodyjmz Jul 1, 2026
a7b1788
test(spec): add WOPI coverage matrix and spec-coverage report script
moodyjmz Jul 1, 2026
a211d1e
test(wopi): add WopiController lock and rename-file unit tests
moodyjmz Jul 2, 2026
c77a5ba
test(token): add TokenManager unit tests
moodyjmz Jul 2, 2026
f207385
fix(tests): make integration bootstrap independent of mount layout
moodyjmz Jul 2, 2026
8965a4e
test(integration): add WOPI integration suite running in a server tree
moodyjmz Jul 2, 2026
df36b07
fix(psalm): drop stale baseline entry for DiscoveryService::resetCache
moodyjmz Jul 2, 2026
8848aa4
fix(share): treat null and empty password as unprotected in openShare
moodyjmz Jul 2, 2026
2fa0aff
fix(wopi): reject backslash-based names in RenameFile's traversal guard
moodyjmz Jul 2, 2026
74fa78b
style: fix import ordering in SettingsController
moodyjmz Jul 2, 2026
c445761
chore(psalm): prune baseline/config entries left stale by the main re…
moodyjmz Aug 28, 2026
90cd043
fix(wopi): submit editor iframe via hidden POST form, not GET src
moodyjmz Sep 7, 2026
a015055
fix(tests): repoint OCP stub autoload at the vendor-bin namespace
moodyjmz Sep 7, 2026
e16745e
fix: 🐛 allow the editor origin in the editor page CSP
moodyjmz Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 129 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -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()`

---

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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).
6 changes: 6 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
<dependencies>
<nextcloud min-version="36" max-version="36"/>
</dependencies>
<background-jobs>
<job>OCA\Office\BackgroundJob\CleanupJob</job>
</background-jobs>
<settings>
<admin>OCA\Office\Settings\Admin</admin>
</settings>
<navigations>
<navigation>
<id>office</id>
Expand Down
15 changes: 14 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
"OCA\\Office\\": "lib/"
}
},
"autoload-dev": {
"psr-4": {
"OCP\\": "vendor-bin/nextcloud-ocp/vendor/nextcloud/ocp/OCP/",
"Psr\\EventDispatcher\\": "vendor-bin/nextcloud-ocp/vendor/psr/event-dispatcher/src/",
"Psr\\Http\\Message\\": "vendor-bin/nextcloud-ocp/vendor/psr/http-message/src/",
"Psr\\Http\\Client\\": "vendor-bin/nextcloud-ocp/vendor/psr/http-client/src/",
"Psr\\Container\\": "vendor-bin/nextcloud-ocp/vendor/psr/container/src/",
"Psr\\Log\\": "vendor-bin/nextcloud-ocp/vendor/psr/log/src/",
"Psr\\Clock\\": "vendor-bin/nextcloud-ocp/vendor/psr/clock/src/",
"OCA\\Office\\Tests\\": "tests/"
}
},
"scripts": {
"post-install-cmd": [
"@composer bin all install --ansi"
Expand All @@ -24,7 +36,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"
},
Expand Down
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
Expand Down
40 changes: 40 additions & 0 deletions lib/BackgroundJob/CleanupJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Office\BackgroundJob;

use OCA\Office\Db\WopiLockMapper;
use OCA\Office\Db\WopiMapper;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;

class CleanupJob extends TimedJob {
private const BATCH_SIZE = 500;

public function __construct(
ITimeFactory $time,
private WopiMapper $wopiMapper,
private WopiLockMapper $wopiLockMapper,
) {
parent::__construct($time);
$this->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);
}
}
}
99 changes: 99 additions & 0 deletions lib/Controller/EditorController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Office\Controller;

use OCA\Office\AppInfo\Application;
use OCA\Office\Http\EditorPagePolicy;
use OCA\Office\Service\DiscoveryService;
use OCA\Office\TokenManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;

class EditorController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private IRootFolder $rootFolder,
private TokenManager $tokenManager,
private DiscoveryService $discoveryService,
private IURLGenerator $urlGenerator,
private LoggerInterface $logger,
private ?string $userId,
) {
parent::__construct($appName, $request);
}

/**
* Open a file in the WOPI editor.
*
* Returns an HTML page that embeds the editor in a full-screen iframe.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/open')]
public function open(int $fileId): TemplateResponse|JSONResponse {
try {
$userFolder = $this->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());
$policy = EditorPagePolicy::forEditorUrl($editorUrl);

} 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(),
]);
$response->setContentSecurityPolicy($policy);
return $response;
}
}
Loading
Loading