Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
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
0dbad67
🔒 fix(security): prevent XPath injection via file extension in getUrlSrc
moodyjmz May 22, 2026
959c413
🔒 fix(security): validate wopi_url scheme in setAdmin to limit SSRF s…
moodyjmz May 22, 2026
e339f7c
🔒 fix(security): fix range-request handling and close file handles in…
moodyjmz May 22, 2026
07bc401
✨ feat: add hourly CleanupJob to purge expired WOPI tokens
moodyjmz May 22, 2026
ab3924b
✨ feat: add office_wopi_locks migration for WOPI lock storage
moodyjmz May 23, 2026
734ee24
✨ feat: add WopiLock entity and WopiLockMapper
moodyjmz May 23, 2026
7c4cef3
✨ feat: add WOPI lock operations (Lock, Unlock, RefreshLock, GetLock)
moodyjmz May 23, 2026
147e7c8
🔒 fix(security): enforce WOPI lock and version checks on PutFile
moodyjmz May 23, 2026
386255c
♻️ refactor: extend CleanupJob to also purge expired WOPI locks
moodyjmz May 23, 2026
d0b0574
🔧 chore: move background-jobs and settings registration to info.xml
moodyjmz May 23, 2026
602a554
🐛 fix: guard fclose() on php://input after putContent()
moodyjmz May 23, 2026
92caf9e
🐛 fix: set Content-Type: application/octet-stream on WOPI GetFile res…
moodyjmz May 23, 2026
05c49fb
🔒 fix(security): reject WOPI lock operations on read-only tokens
moodyjmz May 23, 2026
f05e079
✨ feat: add hide_download support to WOPI token row
moodyjmz May 23, 2026
a926829
✨ feat: add public ShareController for WOPI share-link access
moodyjmz May 23, 2026
15508b7
✨ feat: propagate hideDownload flags in CheckFileInfo response
moodyjmz May 23, 2026
b229432
🔒 fix(security): sanitize guestName and document P3 known gaps
moodyjmz May 23, 2026
85de8bf
✨ feat: add DiscoveryService::getSupportedMimeTypes()
moodyjmz May 23, 2026
46e73a3
✨ feat: issue user token for authenticated visitors on share links
moodyjmz May 23, 2026
a344c9c
✨ feat: inject file-actions script and MIME types on Files app pages
moodyjmz May 23, 2026
d157337
✨ feat: register Files app file action to open documents in Office
moodyjmz May 23, 2026
dc53070
🐛 fix: use history.back() on UI_Close instead of window.close()
moodyjmz May 23, 2026
086e54f
📝 docs: replace scaffold README with WOPI backend developer guide
moodyjmz May 24, 2026
3696fac
🐛 fix: resolve genuine Psalm errors across WOPI backend
moodyjmz May 24, 2026
6a99943
🔧 chore: upgrade Psalm to v6 and add baseline for false positives
moodyjmz May 24, 2026
2ee173b
📦 chore: add package-lock.json
moodyjmz May 24, 2026
933de98
🎨 style: apply PHP CS Fixer (import ordering, docblock alignment)
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
139 changes: 124 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,134 @@
# Office

A template to get started with Nextcloud app development.
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).

## Usage
The overview UI is developed in `feat/euro-office-overview`. This branch adds the
WOPI backend on top of it. For combined local testing see `feat/overview-and-wopi`.

- 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** — browse, filter, search, and create office documents (see overview branch)
- **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)
- **Conflict-free close** — editor close returns the user to the overview via `history.back()`

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

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

---

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

---

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

Design decisions for Phase 3 are recorded in `PHASE3_DECISIONS.md`.
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="31" 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
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace OCA\Office\AppInfo;

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;

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

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

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);
}
}
}
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;
}
}
65 changes: 65 additions & 0 deletions lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?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 OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IAppConfig;
use OCP\IRequest;

class SettingsController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private IAppConfig $appConfig,
) {
parent::__construct($appName, $request);
}

/**
* Return the current admin settings.
*/
#[AuthorizedAdminSetting(settings: \OCA\Office\Settings\Admin::class)]
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/settings/admin')]
public function getAdmin(): DataResponse {
return new DataResponse([
'wopi_url' => $this->appConfig->getValueString(Application::APP_ID, 'wopi_url', ''),
'disable_certificate_verification' => $this->appConfig->getValueString(Application::APP_ID, 'disable_certificate_verification', 'no'),
]);
}

/**
* Persist admin settings.
*/
#[AuthorizedAdminSetting(settings: \OCA\Office\Settings\Admin::class)]
#[ApiRoute(verb: 'POST', url: '/settings/admin')]
public function setAdmin(string $wopi_url, string $disable_certificate_verification = 'no'): DataResponse {
if ($wopi_url !== '') {
$parsed = parse_url($wopi_url);
if ($parsed === false || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
return new DataResponse(['error' => 'wopi_url must use the http or https scheme'], Http::STATUS_BAD_REQUEST);
}
if (isset($parsed['user']) || isset($parsed['pass'])) {
return new DataResponse(['error' => 'wopi_url 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, 'disable_certificate_verification', $disable_certificate_verification === 'yes' ? 'yes' : 'no');

return new DataResponse([]);
}
}
Loading