Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
3cf35bc
feat: :package: add runtime deps and bump NC version requirement
moodyjmz May 22, 2026
0375e6f
feat: :sparkles: add TypeScript services for templates, files and config
moodyjmz May 22, 2026
74c1304
feat: :sparkles: add FileCard and TemplateSection Vue 3 components
moodyjmz May 22, 2026
c5b25a4
feat: :sparkles: add OfficeOverview main view and wire into App
moodyjmz May 22, 2026
49c7f1f
fix: :rotating_light: resolve all lint errors and add stylelint config
moodyjmz May 22, 2026
35b7d43
refactor: :recycle: apply post-review improvements to overview compon…
moodyjmz May 22, 2026
fbb5fa2
chore: bump max-version to 35 for NC35 compatibility
moodyjmz May 22, 2026
feb2cf7
feat: add database migration creating the office_wopi token table
moodyjmz May 22, 2026
27744db
feat: add ExpiredTokenException for WOPI token validation
moodyjmz May 22, 2026
a1d79ac
feat: add UnknownTokenException for WOPI token lookup failures
moodyjmz May 22, 2026
c6ed381
feat: add Wopi entity mapping the office_wopi table
moodyjmz May 22, 2026
f07d041
feat: add WopiMapper for token persistence and lookup
moodyjmz May 22, 2026
9fffd03
feat: add DiscoveryService to fetch and cache WOPI discovery XML
moodyjmz May 22, 2026
832bd30
feat: add TokenManager to generate WOPI access tokens for files
moodyjmz May 22, 2026
b40a9aa
feat: add WopiController implementing CheckFileInfo, GetFile and PutFile
moodyjmz May 22, 2026
6324175
✨ feat: register TokenManager in DI container with session user injec…
moodyjmz May 22, 2026
f615de6
✨ feat: add Admin settings class registering the office settings section
moodyjmz May 22, 2026
46bc891
✨ feat: register Admin settings in Application bootstrap
moodyjmz May 22, 2026
73b9062
✨ feat: add SettingsController with admin get/set endpoints
moodyjmz May 22, 2026
db593eb
✨ feat: add admin settings PHP template
moodyjmz May 22, 2026
4cbb069
✨ feat: add AdminSettings Vue component
moodyjmz May 22, 2026
91138ea
✨ feat: add settings-admin entry point mounting AdminSettings
moodyjmz May 22, 2026
1d89ba7
✨ feat: add EditorController generating WOPI token and editor URL
moodyjmz May 22, 2026
5c2eaff
✨ feat: add editor PHP template passing editor URL to Vue
moodyjmz May 22, 2026
5cdca91
✨ feat: add Editor Vue component rendering the WOPI iframe with origi…
moodyjmz May 22, 2026
65fcc1f
✨ feat: add editor entry point mounting Editor component
moodyjmz May 22, 2026
b970dc9
🔧 chore: add editor and settings-admin entry points to vite config
moodyjmz May 22, 2026
2600f1d
🐛 fix: correct query-string separator in buildEditorUrl
moodyjmz May 22, 2026
8907380
🔒 security: prevent XPath injection via file extension in getUrlSrc
moodyjmz May 22, 2026
cec316e
🔒 security: validate wopi_url scheme in setAdmin to limit SSRF surface
moodyjmz May 22, 2026
c9a89b7
🔒 security: fix range-request handling and close file handles in putFile
moodyjmz May 22, 2026
c328e1f
✨ feat: add hourly CleanupJob to purge expired WOPI tokens
moodyjmz May 22, 2026
8e268fd
✨ feat: add office_wopi_locks migration for WOPI lock storage
moodyjmz May 23, 2026
bf8dec7
✨ feat: add WopiLock entity and WopiLockMapper
moodyjmz May 23, 2026
d3b9c8e
✨ feat: add WOPI lock operations (Lock, Unlock, RefreshLock, GetLock)
moodyjmz May 23, 2026
bb2d73b
🔒 security: enforce WOPI lock and version checks on PutFile
moodyjmz May 23, 2026
b73bab7
♻️ refactor: extend CleanupJob to also purge expired WOPI locks
moodyjmz May 23, 2026
a0dc563
🔧 chore: move background-jobs and settings registration to info.xml
moodyjmz May 23, 2026
76518a5
🐛 fix: guard fclose() on php://input after putContent()
moodyjmz May 23, 2026
5a708d2
🐛 fix: set Content-Type: application/octet-stream on WOPI GetFile res…
moodyjmz May 23, 2026
24bb79a
🔒 security: reject WOPI lock operations on read-only tokens
moodyjmz May 23, 2026
60b586f
✨ feat: add hide_download support to WOPI token row
moodyjmz May 23, 2026
f2c4016
✨ feat: add public ShareController for WOPI share-link access
moodyjmz May 23, 2026
b20d144
✨ feat: propagate hideDownload flags in CheckFileInfo response
moodyjmz May 23, 2026
6c576ba
🔒 security: sanitize guestName and document P3 known gaps
moodyjmz May 23, 2026
c4315cf
✨ feat: add DiscoveryService::getSupportedMimeTypes()
moodyjmz May 23, 2026
b60a666
✨ feat: issue user token for authenticated visitors on share links
moodyjmz May 23, 2026
fd549d6
✨ feat: inject file-actions script and MIME types on Files app pages
moodyjmz May 23, 2026
1ef75ac
✨ feat: register Files app file action to open documents in Office
moodyjmz May 23, 2026
fde4d25
✨ feat: merge overview and WOPI branches
moodyjmz May 23, 2026
279e593
🐛 fix: navigate directly to in-app editor from overview
moodyjmz May 23, 2026
0e48dbb
🐛 fix: use history.back() on UI_Close instead of window.close()
moodyjmz May 23, 2026
9a8e3a8
🐛 fix: use document icon instead of cloud for app icon
moodyjmz May 23, 2026
5feb7b4
✨ feat: decouple openFile from hardcoded WOPI editor URL
moodyjmz May 24, 2026
20342e6
🔧 chore: wire PageController to inject real editor URL for WOPI branch
moodyjmz May 24, 2026
b1348bc
📝 docs: add combined branch README covering full overview + WOPI setup
moodyjmz May 24, 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
12 changes: 12 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,16 @@ module.exports = {
'jsdoc/require-jsdoc': 'off',
'vue/first-attribute-linebreak': 'off',
},
overrides: [
{
// @nextcloud/eslint-config uses @babel/eslint-parser for .vue files,
// which cannot parse TypeScript in <script setup lang="ts">.
// Override to use @typescript-eslint/parser as the inner parser.
files: ['**/*.vue'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
},
},
],
}
5 changes: 5 additions & 0 deletions .stylelintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
extends: [
'@nextcloud/stylelint-config',
],
}
71 changes: 71 additions & 0 deletions PHASE3_DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Phase 3 — Design Decisions

