Skip to content

Feat/wopi phase 5 - #8

Draft
moodyjmz wants to merge 72 commits into
mainfrom
feat/wopi-phase-5
Draft

moodyjmz wants to merge 72 commits into
mainfrom
feat/wopi-phase-5

Conversation

@moodyjmz

Copy link
Copy Markdown
Contributor

does MS WOPI support more

@moodyjmz moodyjmz self-assigned this May 25, 2026
@moodyjmz
moodyjmz marked this pull request as draft May 26, 2026 09:14
@moodyjmz
moodyjmz force-pushed the feat/wopi-phase-5 branch 4 times, most recently from 3d8b00a to 4ec1a9a Compare July 2, 2026 12:35
@moodyjmz moodyjmz mentioned this pull request Sep 7, 2026
6 of 8 tasks
@moodyjmz

moodyjmz commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

(Carried over from #163, which is now closed in favor of this branch.)

Found while running a live cross-replica EO test against this code: EditorController::open() never registers a CSP frame-src/form-action allowance for the editor origin. The default 'self' policy blocks the WOPI editor iframe/form outright in any real browser — this isn't specific to a test setup.

A real fix would derive the allowed origin from $editorUrl (already computed a few lines above in open()) rather than hardcoding anything.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…tion

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…n validation

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
After stripping WOPI template placeholders the URL may have no '?'
yet, so appending with '&' produced a malformed URL like
`https://editor/path&wopisrc=...`. Check for an existing '?' and
use '?' as the separator when absent.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
moodyjmz and others added 28 commits October 23, 2026 14:10
Covers: features, local dev setup, WOPI protocol flow, key classes,
Phase 3 public share support, known gaps, and architecture decisions.

Signed-off-by: James Manuel <james.manuel@nextcloud.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
- LoadAdditionalScriptsListener: replace deprecated IInitialStateService
  with IInitialState (drops app-ID arg from provideInitialState)
- SettingsController: fix Settings\Admin namespace (was resolving to wrong
  FQN), use strict === false check on parse_url result
- EditorController + ShareController: replace invalid 'blank' TemplateResponse
  render type with 'base'
- ShareController: add RedirectResponse to return type union; use !== ''
  for password check instead of truthy comparison
- WopiController: guard fopen() calls against false before passing to
  StreamResponse; add explicit return type on putFile closure; type usort
  callback parameters; cast range $length to int
- Application: remove manual TokenManager service registration — NC DI
  autowires it including the ?string $userId convention
- TokenManager: remove unused $logger property
- DiscoveryService: use strict === false / === [] checks on xpath() results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Psalm 5.x crashes on PHP 8.5 with "null as array offset" in its own
internals. Upgrade vendor-bin/psalm to ^6.0 (6.16.1) to resolve.

Generate psalm-baseline.xml to suppress ~130 known false positives:
UnusedClass for DI-registered services, MissingDependency for NC
internal oc\hooks\emitter, QBMapper entity magic methods, and Doctrine
DBAL docblock type widening. Genuine bugs fixed in previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…lags

- Add RENAME_FILE handler in executeOperation: decodes UTF-7 header,
  checks lock, rejects existing-name conflicts, moves file in-scope of
  ILockManager, returns {"Name": "<name.ext>"}
- Fix CheckFileInfo: add SupportsUpdate, SupportsGetLock,
  SupportsExtendedLockLength (lock_id VARCHAR(1024)), IsAnonymousUser
  for guest sessions
- Set UserCanNotWriteRelative unconditionally to defer PutRelativeFile
  (Save As) to Phase 6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
- Replace hardcoded container name with `docker compose exec` pattern
- Rename "eurooffice" to "connector app" in setup instructions
- Remove private internal file references
- Remove branch-specific cross-references
- Add WOPI spec compliance section: operations table and
  CheckFileInfo capability flags table (reflects Phase 5 state)
- Update features list and known gaps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Application::APP_ID was referenced in addInitScript() without an import,
which would cause a fatal at runtime. Also trim one stale baseline entry
revealed by the fix and add the missing createTable baseline entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…TOCTOU

- RenameFile: validate decoded UTF-7 name with basename() — a name
  containing '/' or '\0' was silently used to construct an arbitrary
  target path, allowing files to escape their parent directory (C-1)
- PutFile: require an active WOPI lock when saving a non-empty file,
  per MS-WOPI spec §3.3.5.3; returns 409 + X-WOPI-Lock: '' otherwise (H-3)
- WopiLockMapper::upsertLock: catch REASON_UNIQUE_CONSTRAINT_VIOLATION on
  INSERT and retry as UPDATE to handle concurrent lock acquisition (H-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…kenManager

TokenManager::__construct is now correctly detected as used via constructor
injection type hints in the controllers, so the suppression is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
ApiRoute registers under /ocs/v2.php/, but the admin UI fetches
/index.php/apps/office/settings/admin - the endpoints were never
reachable from the settings page. Also reset the discovery cache on
settings save: the cached discovery XML depends on wopi_url and was
served stale for up to an hour after changing the editor server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…ments

Docker/proxy setups expose three distinct origins: NC->editor
(wopi_url), browser->editor (public_wopi_url, rewrites the discovery
urlsrc origin), and editor->NC (callback_url, rewrites the WOPISrc
origin). Mirrors the richdocuments URL split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…on tests

tests/bootstrap.php required the Nextcloud server tree and called
OC_App::loadApp, which only resolves inside a server checkout. Add
tests/bootstrap-unit.php (plain composer autoload, no server
dependency) for tests/phpunit.xml, and rename the old bootstrap to
bootstrap-integration.php for the new tests/phpunit.integration.xml,
which runs tests/integration/ inside a server tree.

Standalone unit tests also need OCP\* classes (IRequest, IAppConfig,
etc.) for mocking, which vendor/nextcloud/ocp does not autoload on its
own - add an autoload-dev psr-4 mapping for OCP\ and OCA\Office\Testsand regenerate the autoloader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Cover get()/fetch()/resetCache() cache behavior and fetch-failure
handling, getUrlSrc() (including the XPath-injection extension guard
and the OnlyOffice-vs-Collabora getSupportedMimeTypes() MIME-detection
gap, documented not fixed), and every buildEditorUrl() branch
(public_wopi_url swap, callback_url swap, placeholder stripping,
separator selection).

Fixtures model OnlyOffice-style discovery (apps named by product,
e.g. "Word") and Collabora-style discovery (apps named by MIME type).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
74 rows covering the implemented WOPI surface (CheckFileInfo, GetFile
including range handling, PutFile lock/version conflicts, the lock
protocol, RenameFile, tokens, routing) plus explicit not-implemented
rows for spec operations the app does not support yet (PutRelativeFile,
DeleteFile, PutUserInfo, proof-key validation, ItemVersion
completeness, access_token form-POST delivery), so gaps stay visible
rather than implicit.

tests/spec-coverage.php prints per-operation and total counts with
percentages and exits non-zero if any row claims tested without a
named test. Rows flip to tested in the same commit as the test that
covers them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
30 test methods driving executeOperation() end to end (mocked
WopiMapper/WopiLockMapper/ILockManager/IRootFolder, no server tree
needed): the read-only-token 403 guard across all five overrides, the
501 unknown-override path, LOCK/UNLOCK/REFRESH_LOCK/GET_LOCK including
UnlockAndRelock and expired-lock-treated-as-absent semantics, and a
full RenameFile suite - guest 403, empty/invalid name 400,
name-collision 400, lock-mismatch 409, file-not-found 404, success 200.

Also documents (without fixing) that the RenameFile path-traversal
guard's basename() check only rejects '/' and null bytes, not '\' -
basename() treats '\' as a literal character on non-Windows PHP, so a
backslash-based name currently passes the guard unmodified.

Response::getHeaders() pulls in framework defaults via
\OCP\Server::get(), which needs a running server and isn't available
standalone; read the private $headers property via reflection instead
to check only the WOPI headers the controller itself set.

Flips 30 wopi-spec-matrix.php rows from implemented-untested to
tested, pointing at the covering test method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
15 test methods for generateToken() and generateGuestToken():
serverHost capture from IURLGenerator, fileId/ownerUid/editorUid/
version/canWrite passed through to the mocked WopiMapper, owner-UID
fallback to the current user when the file has no owner, guest-token
propagation of guestName/canWrite/hideDownload and folder resolution
via the file owner rather than the current session user,
BeforeNodeReadEvent dispatch, and NotPermittedException on an
unreadable or missing file.

Does not test token randomness/length or expiry value sanity - both
live in WopiMapper::generateFileToken()/generateGuestToken()
(ISecureRandom and TOKEN_TTL), not TokenManager. With WopiMapper
mocked there is nothing of theirs left to exercise through this class;
see the class docblock for detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Two bugs in the integration bootstrap:

1. __DIR__-relative paths break when the app directory is bind-mounted
   separately from the server tree: realpath() reports the bind-mount
   source, so relative traversal to the server root is unreliable. Use
   /var/www/html directly, overridable via NEXTCLOUD_ROOT.

2. Requiring the app's own vendor/autoload.php after the real server
   bootstrap lets the app's ClassLoader win the autoload race for any
   OCP class the server's classmap loader hasn't already loaded,
   shadowing real interfaces with the composer-dev-only nextcloud/ocp
   stubs - a hard fatal on the first file operation
   (OC\User\User::setEnabled() incompatible with the stub signature).
   The stub OCP\ mapping exists only for standalone unit tests;
   register IntegrationTestCase.php by hand instead and let PHPUnit's
   directory discovery load each *Test.php itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
40 tests across 6 classes (tests/run-integration.sh):

- WopiProtocolTest: CheckFileInfo, GetFile including 206/416 range
  handling, PutFile lock and version conflict handling, the full lock
  protocol (LOCK/UNLOCK/REFRESH_LOCK/GET_LOCK/UnlockAndRelock), and
  RenameFile.
- AdminSettingsTest: settings read/write via the FrontpageRoute
  endpoints.
- EditorControllerTest, ShareGuestTokenTest: 401/404/415 branches and
  guest-token WOPI access issued directly via TokenManager.
- CleanupJobTest: token/lock expiry purge and background-job
  registration.
- RouteTest: the /index.php/-prefixed route form.

run-integration.sh runs each class as its own phpunit process: in a
single process, per-class recreation of the same test user leaves a
stale mount-cache entry (likely LazyFolder/SetupManager) that makes
later classes' file fixtures fail with NotPermittedException, while
every class passes alone. One process per class sidesteps this at a
small time cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
ShareController::openShare() guarded its password challenge with
$share->getPassword() !== ''. IShare::getPassword() returns
string|null and is null (not '') for a share with no password, and
null !== '' is true in PHP, so the guard fired for password-less
shares too: any unauthenticated guest visiting a share link that was
never password-protected got redirected to the generic Nextcloud
share page instead of ever reaching the editor, and it masked every
branch after it for unauthenticated requests, including the
READ-permission check. Authenticated visitors were unaffected, which
is why this went unnoticed.

Updates ShareGuestTokenTest to assert the fixed behavior for a guest
on a passwordless share, and re-targets the unreadable-share 403 test
at an unauthenticated guest directly, since that branch is no longer
masked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
handleRenameFile()'s path-traversal guard rejected names containing
'/' or a null byte via basename() comparison, but basename() only
treats '/' as a separator on non-Windows PHP builds, so a
backslash-based name (e.g. "..\..\evil") passed through unmodified.
Windows-separator names have no legitimate use in a rename, and NC
storage treats '\' as an ordinary character rather than a path
component - reject it explicitly alongside the existing checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
…base

Rebasing onto main's current psalm v6.16 setup left two suppressions
with nothing left to suppress: WopiMapper's DeprecatedMethod baseline
had 2 extra entries, and the UndefinedDocblockClass issue-handler for
Doctrine DBAL Schema/Table was never thrown. findUnusedBaselineEntry
and a clean `composer psalm` run both confirm neither fires anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
EO's WOPI editor route only registers a POST handler, so binding the
iframe src directly (always a GET) 404s. Switch to a hidden
auto-submitting form targeting the iframe by name - the standard WOPI
embedding pattern.

wopisrc stays in the form action URL and access_token moves to a
hidden form field, matching EO's own req.query['wopisrc'] vs
req.body['access_token'] split (keeps the bearer token out of the
URL/Referer, per WOPI convention).

Found and verified via a live two-replica EO + real WOPI host test.

Assisted-by: ClaudeCode:claude-sonnet-5
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
composer.json's autoload-dev still mapped OCP\ to vendor/nextcloud/ocp/OCP/,
the path used before nextcloud/ocp was moved into its own isolated
vendor-bin/nextcloud-ocp namespace (#128). That move never broke anything on
its own branch since nothing there exercised the mapping, but merging in
work that does (real OCP interfaces mocked in PHPUnit tests) surfaced it:
every such test failed with "Class or interface ... does not exist".

Repoints the mapping at the vendor-bin location and adds the psr/*
sub-dependencies (event-dispatcher, http-message, http-client, container,
log, clock) those stubs reference, which live in the same isolated tree.

Also drops psalm.xml's now-unused UndefinedDocblockClass suppression for
Doctrine\DBAL\Schema\* (nothing in lib/ references it) and regenerates
psalm-baseline.xml, which had drifted two stale DeprecatedMethod entries
for WopiMapper.

Assisted-by: ClaudeCode:claude-sonnet-5
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
The editor page posts a hidden form into a named iframe on the editor
server, but its TemplateResponse kept the default policy, so frame-src
and form-action only allowed 'self' and browsers blocked the editor.

Both the user and the public-share editor pages now allow the origin of
the URL the browser is actually sent to (after the public_wopi_url
rewrite). The origin is validated as http(s) with a plain hostname or
bracketed IPv6 literal, since parse_url() accepts hosts containing ';'
or whitespace and the value is spliced into the header verbatim. An
unusable editor URL returns a 500 instead of rendering a page that
can never load.

The editor URL carries the access token, so the parameters are marked
SensitiveParameter to keep it out of the stack trace that the
controllers log on that failure.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant