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
b573ea4
feat: add database migration creating the office_wopi token table
moodyjmz May 22, 2026
8a56d6e
feat: add ExpiredTokenException for WOPI token validation
moodyjmz May 22, 2026
af336a5
feat: add UnknownTokenException for WOPI token lookup failures
moodyjmz May 22, 2026
32aab4a
feat: add Wopi entity mapping the office_wopi table
moodyjmz May 22, 2026
16a8ba0
feat: add WopiMapper for token persistence and lookup
moodyjmz May 22, 2026
99d40f8
feat: add DiscoveryService to fetch and cache WOPI discovery XML
moodyjmz May 22, 2026
b5c087d
feat: add TokenManager to generate WOPI access tokens for files
moodyjmz May 22, 2026
aa0c9fb
feat: add WopiController implementing CheckFileInfo, GetFile and PutFile
moodyjmz May 22, 2026
ddfdbf7
✨ feat: register TokenManager in DI container with session user injec…
moodyjmz May 22, 2026
add97c5
✨ feat: add Admin settings class registering the office settings section
moodyjmz May 22, 2026
b564962
✨ feat: register Admin settings in Application bootstrap
moodyjmz May 22, 2026
4a3c498
✨ feat: add SettingsController with admin get/set endpoints
moodyjmz May 22, 2026
d18761b
✨ feat: add admin settings PHP template
moodyjmz May 22, 2026
b39b1e2
✨ feat: add AdminSettings Vue component
moodyjmz May 22, 2026
57a6e4a
✨ feat: add settings-admin entry point mounting AdminSettings
moodyjmz May 22, 2026
7921a7d
✨ feat: add EditorController generating WOPI token and editor URL
moodyjmz May 22, 2026
d373ff6
✨ feat: add editor PHP template passing editor URL to Vue
moodyjmz May 22, 2026
cf116a8
✨ feat: add Editor Vue component rendering the WOPI iframe with origi…
moodyjmz May 22, 2026
90de35c
✨ feat: add editor entry point mounting Editor component
moodyjmz May 22, 2026
737cb64
🔧 chore: add editor and settings-admin entry points to vite config
moodyjmz May 22, 2026
74dc784
🐛 fix: correct query-string separator in buildEditorUrl
moodyjmz May 22, 2026
4d8c58e
🔒 fix(security): prevent XPath injection via file extension in getUrlSrc
moodyjmz May 22, 2026
056dcb6
🔒 fix(security): validate wopi_url scheme in setAdmin to limit SSRF s…
moodyjmz May 22, 2026
aa2e8f8
🔒 fix(security): fix range-request handling and close file handles in…
moodyjmz May 22, 2026
12cda14
✨ feat: add hourly CleanupJob to purge expired WOPI tokens
moodyjmz May 22, 2026
44682a1
✨ feat: add office_wopi_locks migration for WOPI lock storage
moodyjmz May 23, 2026
a63fd7e
✨ feat: add WopiLock entity and WopiLockMapper
moodyjmz May 23, 2026
39879f3
✨ feat: add WOPI lock operations (Lock, Unlock, RefreshLock, GetLock)
moodyjmz May 23, 2026
ccac67f
🔒 fix(security): enforce WOPI lock and version checks on PutFile
moodyjmz May 23, 2026
4664bef
♻️ refactor: extend CleanupJob to also purge expired WOPI locks
moodyjmz May 23, 2026
79c8629
🔧 chore: move background-jobs and settings registration to info.xml
moodyjmz May 23, 2026
40d891f
🐛 fix: guard fclose() on php://input after putContent()
moodyjmz May 23, 2026
a212825
🐛 fix: set Content-Type: application/octet-stream on WOPI GetFile res…
moodyjmz May 23, 2026
51e4fc5
🔒 fix(security): reject WOPI lock operations on read-only tokens
moodyjmz May 23, 2026
b452926
✨ feat: add hide_download support to WOPI token row
moodyjmz May 23, 2026
a2e3aa0
✨ feat: add public ShareController for WOPI share-link access
moodyjmz May 23, 2026
d7ff5c6
✨ feat: propagate hideDownload flags in CheckFileInfo response
moodyjmz May 23, 2026
48fa1ef
🔒 fix(security): sanitize guestName and document P3 known gaps
moodyjmz May 23, 2026
de677b0
✨ feat: add DiscoveryService::getSupportedMimeTypes()
moodyjmz May 23, 2026
b902420
✨ feat: issue user token for authenticated visitors on share links
moodyjmz May 23, 2026
82907b8
✨ feat: inject file-actions script and MIME types on Files app pages
moodyjmz May 23, 2026
baffa82
✨ feat: register Files app file action to open documents in Office
moodyjmz May 23, 2026
3da6dbe
🐛 fix: use history.back() on UI_Close instead of window.close()
moodyjmz May 23, 2026
d29de64
📝 docs: replace scaffold README with WOPI backend developer guide
moodyjmz May 24, 2026
8bce032
🐛 fix: resolve genuine Psalm errors across WOPI backend
moodyjmz May 24, 2026
8391e73
🔧 chore: upgrade Psalm to v6 and add baseline for false positives
moodyjmz May 24, 2026
378ad7c
🎨 style: apply PHP CS Fixer (import ordering, docblock alignment)
moodyjmz May 24, 2026
9785334
✨ feat(wopi): implement RenameFile and fix CheckFileInfo capability f…
moodyjmz May 24, 2026
25b6d50
📝 docs: update README — WOPI spec compliance table, generic setup
moodyjmz May 24, 2026
3138d51
🐛 fix: add missing Application import in LoadAdditionalScriptsListener
moodyjmz May 25, 2026
71e4f08
🔒 fix(wopi): close C-1 path traversal, H-3 unlocked writes, H-2 lock …
moodyjmz May 25, 2026
807d718
🔧 fix(psalm): remove stale PossiblyUnusedMethod baseline entry for To…
moodyjmz May 25, 2026
fb4ff7f
chore(assets): rebuild compiled js/css bundles
moodyjmz Jul 1, 2026
50754ca
chore(psalm): bump phpVersion to 8.3 and regenerate baseline
moodyjmz Jul 1, 2026
f773ecc
chore(deps): register @nextcloud/sharing as a root dependency
moodyjmz Jul 1, 2026
5bee9dd
style: fix eslint violations in AdminSettings and Editor
moodyjmz Jul 1, 2026
6908e9d
fix(settings): register admin settings routes as FrontpageRoute
moodyjmz Jul 1, 2026
f35352b
feat(wopi): add public_wopi_url and callback_url for split-URL deploy…
moodyjmz Jul 1, 2026
2a5b4e2
test(bootstrap): split standalone unit tests from in-server integrati…
moodyjmz Jul 1, 2026
7dd6831
test(discovery): add DiscoveryService unit tests and discovery fixtures
moodyjmz Jul 1, 2026
ede31c8
test(spec): add WOPI coverage matrix and spec-coverage report script
moodyjmz Jul 1, 2026
b432ed9
test(wopi): add WopiController lock and rename-file unit tests
moodyjmz Jul 2, 2026
43bfe38
test(token): add TokenManager unit tests
moodyjmz Jul 2, 2026
19ec8b3
fix(tests): make integration bootstrap independent of mount layout
moodyjmz Jul 2, 2026
754b62d
test(integration): add WOPI integration suite running in a server tree
moodyjmz Jul 2, 2026
54596a0
fix(psalm): drop stale baseline entry for DiscoveryService::resetCache
moodyjmz Jul 2, 2026
c021e13
fix(share): treat null and empty password as unprotected in openShare
moodyjmz Jul 2, 2026
b3fed25
fix(wopi): reject backslash-based names in RenameFile's traversal guard
moodyjmz Jul 2, 2026
4ec1a9a
style: fix import ordering in SettingsController
moodyjmz Jul 2, 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
149 changes: 119 additions & 30 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 All @@ -55,26 +63,107 @@ npm run watch # rebuild on file changes

---

## Editor integration
## How it works

### WOPI flow

The overview opens files via NC's file shortlink (`/f/{fileid}`), which
redirects to the Files app and triggers the default file action for the
installed office editor.
```
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

To inject a custom editor URL, a backend component can call
`provideInitialState('office', 'editor-url', $url)` before the page renders.
The frontend reads this via `loadState('office', 'editor-url', null)` and, when
present, navigates directly to that URL instead of `/f/{fileid}`.
| 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 |

---

## 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 |

```
/apps/office
└── PageController::index() Renders the SPA shell
└── OfficeOverview.vue Full Vue 3 SPA
├── officeFiles.ts WebDAV file listing via @nextcloud/files
├── templates.ts Template discovery and file creation
└── config.ts User preference persistence (grid/list view)
```
---

## 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.

---

## 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="35" max-version="35"/>
</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
Loading
Loading