Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
1029a26
feat: add database migration creating the office_wopi token table
moodyjmz May 22, 2026
380d19a
feat: add ExpiredTokenException for WOPI token validation
moodyjmz May 22, 2026
22f1d11
feat: add UnknownTokenException for WOPI token lookup failures
moodyjmz May 22, 2026
c275bab
feat: add Wopi entity mapping the office_wopi table
moodyjmz May 22, 2026
368b2f7
feat: add WopiMapper for token persistence and lookup
moodyjmz May 22, 2026
1a4eccc
feat: add DiscoveryService to fetch and cache WOPI discovery XML
moodyjmz May 22, 2026
9d22b92
feat: add TokenManager to generate WOPI access tokens for files
moodyjmz May 22, 2026
43e48fa
feat: add WopiController implementing CheckFileInfo, GetFile and PutFile
moodyjmz May 22, 2026
43fef66
✨ feat: register TokenManager in DI container with session user injec…
moodyjmz May 22, 2026
26fa4d7
✨ feat: add Admin settings class registering the office settings section
moodyjmz May 22, 2026
fc2967c
✨ feat: register Admin settings in Application bootstrap
moodyjmz May 22, 2026
fd5f927
✨ feat: add SettingsController with admin get/set endpoints
moodyjmz May 22, 2026
c791e6a
✨ feat: add admin settings PHP template
moodyjmz May 22, 2026
0eff613
✨ feat: add AdminSettings Vue component
moodyjmz May 22, 2026
03d4e12
✨ feat: add settings-admin entry point mounting AdminSettings
moodyjmz May 22, 2026
0cd74fc
✨ feat: add EditorController generating WOPI token and editor URL
moodyjmz May 22, 2026
14b6253
✨ feat: add editor PHP template passing editor URL to Vue
moodyjmz May 22, 2026
76af1e1
✨ feat: add Editor Vue component rendering the WOPI iframe with origi…
moodyjmz May 22, 2026
fdf588b
✨ feat: add editor entry point mounting Editor component
moodyjmz May 22, 2026
d38a99b
🔧 chore: add editor and settings-admin entry points to vite config
moodyjmz May 22, 2026
5847fc2
🐛 fix: correct query-string separator in buildEditorUrl
moodyjmz May 22, 2026
ec879f4
🔒 fix(security): prevent XPath injection via file extension in getUrlSrc
moodyjmz May 22, 2026
111f7ac
🔒 fix(security): validate wopi_url scheme in setAdmin to limit SSRF s…
moodyjmz May 22, 2026
9bdd22a
🔒 fix(security): fix range-request handling and close file handles in…
moodyjmz May 22, 2026
e1dc1de
✨ feat: add hourly CleanupJob to purge expired WOPI tokens
moodyjmz May 22, 2026
edfbca2
✨ feat: add office_wopi_locks migration for WOPI lock storage
moodyjmz May 23, 2026
be4705e
✨ feat: add WopiLock entity and WopiLockMapper
moodyjmz May 23, 2026
537cc5e
✨ feat: add WOPI lock operations (Lock, Unlock, RefreshLock, GetLock)
moodyjmz May 23, 2026
eff82e6
🔒 fix(security): enforce WOPI lock and version checks on PutFile
moodyjmz May 23, 2026
f4892bf
♻️ refactor: extend CleanupJob to also purge expired WOPI locks
moodyjmz May 23, 2026
ee5c268
🔧 chore: move background-jobs and settings registration to info.xml
moodyjmz May 23, 2026
dc4d26b
🐛 fix: guard fclose() on php://input after putContent()
moodyjmz May 23, 2026
36e1841
🐛 fix: set Content-Type: application/octet-stream on WOPI GetFile res…
moodyjmz May 23, 2026
cfde772
🔒 fix(security): reject WOPI lock operations on read-only tokens
moodyjmz May 23, 2026
498344d
✨ feat: add hide_download support to WOPI token row
moodyjmz May 23, 2026
c9bc0e7
✨ feat: add public ShareController for WOPI share-link access
moodyjmz May 23, 2026
9ca1edc
✨ feat: propagate hideDownload flags in CheckFileInfo response
moodyjmz May 23, 2026
c5c2e79
🔒 fix(security): sanitize guestName and document P3 known gaps
moodyjmz May 23, 2026
130e3cf
✨ feat: add DiscoveryService::getSupportedMimeTypes()
moodyjmz May 23, 2026
11d7f9e
✨ feat: issue user token for authenticated visitors on share links
moodyjmz May 23, 2026
790967c
✨ feat: inject file-actions script and MIME types on Files app pages
moodyjmz May 23, 2026
85ff4e2
✨ feat: register Files app file action to open documents in Office
moodyjmz May 23, 2026
b41dc82
🐛 fix: use history.back() on UI_Close instead of window.close()
moodyjmz May 23, 2026
2cc2d4d
📝 docs: replace scaffold README with WOPI backend developer guide
moodyjmz May 24, 2026
7f34b74
🐛 fix: resolve genuine Psalm errors across WOPI backend
moodyjmz May 24, 2026
40b1d5b
🔧 chore: upgrade Psalm to v6 and add baseline for false positives
moodyjmz May 24, 2026
981fc43
🎨 style: apply PHP CS Fixer (import ordering, docblock alignment)
moodyjmz May 24, 2026
ec35245
✨ feat(wopi): implement RenameFile and fix CheckFileInfo capability f…
moodyjmz May 24, 2026
5efa808
📝 docs: update README — WOPI spec compliance table, generic setup
moodyjmz May 24, 2026
eab87f5
🐛 fix: add missing Application import in LoadAdditionalScriptsListener
moodyjmz May 25, 2026
ff8ac75
🔒 fix(wopi): close C-1 path traversal, H-3 unlocked writes, H-2 lock …
moodyjmz May 25, 2026
574b173
🔧 fix(psalm): remove stale PossiblyUnusedMethod baseline entry for To…
moodyjmz May 25, 2026
ddb4d0c
chore(psalm): bump phpVersion to 8.3 and regenerate baseline
moodyjmz Jul 1, 2026
75d432d
chore(deps): register @nextcloud/sharing as a root dependency
moodyjmz Jul 1, 2026
2938f9a
style: fix eslint violations in AdminSettings and Editor
moodyjmz Jul 1, 2026
6c3cf5e
fix(settings): register admin settings routes as FrontpageRoute
moodyjmz Jul 1, 2026
5ca272e
feat(wopi): add public_wopi_url and callback_url for split-URL deploy…
moodyjmz Jul 1, 2026
2e09bb2
test(bootstrap): split standalone unit tests from in-server integrati…
moodyjmz Jul 1, 2026
c87c835
test(discovery): add DiscoveryService unit tests and discovery fixtures
moodyjmz Jul 1, 2026
8f6af8c
test(spec): add WOPI coverage matrix and spec-coverage report script
moodyjmz Jul 1, 2026
b8a6cdf
test(wopi): add WopiController lock and rename-file unit tests
moodyjmz Jul 2, 2026
57d0da1
test(token): add TokenManager unit tests
moodyjmz Jul 2, 2026
3bf15f6
fix(tests): make integration bootstrap independent of mount layout
moodyjmz Jul 2, 2026
5ea35cc
test(integration): add WOPI integration suite running in a server tree
moodyjmz Jul 2, 2026
629e474
fix(psalm): drop stale baseline entry for DiscoveryService::resetCache
moodyjmz Jul 2, 2026
8658770
fix(share): treat null and empty password as unprotected in openShare
moodyjmz Jul 2, 2026
f4bd57c
fix(wopi): reject backslash-based names in RenameFile's traversal guard
moodyjmz Jul 2, 2026
12f47f5
style: fix import ordering in SettingsController
moodyjmz Jul 2, 2026
4eb7bbd
chore(psalm): prune baseline/config entries left stale by the main re…
moodyjmz Aug 28, 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
9 changes: 8 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
},
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);
}
}
}
96 changes: 96 additions & 0 deletions lib/Controller/EditorController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?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\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());

} 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;
}
}
Loading
Loading