**Date:** 2026-05-23
**Scope:** WOPI share-link / guest token support

---

## D1 — User lock demotes guest write access

**Decision:** Yes — if a `TYPE_USER` lock exists on a file whose owner ≠ `editorUid`, the guest's `UserCanWrite` is forced to `false` in CheckFileInfo.

**Rationale:** `editorUid` is `null` for all guests, so they always fail the owner check. This is the safest default and matches both richdocuments and eurooffice-nextcloud behaviour. Two parties (a user and a guest) writing to the same file simultaneously would risk data loss.

---

## D2 — Share-level flags stored on the token row

**Decision:** `hide_download` is stamped onto the `oc_office_wopi` row at token-generation time (migration `1002`). It is never re-read from the share on subsequent requests.

**Rationale:** The `oc_office_wopi` token row is the right boundary — it is already the authority for `canwrite`, `owner_uid`, etc. Storing flags here avoids a per-request `IShareManager` lookup on every CheckFileInfo heartbeat. This is the richdocuments pattern.

**Trade-off accepted:** Share revocation mid-session is not enforced. A deleted/revoked share leaves its issued tokens valid until they expire (10 h TTL). Acceptable for Phase 3; can be revisited if needed.

---

## D3 — Share revocation window accepted

**Decision:** No per-request share re-validation. See D2.

