Skip to content

Feat/wopi phase 4#6

Draft
moodyjmz wants to merge 49 commits into
mainfrom
feat/wopi-phase-4
Draft

Feat/wopi phase 4#6
moodyjmz wants to merge 49 commits into
mainfrom
feat/wopi-phase-4

Conversation

@moodyjmz
Copy link
Copy Markdown

Add WOPI support - kind of PoC, but works well

moodyjmz added 30 commits May 22, 2026 23:25
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>
…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>
File extensions are user-controlled (derived from the filename) and
were interpolated directly into XPath predicates. Validate the extension
against a strict [a-zA-Z0-9]{1,20} allowlist before use.

Also pass LIBXML_NONET | LIBXML_NOCDATA to SimpleXMLElement to block
network requests during XML parsing of the discovery response.

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

An unconstrained wopi_url allowed any scheme (file://, gopher://)
and any host including cloud metadata endpoints. Reject non-http/https
schemes and reject URLs that embed credentials.

allow_local_address remains enabled intentionally: self-hosted deployments
often run the editor and Nextcloud on the same host/network.

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

Range requests with start >= fileSize now return 416 (Range Not
Satisfiable) per RFC 7233 §4.4 instead of silently streaming wrong
data. Added try/finally around both range-stream handles so they
are closed on exception.

Also wrapped php://input in try/finally in putFile so the handle
is always released even when early-return or an exception fires.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Without a cleanup job the office_wopi table grows without bound —
one row per file open per 10-hour TTL window. Add a TimedJob that
runs hourly and batch-deletes expired rows (500 at a time) via a new
WopiMapper::deleteByIds() helper.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
WOPI locks are file-level (one opaque lock_id per file, 30-minute TTL)
and distinct from the per-token session data in office_wopi. Separate
table keeps the semantics clean.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
WopiLock stores a single opaque lock_id per file with a 30-minute TTL
(WOPI spec). WopiLockMapper provides findByFileId, upsertLock (create
or refresh), getExpiredLockIds, and deleteByIds for the cleanup job.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
POST wopi/files/{fileId} dispatches on X-WOPI-Override header:
- LOCK: acquire lock; idempotent for same lock_id; 409 on conflict
- UNLOCK: verify lock_id then release; 409 on mismatch
- REFRESH_LOCK: extend TTL; 409 on mismatch
- GET_LOCK: return current lock_id (empty string if unlocked)
- LOCK + X-WOPI-OldLock: UnlockAndRelock (atomic swap)

All conflict responses carry X-WOPI-Lock and X-WOPI-LockFailureReason
headers per WOPI spec so the editor can surface a meaningful error.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Without these guards PutFile accepted any write regardless of whether
another client held the file lock, silently discarding concurrent edits.

- X-WOPI-Lock must match the current stored lock when one exists;
  returns 409 with X-WOPI-Lock + X-WOPI-LockFailureReason on mismatch.
- X-WOPI-ItemVersion (when provided) must match the file mtime; returns
  409 with current X-WOPI-ItemVersion when file changed out-of-band.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
moodyjmz added 15 commits May 23, 2026 02:48
The same hourly job now clears both office_wopi (expired tokens)
and office_wopi_locks (expired file locks) so neither table grows
without bound.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
NC35 removed registerJob() and registerSettings() from
IRegistrationContext. Declare the CleanupJob background job and the
Admin settings class in appinfo/info.xml instead, which is the
supported mechanism from NC35 onwards.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
NC's putContent() closes the stream handle internally. The finally
block called fclose() on an already-invalid resource, producing a
PHP warning and a 500 response. Guard with is_resource() before
closing.

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

StreamResponse defaults to text/html; override on all GetFile paths
(full, range, zero-byte) for WOPI spec compliance.

Signed-off-by: James Manuel <james.manuel@nextcloud.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
executeOperation dispatched LOCK/UNLOCK/REFRESH_LOCK without checking
canwrite, allowing read-only tokens (including read-only guest shares)
to acquire and hold locks.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Migration 1002 adds hide_download BOOLEAN (default false) to
oc_office_wopi. WopiMapper::generateGuestToken and TokenManager::
generateGuestToken accept the flag; CheckFileInfo derives
HideExportOption, DisablePrint, DisableExport and UserCanNotWriteRelative
from it at response time.

Signed-off-by: James Manuel <james.manuel@nextcloud.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
GET /apps/office/open/share/{shareToken} opens a file in the WOPI editor
via a Nextcloud public share link. Handles:
- File shares (share node is a File)
- Folder shares (resolve target by fileId or path parameter)
- Password-protected shares (checks public_link_authenticated session
  key in both legacy string and current array-of-IDs formats)

Generates a guest WOPI token stamped with the share's canWrite and
hideDownload flags. Returns the same editor TemplateResponse shape
as EditorController::open.

Signed-off-by: James Manuel <james.manuel@nextcloud.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
HideExportOption, DisablePrint and DisableExport are now derived from
the token's hide_download field. UserCanNotWriteRelative is true for
both guests and hideDownload tokens, preventing Save As from writing
back to the owner's storage.

Signed-off-by: James Manuel <james.manuel@nextcloud.com>
Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
- Strip control chars and cap guestName at 64 bytes in ShareController
  to prevent log injection and oversized display names
- Add boundary comment on getFirstNodeById in resolveFile
- Document three known gaps in PHASE3_DECISIONS.md (password UI,
  authenticated-user-through-share, federated shares)

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Parses app/@name attributes from the cached discovery XML to return
all MIME types the editor advertises. Returns [] on cache miss or
parse failure so callers degrade gracefully.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
KG2: authenticated NC users visiting a share link now get a full user
token via TokenManager::generateToken() rather than a guest token.
hideDownload is not applied on the user path — download restrictions
target unauthenticated third parties, not collaborators with direct access.

KG1: password-protected share without a session now redirects to
/s/{token} for the NC password challenge instead of returning 401 JSON.
Authenticated users bypass the password check entirely.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Register LoadAdditionalScriptsListener to load the office-file-actions
script on every Files app page. Supported MIME types from DiscoveryService
are injected as initial state so the file action can filter correctly
without a per-file HTTP round-trip.

Falls back to an empty MIME list if discovery is unavailable, preventing
the listener from blocking page render.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
Adds a file-actions.ts entry that registers a DEFAULT file action for
all MIME types advertised by the editor's discovery XML. MIME list is
injected as initial state by LoadAdditionalScriptsListener.

- Authenticated file access routes to EditorController::open
- Public share context (isPublicShare()) routes to ShareController
  using getSharingToken() from @nextcloud/sharing/public

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
window.close() only works for popup windows. For full-page navigation,
fall back to the office overview if there is no history to go back to.

Signed-off-by: James Manuel <moodyjmz@users.noreply.github.com>
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>
@moodyjmz moodyjmz self-assigned this May 24, 2026
@moodyjmz moodyjmz marked this pull request as draft May 24, 2026 00:23
moodyjmz and others added 4 commits May 24, 2026 02:37
- 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>
Required by CI npm install/build/lint steps.

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>
@moodyjmz moodyjmz force-pushed the feat/wopi-phase-4 branch from e3ac239 to 933de98 Compare May 24, 2026 07:04
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