**Rationale:** Adds latency and couples the WOPI token lifetime to the share object. The 10 h token TTL bounds the exposure window. eurooffice-nextcloud (which re-validates per request) uses a different architecture with no WOPI token table — not applicable here.

---

## D4 — Guests may issue WOPI locks (same as authenticated users)

**Decision:** No special guest check in `executeOperation`. Guests with `canWrite = true` may `LOCK`, `UNLOCK`, and `REFRESH_LOCK`.

**Rationale:** richdocuments allows guests to lock. Blocking guests from locking would cause EO to skip `PutFile` (EO locks before writing), breaking guest editing entirely. The 30-minute lock TTL and `CleanupJob` are the mitigations against lock-and-abandon abuse.

---

## D5 — Share scope for Phase 3

**Decision:** All three types handled in one shot:
- **File shares** — share node is a `File`; returned directly.
- **Folder shares** — share node is a `Folder`; resolved by `fileId` or relative `path` parameter. NC's `Folder::get()` / `getFirstNodeById()` throw `NotFoundException` on traversal — no extra guard needed.
- **Password-protected shares** — checked via `ISession::get('public_link_authenticated')`. Supports both legacy string format and current array-of-IDs format (matches richdocuments).

**Deferred:** Federated/remote shares. Internal user-through-share tokens (authenticated user viewing a share link gets a guest token for Phase 3; full user-token path deferred to Phase 4).

---

## Architecture notes

- `UserCanNotWriteRelative` = `$wopi->isGuest() || $wopi->getHideDownload()` — guests can never write relative (Save As to owner storage), and hideDownload also blocks it.
- `HideExportOption`, `DisablePrint`, `DisableExport` are all derived from `hideDownload` at CheckFileInfo time.
- `generateFileToken` (authenticated users) does not accept a `hideDownload` param — always `false`. Share-level restrictions only apply to the guest path.
- `files_sharing` app is not declared as a formal dependency (matches richdocuments). `IShareManager::getShareByToken()` returns a clean 404 if the share is not found.

---

## Known gaps (deferred)

**KG1 — Password-protected share UI** (P4)
`ShareController` returns `401 Password required` when the session lacks `public_link_authenticated`. There is currently no redirect to `/s/{shareToken}` or embedded password form. Users must visit `/s/{shareToken}` first, then navigate to the editor URL. A P4 redirect or embedded challenge is needed.

**KG2 — Authenticated-user-through-share** (P4)
An authenticated NC user visiting a share link currently gets a guest token. richdocuments issues a user token (with the user's own uid) for authenticated visitors. Deferred to P4 — requires a `$userSession->isLoggedIn()` branch in `ShareController`.

**KG3 — Federated/remote share support** (deferred, not P4)
`ShareController` accepts any share type returned by `IShareManager::getShareByToken()`. Federated shares (TYPE_REMOTE, TYPE_REMOTE_GROUP) are not tested. `getHideDownload()` is confirmed in `OCP\Share\IShare` and returns `false` for non-link shares by default, so no crash risk. Full federated support is out of scope.
142 changes: 127 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,137 @@
# Office

A template to get started with Nextcloud app development.
A Nextcloud app that integrates Euro-Office as a WOPI host, providing a document hub
and full-page editor. Nextcloud acts as the WOPI host (file storage, token authority,
lock manager); Euro-Office acts as the WOPI client (rendering, editing).

## Usage
> **Note:** This branch (`feat/overview-and-wopi`) is a combined test branch that
> merges `feat/euro-office-overview` and `feat/wopi-phase-4`. It is not a development
> target — work happens on those two branches independently.

- To get started easily use the [Appstore App generator](https://apps.nextcloud.com/developer/apps/generate) to
dynamically generate an App based on this repository with all the constants prefilled.
- Alternatively you can use the "Use this template" button on the top of this page to create a new repository based on
this repository. Afterwards adjust all the necessary constants like App ID, namespace, descriptions etc.
---

Once your app is ready follow the [instructions](https://nextcloudappstore.readthedocs.io/en/latest/developer.html) to
upload it to the Appstore.
## Features

## Resources
- **Overview page** at `/apps/office` — browse, filter, search, and create Documents,
Spreadsheets, Presentations, and Diagrams from one place
- **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
- **Full-page editor** at `/apps/office/open?fileId=N`
- **WOPI host implementation** — CheckFileInfo, GetFile, PutFile, Lock/Unlock/RefreshLock
- **Files app integration** — DEFAULT file action for all MIME types advertised by the editor
- **Public share support** — guest tokens for link-share access (Phase 3)
- **Clean navigation** — editor close (`history.back()`) returns the user to the overview

### Documentation for developers:
---

- General documentation and tutorials: https://nextcloud.com/developer
- Technical documentation: https://docs.nextcloud.com/server/latest/developer_manual
## Local development

### Help for developers:
### Requirements

- Official community chat: https://cloud.nextcloud.com/call/xs25tz5y
- Official community forum: https://help.nextcloud.com/c/dev/11
- [nextcloud-docker-dev](https://github.com/juliushaertl/nextcloud-docker-dev)
- NC ≥ 33
- Node 24 / npm 11
- Euro-Office server reachable from the NC container

### 1. Mount the app into the container

Add to `nextcloud-docker-dev/docker-compose.override.yml`:

```yaml
services:
nextcloud:
volumes:
- /path/to/office:/var/www/html/apps-extra/office
```

Restart the container after saving.

### 2. Enable the app and disable eurooffice

```bash
docker exec -u www-data nextcloud-docker-dev-nextcloud-1 \
php occ app:enable office

# Disable eurooffice so it does not compete for the DEFAULT file action
docker exec -u www-data nextcloud-docker-dev-nextcloud-1 \
php occ app:disable eurooffice
```

### 3. Build the frontend

```bash
npm ci
npm run build # one-off build
npm run watch # rebuild on file changes
```

---

## How it works

### User flow

1. User navigates to `/apps/office`
2. Overview lists all office files, grouped by type
3. User clicks a file → navigated to `/apps/office/open?fileId=N`
4. `EditorController` mints a WOPI token and builds the editor URL
5. Euro-Office loads the file via the WOPI protocol
6. User closes the editor → `history.back()` returns to the overview

### 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 |
|---|---|
| `PageController` | Renders overview page; injects editor URL into NC initial state |
| `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 |

---

## Public share support (Phase 3)

Share link visitors (`/s/{token}`) receive a guest WOPI token via `ShareController`.
File access, locking, and `CheckFileInfo` flags (`HideExportOption`, `DisablePrint`,
`UserCanWrite`, etc.) are derived from the share's permissions and `hide_download`
flag at token-issue time.

**Known gaps** — see `PHASE3_DECISIONS.md` for full context:

- **KG1** — Password-protected shares: no redirect to `/s/{token}` password page yet.
Users must authenticate at `/s/{token}` before navigating to the editor.
- **KG2** — Authenticated users through share links receive guest tokens.
Full user-token path deferred to Phase 4.
- **KG3** — Federated/remote shares not tested.
8 changes: 7 additions & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@
<category>office</category>
<bugs>https://github.com/nextcloud/office</bugs>
<dependencies>
<nextcloud min-version="31" max-version="32"/>
<nextcloud min-version="33" 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
5 changes: 4 additions & 1 deletion img/app-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion img/app.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@

namespace OCA\Office\AppInfo;

use OCA\Office\Listener\LoadAdditionalScriptsListener;
use OCA\Office\TokenManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

class Application extends App implements IBootstrap {
public const APP_ID = 'office';
Expand All @@ -18,6 +26,18 @@ public function __construct() {
}

public function register(IRegistrationContext $context): void {
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class);

$context->registerService(TokenManager::class, static function ($c) {
return new TokenManager(
$c->get(IRootFolder::class),
$c->get(\OCA\Office\Db\WopiMapper::class),
$c->get(IURLGenerator::class),
$c->get(IEventDispatcher::class),
$c->get(LoggerInterface::class),
$c->get(IUserSession::class)->getUser()?->getUID(),
);
});
}

public function boot(IBootContext $context): void {
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);
}
}
}
Loading
Loading