diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 00000000..24c5fba6 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,202 @@ +# Based on the organization's phpunit-*.yml templates +# (https://github.com/nextcloud/.github), merged into one workflow: the +# integration suite needs OFFICE_TEST_BASE_URL and runs once per database. +# +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: PHPUnit + +on: pull_request + +permissions: + contents: read + +concurrency: + group: phpunit-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + changes-and-matrix: + runs-on: ubuntu-latest-low + permissions: + contents: read + pull-requests: read + + outputs: + src: ${{ steps.changes.outputs.src }} + php-versions: ${{ steps.versions.outputs.php-available-list }} + php-max: ${{ steps.versions.outputs.php-max }} + server-max: ${{ steps.versions.outputs.branches-max }} + + steps: + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/**' + - 'appinfo/**' + - 'lib/**' + - 'templates/**' + - 'tests/**' + - 'vendor-bin/**' + - 'composer.json' + - 'composer.lock' + + - name: Checkout app + if: steps.changes.outputs.src != 'false' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Get version matrix + if: steps.changes.outputs.src != 'false' + id: versions + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + + unit: + runs-on: ubuntu-latest + + needs: changes-and-matrix + if: needs.changes-and-matrix.outputs.src != 'false' + + strategy: + fail-fast: false + matrix: + php-versions: ${{ fromJson(needs.changes-and-matrix.outputs.php-versions) }} + + name: Unit PHP ${{ matrix.php-versions }} + + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: ctype, json, mbstring, openssl, simplexml + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install composer dependencies + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + + - name: PHPUnit + run: composer run test:unit + + integration: + runs-on: ubuntu-latest + + needs: changes-and-matrix + if: needs.changes-and-matrix.outputs.src != 'false' + + strategy: + fail-fast: false + matrix: + database: ['sqlite', 'mysql', 'pgsql'] + + name: Integration ${{ matrix.database }} PHP ${{ needs.changes-and-matrix.outputs.php-max }} Nextcloud ${{ needs.changes-and-matrix.outputs.server-max }} + + # Both start in every job; only the matrix database is used. + services: + mysql: + image: ghcr.io/nextcloud/continuous-integration-mysql-8.4:latest # zizmor: ignore[unpinned-images] + ports: + - 4444:3306/tcp + env: + MYSQL_ROOT_PASSWORD: rootpassword + options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10 + postgres: + image: ghcr.io/nextcloud/continuous-integration-postgres-16:latest # zizmor: ignore[unpinned-images] + ports: + - 4445:5432/tcp + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: rootpassword + POSTGRES_DB: nextcloud + options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 + + steps: + - name: Checkout server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ needs.changes-and-matrix.outputs.server-max }} + + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/office + + - name: Set up php ${{ needs.changes-and-matrix.outputs.php-max }} + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: ${{ needs.changes-and-matrix.outputs.php-max }} + # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite, mysql, pdo_mysql, pgsql, pdo_pgsql + coverage: none + ini-file: development + # Temporary workaround for missing pcntl_* in PHP 8.3 + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install composer dependencies + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + working-directory: apps/office + + - name: Set up Nextcloud + env: + DATABASE: ${{ matrix.database }} + run: | + case "$DATABASE" in + mysql) db_args=(--database-host=127.0.0.1 --database-port=4444) ;; + pgsql) db_args=(--database-host=127.0.0.1 --database-port=4445) ;; + *) db_args=() ;; + esac + mkdir data + ./occ maintenance:install --verbose --database="$DATABASE" --database-name=nextcloud --database-user=root --database-pass=rootpassword "${db_args[@]}" --admin-user admin --admin-pass admin + ./occ app:enable --force office + + - name: Run Nextcloud + env: + PHP_CLI_SERVER_WORKERS: 4 + run: php -S localhost:8080 & + + - name: PHPUnit integration + working-directory: apps/office + env: + NEXTCLOUD_ROOT: ${{ github.workspace }} + OFFICE_TEST_BASE_URL: http://localhost:8080 + # composer stops scripts after 300 s by default + COMPOSER_PROCESS_TIMEOUT: 0 + run: composer run test:integration + + - name: Print logs + if: always() + run: cat data/nextcloud.log + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: [changes-and-matrix, unit, integration] + + if: always() + + name: phpunit-summary + + steps: + - name: Summary status + run: if ${{ needs.changes-and-matrix.outputs.src != 'false' && (needs.unit.result != 'success' || needs.integration.result != 'success') }}; then exit 1; fi diff --git a/README.md b/README.md index 15f0a1ff..18b3d54c 100644 --- a/README.md +++ b/README.md @@ -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()` --- @@ -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 @@ -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 @@ -74,6 +82,20 @@ every component needs outside a running NC page stub. `src/test-utils/fixtures.ts` has `makeNode()`/`makeCreator()` factories for building test data. CI runs the suite on PRs via `test-unit.yml`. +```bash +composer test:unit # PHPUnit against the OCP stubs, no server needed +composer test:integration # against a running Nextcloud with this app enabled +OFFICE_IT_CONTAINER=nextcloud tests/run-integration.sh # from the host, in the dev container +``` + +The integration runner takes `OFFICE_TEST_BASE_URL` (the URL the server answers on, +default `http://localhost`) and `NEXTCLOUD_ROOT` (default `/var/www/html`). It starts +its own stub editor, so no Euro-Office is needed. For the run it points `wopi_url` at +the stub and clears `public_wopi_url`, `callback_url` and `wopi_allowlist`, and puts +them back when phpunit exits; an interrupted run (Ctrl-C) leaves them changed. With +`overwritehost` or `overwriteprotocol` set, `OFFICE_TEST_BASE_URL` must match them, or +the signed WOPI requests get 403. + ### 5. Committing changes Commit **source only** (`src/`, `lib/`, …) — do **not** commit the built @@ -90,6 +112,73 @@ recompiled assets as a `chore(assets): Recompile assets` commit. --- +## 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 | +| `WopiAccessResolver` | Resolves a token to its file and current rights; share-link tokens through the live share | +| `WopiProofMiddleware` / `WopiProofVerifier` | Require the editor's proof signature when discovery publishes keys | +| `WopiAllowlistMiddleware` | Optional `wopi_allowlist` of editor addresses for the WOPI endpoints | +| `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 | + +--- + +## Who may call the WOPI endpoints + +The WOPI routes are public: the access token authorises the request. Because the token +is in the page of whoever opened the document, the app also checks the caller: + +- **Proof keys.** When the editor's discovery publishes a ``, every WOPI + request must carry a valid `X-WOPI-Proof` / `X-WOPI-ProofOld` signature and an + `X-WOPI-TimeStamp` no older than 20 minutes, or it gets 403. Collabora always signs; + Euro-Office signs when its WOPI keys are configured (its images generate them with + `WOPI_ENABLED=true`, the same switch that turns WOPI on). The integration tests run + against a stub editor that publishes a proof key and signs their requests. +- **Allowlist.** `occ config:app:set office wopi_allowlist --value="10.0.0.0/24 192.0.2.7"` + restricts the WOPI endpoints to those addresses (IPs or CIDR ranges); empty leaves + them open. + +With neither, the admin page shows a warning. The keys are remembered in app config and +rechecked hourly; a proof that doesn't verify triggers one reread (at most every 5 +minutes) to pick up regenerated keys. Verification uses the phpseclib 3 that Nextcloud +ships. + +--- + ## Editor integration The overview opens files via NC's file shortlink (`/f/{fileid}`), which @@ -103,7 +192,64 @@ present, navigates directly to that URL instead of `/f/{fileid}`. --- -## 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` | `true` | Locks are stored by the app (`office_wopi_locks`), independent of any NC lock provider | +| `SupportsGetLock` | `true` | GetLock is implemented | +| `SupportsExtendedLockLength` | `true` | `lock_id` column is `VARCHAR(1024)` | +| `SupportsRename` | per session | `true` for user tokens; `false` for share-link tokens, including logged-in users whose only access is the link | +| `UserCanRename` | per session | `true` for user tokens; `false` for share-link tokens, including logged-in users whose only access is the link | +| `UserCanNotWriteRelative` | `true` | PutRelativeFile deferred to Phase 6 | +| `UserCanWrite` | per request | the token's write right, narrowed to the share's current permissions for share-link sessions | +| `IsAnonymousUser` | per session | `true` when no Nextcloud user is behind the session (anonymous share-link visitors) | +| `HasContentRange` | `true` | partial file reads via HTTP Range are supported | +| `HideExportOption` | per request | the token's flag or the share's current `hide_download` | +| `DisablePrint` | per request | the token's flag or the share's current `hide_download` | +| `DisableExport` | per request | the token's flag or the share's current `hide_download` | + +--- + +## Public share support + +Share link visitors (`/s/{token}`) receive a share-link WOPI token via `ShareController`, +bound to the share. File access, locking, and `CheckFileInfo` flags (`HideExportOption`, +`DisablePrint`, `UserCanWrite`, etc.) follow the share's current permissions and +`hide_download` flag on every request. + +**Known gaps:** + +- **KG1** — Password-protected shares: users must authenticate at `/s/{token}` before + navigating to the editor. +- **KG2** — Authenticated users arriving through share links get a user token when the + file is in their own folder, and otherwise a share-link token that names them (after the + link password, if the share has one). Versions and activity don't record them as the + author yet: WOPI requests set no user scope. +- **KG3** — Federated/remote shares not tested. + +--- + +## Frontend architecture ``` /apps/office @@ -120,3 +266,14 @@ present, navigates directly to that URL instead of `/f/{fileid}`. ├── fileCategories.ts Mime → category mapping (pure) └── validateFilename.ts ``` + +--- + +## Backend architecture notes + +The WOPI token row (`oc_office_wopi`) stamps `canwrite` and `hideDownload` when the +token is issued. `WopiAccessResolver` resolves each request's file and effective +rights: user tokens through the editor's own folder; share-link tokens (with a +`share_token`) through the live share, as richdocuments does, so deleting, expiring +or narrowing a share takes effect on the next WOPI request rather than when the +token expires (10 h). diff --git a/appinfo/info.xml b/appinfo/info.xml index 6e5846a1..47c44b2a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Office Office overview Office overview app for Nextcloud - 2.0.0-dev.0 + 2.0.0-dev.1 AGPL-3.0-or-later Julius Knorr Office @@ -14,6 +14,12 @@ + + OCA\Office\BackgroundJob\CleanupJob + + + OCA\Office\Settings\Admin + office diff --git a/composer.json b/composer.json index b09b3a19..38333c86 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,11 @@ "OCA\\Office\\": "lib/" } }, + "autoload-dev": { + "psr-4": { + "OCA\\Office\\Tests\\": "tests/" + } + }, "scripts": { "post-install-cmd": [ "@composer bin all install --ansi" @@ -24,15 +29,17 @@ "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": "tests/run-integration.sh", "openapi": "generate-spec", "rector": "rector && composer cs:fix" }, "require": { - "bamarni/composer-bin-plugin": "^1.8", - "php": "^8.3" + "php": "^8.3", + "bamarni/composer-bin-plugin": "^1.8" }, "require-dev": { + "phpseclib/phpseclib": "^3.0.55", "roave/security-advisories": "dev-latest" }, "config": { diff --git a/composer.lock b/composer.lock index 38066726..bf0d318d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "80d7fd270809770d111586f940b825a6", + "content-hash": "6740d3ca2d589c4a8d5d0a5334f14ae5", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -65,6 +65,235 @@ } ], "packages-dev": [ + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.57", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.57" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-08-26T12:13:21+00:00" + }, { "name": "roave/security-advisories", "version": "dev-latest", diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 68a60099..2258b46c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -5,10 +5,15 @@ namespace OCA\Office\AppInfo; use OCA\Office\Listener\AppMenuActionListener; +use OCA\Office\Listener\LoadAdditionalScriptsListener; +use OCA\Office\Middleware\WopiAllowlistMiddleware; +use OCA\Office\Middleware\WopiProofMiddleware; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent; use OCP\Navigation\Events\LoadAdditionalEntriesEvent; final class Application extends App implements IBootstrap { @@ -22,6 +27,12 @@ public function __construct() { #[\Override] public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalEntriesEvent::class, AppMenuActionListener::class); + $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class); + // Public share pages don't fire LoadAdditionalScriptsEvent. + $context->registerEventListener(BeforeTemplateRenderedEvent::class, LoadAdditionalScriptsListener::class); + // Cheap address check first, then the signature. + $context->registerMiddleware(WopiAllowlistMiddleware::class); + $context->registerMiddleware(WopiProofMiddleware::class); } #[\Override] diff --git a/lib/BackgroundJob/CleanupJob.php b/lib/BackgroundJob/CleanupJob.php new file mode 100644 index 00000000..03265293 --- /dev/null +++ b/lib/BackgroundJob/CleanupJob.php @@ -0,0 +1,49 @@ +setInterval(3600); // run hourly + } + + #[\Override] + protected function run(mixed $argument): void { + for ($i = 0; $i < self::MAX_BATCHES; $i++) { + $tokenIds = $this->wopiMapper->getExpiredTokenIds(self::BATCH_SIZE); + if ($tokenIds === []) { + break; + } + $this->wopiMapper->deleteByIds($tokenIds); + } + + for ($i = 0; $i < self::MAX_BATCHES; $i++) { + $lockIds = $this->wopiLockMapper->getExpiredLockIds(self::BATCH_SIZE); + if ($lockIds === []) { + break; + } + $this->wopiLockMapper->deleteByIds($lockIds); + } + } +} diff --git a/lib/Controller/EditorController.php b/lib/Controller/EditorController.php new file mode 100644 index 00000000..02f8df8a --- /dev/null +++ b/lib/Controller/EditorController.php @@ -0,0 +1,105 @@ +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); + } + + if (!$this->discoveryService->isAvailable()) { + return new JSONResponse(['error' => 'Editor server unavailable'], \OCP\AppFramework\Http::STATUS_SERVICE_UNAVAILABLE); + } + + $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()); + $policy = EditorPagePolicy::forEditorUrl($editorUrl); + + } 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, + // WOPI access_token_ttl: absolute expiry in milliseconds since the epoch. + 'accessTokenTtl' => $wopi->getExpiry() * 1000, + 'postMessageOrigin' => $wopi->getServerHost(), + 'fileName' => $file->getName(), + ]); + $response->setContentSecurityPolicy($policy); + return $response; + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 00000000..a12fa39d --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,77 @@ + $this->appConfig->getValueString(Application::APP_ID, 'wopi_url', ''), + 'public_wopi_url' => $this->appConfig->getValueString(Application::APP_ID, 'public_wopi_url', ''), + 'callback_url' => $this->appConfig->getValueString(Application::APP_ID, 'callback_url', ''), + 'disable_certificate_verification' => $this->appConfig->getValueString(Application::APP_ID, 'disable_certificate_verification', 'no'), + ]); + } + + /** + * Persist admin settings. + */ + #[FrontpageRoute(verb: 'POST', url: '/settings/admin')] + public function setAdmin(string $wopi_url, string $public_wopi_url = '', string $callback_url = '', string $disable_certificate_verification = 'no'): DataResponse { + foreach (['wopi_url' => $wopi_url, 'public_wopi_url' => $public_wopi_url, 'callback_url' => $callback_url] as $field => $value) { + if ($value !== '') { + $parsed = parse_url($value); + if ($parsed === false || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) { + return new DataResponse(['error' => "$field must use the http or https scheme"], Http::STATUS_BAD_REQUEST); + } + if (isset($parsed['user']) || isset($parsed['pass'])) { + return new DataResponse(['error' => "$field 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, 'public_wopi_url', rtrim($public_wopi_url, '/')); + $this->appConfig->setValueString(Application::APP_ID, 'callback_url', rtrim($callback_url, '/')); + $this->appConfig->setValueString(Application::APP_ID, 'disable_certificate_verification', $disable_certificate_verification === 'yes' ? 'yes' : 'no'); + + // The discovery document depends on wopi_url; a settings change must not + // serve actions cached from the previous editor server for up to CACHE_TTL. + $this->discoveryService->resetCache(); + + return new DataResponse([]); + } +} diff --git a/lib/Controller/ShareController.php b/lib/Controller/ShareController.php new file mode 100644 index 00000000..06c2fba8 --- /dev/null +++ b/lib/Controller/ShareController.php @@ -0,0 +1,256 @@ +shareManager->getShareByToken($shareToken); + } catch (ShareNotFound $e) { + $this->logger->debug('Share token not found: ' . $e->getMessage()); + $response = new JSONResponse(['error' => 'Share not found'], Http::STATUS_NOT_FOUND); + $response->throttle(['action' => 'officeOpenShare']); + return $response; + } + + // Password-protected share: NC core sets 'public_link_authenticated' in the session + // once the user has entered the password at /s/{token}. Check both legacy (string) + // and current (array of share IDs) formats, matching richdocuments' pattern. + // getPassword() returns null (not '') for a share with no password at all - both + // must be treated as "no password", or every passwordless share incorrectly + // redirects unauthenticated guests away instead of letting them through. + // (array) turns the legacy single-ID string into a one-element list and null into []. + $passwordPending = $share->getPassword() !== null && $share->getPassword() !== '' + && !in_array($share->getId(), (array)$this->session->get('public_link_authenticated'), true); + + // Logged-in users who can reach the file through their own folder skip the password below; + // everyone whose only access is this link has to pass it. + if ($passwordPending && !$this->userSession->isLoggedIn()) { + return $this->passwordChallenge($shareToken); + } + + // Below, a logged-in user may still be pending the password: until it's entered, every + // failure leads to the challenge, so probing ?path= or ?fileId= reveals nothing. + if (($share->getPermissions() & \OCP\Constants::PERMISSION_READ) === 0) { + return $this->refuse($passwordPending, $shareToken, new JSONResponse(['error' => 'Share is not readable'], Http::STATUS_FORBIDDEN)); + } + + try { + $file = $this->resolveFile($share, $fileId, $path); + } catch (NotFoundException $e) { + return $this->refuse($passwordPending, $shareToken, new JSONResponse(['error' => 'File not found'], Http::STATUS_NOT_FOUND)); + } catch (NotPermittedException $e) { + return $this->refuse($passwordPending, $shareToken, new JSONResponse(['error' => 'Access denied'], Http::STATUS_FORBIDDEN)); + } + + if (!$this->discoveryService->isAvailable()) { + return $this->refuse($passwordPending, $shareToken, new JSONResponse(['error' => 'Editor server unavailable'], Http::STATUS_SERVICE_UNAVAILABLE)); + } + + $extension = pathinfo($file->getName(), PATHINFO_EXTENSION); + $urlsrc = $this->discoveryService->getUrlSrc($extension, 'edit') + ?? $this->discoveryService->getUrlSrc($extension, 'view'); + + if ($urlsrc === null) { + return $this->refuse($passwordPending, $shareToken, new JSONResponse( + ['error' => 'File type not supported by the editor'], + Http::STATUS_UNSUPPORTED_MEDIA_TYPE, + )); + } + + $canWrite = (bool)($share->getPermissions() & \OCP\Constants::PERMISSION_UPDATE); + $ownerUid = $share->getShareOwner(); + + try { + $wopi = null; + $user = $this->userSession->getUser(); + if ($user !== null) { + // Authenticated user visiting a share link: issue a full user token via their + // own folder. hideDownload is intentionally not applied — the share's download + // restriction targets unauthenticated third parties, not collaborators with + // direct NC access. + try { + $wopi = $this->tokenManager->generateToken($file->getId()); + } catch (NotPermittedException) { + // The file isn't in their own folder: the link is their only access, so they + // get the same share-scoped token as a guest, under their own name. + } + } + + if ($wopi === null) { + if ($passwordPending) { + return $this->passwordChallenge($shareToken); + } + + $hideDownload = $share->getHideDownload(); + + // Sanitize guestName: strip control chars, cap at 64 chars, default to 'Guest'. + // Authenticated user's display name overrides guestName param to prevent spoofing. + $guestName = trim((string)preg_replace('/[\x00-\x1f\x7f]/u', '', $guestName ?? '')); + $guestName = mb_substr($guestName, 0, 64); + $displayName = $user?->getDisplayName() ?? ($guestName !== '' ? $guestName : 'Guest'); + + $wopi = $this->tokenManager->generateGuestToken( + fileId: $file->getId(), + ownerUid: $ownerUid, + shareToken: $share->getToken(), + editorUid: $user?->getUID(), + guestName: $displayName, + canWrite: $canWrite, + hideDownload: $hideDownload, + ); + } + } catch (NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['error' => 'File not accessible'], Http::STATUS_FORBIDDEN); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['error' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $wopiSrc = $this->urlGenerator->linkToRouteAbsolute( + 'office.wopi.checkFileInfo', + ['fileId' => $file->getId()], + ); + + $editorUrl = $this->discoveryService->buildEditorUrl($urlsrc, $wopiSrc, $wopi->getToken()); + + try { + $policy = EditorPagePolicy::forEditorUrl($editorUrl); + } catch (\InvalidArgumentException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['error' => 'Internal error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $response = new TemplateResponse(Application::APP_ID, 'editor', [], 'base'); + $response->setParams([ + 'editorUrl' => $editorUrl, + // WOPI access_token_ttl: absolute expiry in milliseconds since the epoch. + 'accessTokenTtl' => $wopi->getExpiry() * 1000, + 'postMessageOrigin' => $wopi->getServerHost(), + 'fileName' => $file->getName(), + ]); + $response->setContentSecurityPolicy($policy); + return $response; + } + + /** + * Send the visitor to the share page to enter the password. After authenticating + * there, they reopen the file action. + */ + private function passwordChallenge(string $shareToken): RedirectResponse { + return new RedirectResponse($this->urlGenerator->linkToRoute( + 'files_sharing.sharecontroller.showShare', + ['token' => $shareToken], + )); + } + + private function refuse(bool $passwordPending, string $shareToken, JSONResponse $error): JSONResponse|RedirectResponse { + return $passwordPending ? $this->passwordChallenge($shareToken) : $error; + } + + /** + * Resolve the target file from a share. + * + * For file shares: returns the shared node directly. + * For folder shares: resolves by $fileId or $path within the shared folder. + * NC throws NotFoundException on path traversal attempts — no extra guard needed. + * + * @throws NotFoundException + * @throws NotPermittedException + */ + private function resolveFile(IShare $share, ?int $fileId, ?string $path): File { + $node = $share->getNode(); + + if ($node instanceof File) { + return $node; + } + + if (!$node instanceof Folder) { + throw new NotFoundException('Share points to an unsupported node type'); + } + + if ($path !== null) { + $resolved = $node->get($path); + } elseif ($fileId !== null) { + // getFirstNodeById scoped to $node — cannot escape the shared folder boundary. + $resolved = $node->getFirstNodeById($fileId); + } else { + throw new NotFoundException('Folder share requires fileId or path parameter'); + } + + if (!$resolved instanceof File) { + throw new NotFoundException('Resolved node is not a file'); + } + + return $resolved; + } +} diff --git a/lib/Controller/WopiController.php b/lib/Controller/WopiController.php new file mode 100644 index 00000000..15015928 --- /dev/null +++ b/lib/Controller/WopiController.php @@ -0,0 +1,635 @@ +wopiMapper->getWopiForToken($access_token); + $access = $this->accessResolver->resolve($wopi); + $file = $access->file; + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($wopi->getFileid() !== $fileId) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $isAnonymous = $wopi->isAnonymous(); + $isShareSession = $wopi->isShareSession(); + $user = $this->userManager->get($wopi->getEditorUid() ?? ''); + $displayName = $isAnonymous + ? ($wopi->getGuestDisplayname() ?? 'Guest') + : ($user?->getDisplayName() ?? $wopi->getEditorUid() ?? ''); + + $canWrite = $access->canWrite; + + try { + $locks = $this->lockManager->getLocks($wopi->getFileid()); + foreach ($locks as $lock) { + if ($lock->getType() === \OCP\Files\Lock\ILock::TYPE_USER && $lock->getOwner() !== $wopi->getEditorUid()) { + $canWrite = false; + break; + } + } + } catch (NoLockProviderException|PreConditionNotMetException) { + } + + $hideDownload = $access->hideDownload; + + return new JSONResponse([ + 'BaseFileName' => $file->getName(), + 'Size' => $file->getSize(), + 'Version' => (string)$file->getMTime(), + 'UserId' => $isAnonymous ? 'Guest-' . substr(md5($wopi->getToken()), 0, 8) : $wopi->getEditorUid(), + 'OwnerId' => $wopi->getOwnerUid(), + 'UserFriendlyName' => $displayName, + 'IsAnonymousUser' => $isAnonymous, + 'UserCanWrite' => $canWrite, + // PutRelativeFile (Save As) deferred to Phase 6. + 'UserCanNotWriteRelative' => true, + 'PostMessageOrigin' => $wopi->getServerHost(), + 'LastModifiedTime' => $this->toISO8601($file->getMTime()), + 'SupportsUpdate' => true, + // WOPI locks live in office_wopi_locks, not in an NC lock provider, and PutFile requires one + // on non-empty files: clients only send LOCK when this is true, so it must not depend on files_lock. + 'SupportsLocks' => true, + 'SupportsGetLock' => true, + 'SupportsExtendedLockLength' => true, + // Share-link sessions act on someone else's file through the link: no renaming or resharing, + // even for a logged-in visitor. + 'SupportsRename' => !$isShareSession, + 'UserCanRename' => !$isShareSession, + 'EnableInsertRemoteImage' => !$isShareSession, + 'EnableShare' => !$isShareSession, + 'HideExportOption' => $hideDownload, + 'DisablePrint' => $hideDownload, + 'DisableExport' => $hideDownload, + 'HideUserList' => '', + 'EnableOwnerTermination' => $canWrite && !$isShareSession, + 'HasContentRange' => true, + // ServerPrivateInfo is intentionally empty — credentials must never travel via CheckFileInfo. + 'ServerPrivateInfo' => [], + ]); + } + + /** + * WOPI GetFile — returns the binary content of the file. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'GET', url: 'wopi/files/{fileId}/contents')] + public function getFile( + int $fileId, + #[\SensitiveParameter] + string $access_token, + ): Http\Response { + try { + $wopi = $this->wopiMapper->getWopiForToken($access_token); + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($wopi->getFileid() !== $fileId) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $access = $this->resolveAccess($wopi); + if (!$access instanceof WopiAccess) { + return $access; + } + $file = $access->file; + + if ($file->getSize() === 0) { + $empty = new Http\Response(); + $empty->addHeader('Content-Type', 'application/octet-stream'); + return $empty; + } + + $rangeHeader = $this->request->getHeader('Range'); + if ($rangeHeader !== '') { + return $this->getFileRange($file, $rangeHeader); + } + + $resource = $file->fopen('rb'); + if ($resource === false) { + return new JSONResponse(['message' => 'Could not open file for reading'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $response = new StreamResponse($resource); + $response->addHeader('Content-Type', 'application/octet-stream'); + return $response; + } + + /** + * WOPI PutFile — saves binary content sent by the editor. + * + * Nextcloud's advisory locking is acquired around the write so other + * processes see a consistent file. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: 'wopi/files/{fileId}/contents')] + public function putFile( + int $fileId, + #[\SensitiveParameter] + string $access_token, + ): JSONResponse { + try { + $wopi = $this->wopiMapper->getWopiForToken($access_token); + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($wopi->getFileid() !== $fileId) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $access = $this->resolveAccess($wopi); + if (!$access instanceof WopiAccess) { + return $access; + } + if (!$access->canWrite) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + $file = $access->file; + + // Enforce WOPI lock: if a non-expired lock exists the client must supply the matching lock ID. + $lockId = $this->request->getHeader('X-WOPI-Lock'); + $existingLock = $this->wopiLockMapper->findByFileId($fileId); + if ($existingLock !== null && !$existingLock->isExpired()) { + if ($lockId !== $existingLock->getLockId()) { + return $this->lockConflict($existingLock->getLockId(), 'File is locked'); + } + } + + // Per WOPI spec §3.3.5.3: a non-empty file must be locked before it can be saved. + if (($existingLock === null || $existingLock->isExpired()) && $file->getSize() > 0) { + return $this->lockConflict('', 'A lock is required to save a non-empty file'); + } + + // Editors echo back the LastModifiedTime from CheckFileInfo or the previous PutFile; a mismatch means + // the file changed outside this editing session. Collabora (X-COOL-WOPI-Timestamp) answers + // COOLStatusCode 1010 with a "document changed" prompt, as with richdocuments. Euro-Office + // (X-LOOL-WOPI-Timestamp) has no handling for it and only refreshes its timestamp from a successful + // PutFile, so a 409 would fail every later save of the session: write anyway, the previous content + // stays in the file's versions, and log the overwrite. + $coolTimestamp = $this->request->getHeader('X-COOL-WOPI-Timestamp'); + $loolTimestamp = $this->request->getHeader('X-LOOL-WOPI-Timestamp'); + if ($coolTimestamp !== '' && !$this->isCurrentTimestamp($coolTimestamp, $file)) { + $this->logger->info('Rejected save of file {fileId}: changed outside the editing session', ['fileId' => $fileId]); + return new JSONResponse(['COOLStatusCode' => self::COOL_STATUS_DOC_CHANGED], Http::STATUS_CONFLICT); + } + if ($coolTimestamp === '' && $loolTimestamp !== '' && !$this->isCurrentTimestamp($loolTimestamp, $file)) { + $this->logger->warning('Save of file {fileId} overwrites a change made outside the editing session', [ + 'fileId' => $fileId, + 'editorTimestamp' => $loolTimestamp, + 'fileTimestamp' => $this->toISO8601($file->getMTime()), + ]); + } + + $content = $this->openRequestBody(); + if ($content === false) { + // putContent(false) would store an empty file in place of the document. + $this->logger->error('Could not read the PutFile request body for file {fileId}', ['fileId' => $fileId]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + try { + try { + $freespace = $file->getStorage()->free_space($file->getInternalPath()); + $contentLength = (int)$this->request->getHeader('Content-Length'); + if ($freespace >= 0 && $contentLength > $freespace) { + return new JSONResponse(['message' => 'Not enough storage'], Http::STATUS_INSUFFICIENT_STORAGE); + } + + $this->writeWithLock($wopi, $file, static function () use ($file, $content): void { + $file->putContent($content); + }); + } finally { + // putContent() may close the stream internally; guard to avoid a warning. + /** @psalm-suppress RedundantConditionGivenDocblockType psalm can't see putContent() closing it */ + if (is_resource($content)) { + fclose($content); + } + } + } catch (LockedException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse(['message' => 'File locked'], Http::STATUS_CONFLICT); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + $response = new JSONResponse(['LastModifiedTime' => $this->toISO8601($file->getMTime())]); + $response->addHeader('X-WOPI-ItemVersion', (string)$file->getMTime()); + return $response; + } + + /** + * WOPI lock operations — all arrive as POST wopi/files/{fileId} with + * an X-WOPI-Override header selecting the operation. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + #[FrontpageRoute(verb: 'POST', url: 'wopi/files/{fileId}')] + public function executeOperation( + int $fileId, + #[\SensitiveParameter] + string $access_token, + ): Http\Response { + try { + $wopi = $this->wopiMapper->getWopiForToken($access_token); + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($wopi->getFileid() !== $fileId) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $access = $this->resolveAccess($wopi); + if (!$access instanceof WopiAccess) { + return $access; + } + if (!$access->canWrite) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $override = $this->request->getHeader('X-WOPI-Override'); + $lockId = $this->request->getHeader('X-WOPI-Lock'); + + return match ($override) { + 'LOCK' => $this->handleLock($fileId, $lockId), + 'UNLOCK' => $this->handleUnlock($fileId, $lockId), + 'REFRESH_LOCK' => $this->handleRefreshLock($fileId, $lockId), + 'GET_LOCK' => $this->handleGetLock($fileId), + 'RENAME_FILE' => $this->handleRenameFile($fileId, $wopi, $access->file), + default => new JSONResponse(['error' => 'Unsupported WOPI operation'], Http::STATUS_NOT_IMPLEMENTED), + }; + } + + private function handleLock(int $fileId, string $lockId): Http\Response { + if ($lockId === '') { + return new JSONResponse(['error' => 'X-WOPI-Lock is required'], Http::STATUS_BAD_REQUEST); + } + + $oldLockId = $this->request->getHeader('X-WOPI-OldLock'); + $existing = $this->wopiLockMapper->findByFileId($fileId); + + if ($oldLockId !== '') { + // UnlockAndRelock: verify the old lock first, then replace with new. + if ($existing === null || $existing->isExpired() || $existing->getLockId() !== $oldLockId) { + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + return $this->lockConflict($current, 'Lock mismatch on UnlockAndRelock'); + } + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + if ($existing !== null && !$existing->isExpired()) { + if ($existing->getLockId() !== $lockId) { + return $this->lockConflict($existing->getLockId(), 'File is locked by another client'); + } + // Idempotent re-lock with same ID — refresh the TTL. + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + private function handleUnlock(int $fileId, string $lockId): Http\Response { + if ($lockId === '') { + return new JSONResponse(['error' => 'X-WOPI-Lock is required'], Http::STATUS_BAD_REQUEST); + } + + $existing = $this->wopiLockMapper->findByFileId($fileId); + + if ($existing === null || $existing->isExpired() || $existing->getLockId() !== $lockId) { + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + return $this->lockConflict($current, 'Lock mismatch'); + } + + $this->wopiLockMapper->delete($existing); + return $this->lockOkResponse(); + } + + private function handleRefreshLock(int $fileId, string $lockId): Http\Response { + if ($lockId === '') { + return new JSONResponse(['error' => 'X-WOPI-Lock is required'], Http::STATUS_BAD_REQUEST); + } + + $existing = $this->wopiLockMapper->findByFileId($fileId); + + if ($existing === null || $existing->isExpired() || $existing->getLockId() !== $lockId) { + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + return $this->lockConflict($current, 'Lock mismatch on RefreshLock'); + } + + $this->wopiLockMapper->upsertLock($fileId, $lockId); + return $this->lockOkResponse(); + } + + private function handleGetLock(int $fileId): Http\Response { + $existing = $this->wopiLockMapper->findByFileId($fileId); + $current = ($existing !== null && !$existing->isExpired()) ? $existing->getLockId() : ''; + + $response = new JSONResponse([]); + $response->addHeader('X-WOPI-Lock', $current); + return $response; + } + + private function handleRenameFile(int $fileId, Wopi $wopi, File $file): Http\Response { + // Renaming acts on the owner's file: not through a share link, logged in or not. + if ($wopi->isShareSession()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $requestedName = $this->request->getHeader('X-WOPI-RequestedName'); + if ($requestedName === '') { + return new JSONResponse(['error' => 'X-WOPI-RequestedName is required'], Http::STATUS_BAD_REQUEST); + } + + // X-WOPI-RequestedName is UTF-7 encoded; decode to UTF-8. + $newBaseName = (string)mb_convert_encoding($requestedName, 'UTF-8', 'UTF-7'); + + // Reject names that contain path separators or null bytes (path traversal guard). + // basename() only treats '/' as a separator on non-Windows PHP, so a + // backslash is checked explicitly too - Windows-separator names have no + // legitimate use in a rename, and NC storage treats '\' as an ordinary + // character rather than a path component. + if ($newBaseName === '' || $newBaseName !== basename($newBaseName) || str_contains($newBaseName, "\0") || str_contains($newBaseName, '\\')) { + $resp = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $resp->addHeader('X-WOPI-InvalidFileNameError', 'File name contains invalid characters'); + return $resp; + } + + // Lock check: an active lock must be matched by the client's X-WOPI-Lock. + $lockId = $this->request->getHeader('X-WOPI-Lock'); + $existing = $this->wopiLockMapper->findByFileId($fileId); + if ($existing !== null && !$existing->isExpired()) { + if ($lockId !== $existing->getLockId()) { + return $this->lockConflict($existing->getLockId(), 'File is locked'); + } + } + + $extension = pathinfo($file->getName(), PATHINFO_EXTENSION); + $newName = $extension !== '' ? $newBaseName . '.' . $extension : $newBaseName; + + // Reject if a file with the requested name already exists in the same directory. + try { + $file->getParent()->get($newName); + $response = new JSONResponse([], Http::STATUS_BAD_REQUEST); + $response->addHeader('X-WOPI-InvalidFileNameError', 'A file with that name already exists'); + return $response; + } catch (NotFoundException) { + // Target name is free — proceed with rename. + } + + $newPath = $file->getParent()->getPath() . '/' . $newName; + + try { + try { + $this->lockManager->runInScope( + new LockContext($file, \OCP\Files\Lock\ILock::TYPE_APP, 'office'), + static function () use (&$file, $newPath): void { + $file = $file->move($newPath); + }, + ); + } catch (NoLockProviderException|PreConditionNotMetException) { + $file = $file->move($newPath); + } catch (OwnerLockedException) { + return $this->lockConflict('', 'File is locked by another user'); + } + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse(['Name' => $file->getName()]); + } + + private function lockOkResponse(): JSONResponse { + return new JSONResponse([]); + } + + private function lockConflict(string $currentLockId, string $reason): JSONResponse { + $response = new JSONResponse([], Http::STATUS_CONFLICT); + $response->addHeader('X-WOPI-Lock', $currentLockId); + $response->addHeader('X-WOPI-LockFailureReason', $reason); + return $response; + } + + /** + * The token's file and current rights, or the response to send when the file + * or the share it was opened through is gone. + */ + private function resolveAccess(Wopi $wopi): WopiAccess|JSONResponse { + try { + return $this->accessResolver->resolve($wopi); + } catch (NotFoundException $e) { + $this->logger->info($e->getMessage(), ['fileId' => $wopi->getFileid()]); + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + } + + private function getFileRange(File $file, string $rangeHeader): Http\Response { + // getSize() is int|float only for sizes beyond PHP_INT_MAX on 32-bit builds. + $size = (int)$file->getSize(); + if (preg_match('/bytes=(\d+)-(\d+)?/', $rangeHeader, $m)) { + $start = (int)$m[1]; + $end = isset($m[2]) ? (int)$m[2] : $size - 1; + // Clamp end to actual file size to produce a correct Content-Range header (RFC 7233). + $end = min($end, $size - 1); + + // RFC 7233 §4.4: return 416 if range is unsatisfiable. + if ($start >= $size || $start > $end) { + $response = new Http\Response(); + $response->setStatus(416); + $response->addHeader('Content-Range', "bytes */{$size}"); + return $response; + } + + $length = (int)($end - $start + 1); + + $fp = $file->fopen('rb'); + if ($fp === false) { + $r = new Http\Response(); + $r->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + return $r; + } + try { + $rangeStream = fopen('php://temp', 'w+b'); + if ($rangeStream === false) { + $r = new Http\Response(); + $r->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + return $r; + } + try { + stream_copy_to_stream($fp, $rangeStream, $length, $start); + fseek($rangeStream, 0); + + $response = new StreamResponse($rangeStream); + $response->setStatus(Http::STATUS_PARTIAL_CONTENT); + $response->addHeader('Content-Type', 'application/octet-stream'); + $response->addHeader('Content-Range', "bytes {$start}-{$end}/{$size}"); + $response->addHeader('Content-Length', (string)$length); + $response->addHeader('Accept-Ranges', 'bytes'); + return $response; + } catch (\Throwable $e) { + fclose($rangeStream); + throw $e; + } + } finally { + fclose($fp); + } + } + + $resource = $file->fopen('rb'); + if ($resource === false) { + $r = new Http\Response(); + $r->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + return $r; + } + $response = new StreamResponse($resource); + $response->addHeader('Content-Type', 'application/octet-stream'); + return $response; + } + + /** + * Write file content while acquiring an advisory ILockManager lock if available. + * Falls back to writing without a lock when no provider is registered. + */ + private function writeWithLock(Wopi $wopi, File $file, callable $write): void { + try { + $this->lockManager->runInScope( + new LockContext($file, \OCP\Files\Lock\ILock::TYPE_APP, 'office'), + $write, + ); + } catch (NoLockProviderException|PreConditionNotMetException) { + $write(); + } catch (OwnerLockedException $e) { + throw new LockedException($file->getPath(), $e); + } + } + + /** + * Compare at second resolution rather than as strings, so the check doesn't depend on the editor + * echoing our exact ISO 8601 format (richdocuments emits microseconds, this app milliseconds). + */ + private function isCurrentTimestamp(string $editorTimestamp, File $file): bool { + try { + return (new \DateTimeImmutable($editorTimestamp))->getTimestamp() === $file->getMTime(); + } catch (\Exception) { + return false; + } + } + + /** + * @return resource|false + */ + protected function openRequestBody() { + return fopen('php://input', 'rb'); + } + + private function toISO8601(int $timestamp): string { + return (new \DateTime('@' . $timestamp))->format('Y-m-d\TH:i:s.000\Z'); + } +} diff --git a/lib/Db/Wopi.php b/lib/Db/Wopi.php new file mode 100644 index 00000000..6c61bef8 --- /dev/null +++ b/lib/Db/Wopi.php @@ -0,0 +1,108 @@ +addType('ownerUid', Types::STRING); + $this->addType('editorUid', Types::STRING); + $this->addType('guestDisplayname', Types::STRING); + $this->addType('fileid', Types::INTEGER); + $this->addType('version', Types::STRING); + $this->addType('canwrite', Types::BOOLEAN); + $this->addType('hideDownload', Types::BOOLEAN); + $this->addType('serverHost', Types::STRING); + $this->addType('token', Types::STRING); + $this->addType('expiry', Types::INTEGER); + $this->addType('shareToken', Types::STRING); + } + + /** + * Opened through a share link: file access and rights come from the share, + * re-checked on every request, whether or not the visitor is logged in. + */ + public function isShareSession(): bool { + return $this->shareToken !== null; + } + + /** + * No Nextcloud user behind the session (an anonymous share-link visitor). + */ + public function isAnonymous(): bool { + return $this->editorUid === null; + } + + public function isExpired(): bool { + return $this->expiry !== null && $this->expiry < time(); + } +} diff --git a/lib/Db/WopiLock.php b/lib/Db/WopiLock.php new file mode 100644 index 00000000..4cc986cd --- /dev/null +++ b/lib/Db/WopiLock.php @@ -0,0 +1,42 @@ +addType('fileid', Types::INTEGER); + $this->addType('lockId', Types::STRING); + $this->addType('expiry', Types::INTEGER); + } + + public function isExpired(): bool { + return $this->expiry < time(); + } +} diff --git a/lib/Db/WopiLockMapper.php b/lib/Db/WopiLockMapper.php new file mode 100644 index 00000000..b4468dc4 --- /dev/null +++ b/lib/Db/WopiLockMapper.php @@ -0,0 +1,112 @@ + */ +class WopiLockMapper extends QBMapper { + // WOPI spec: locks are valid for 30 minutes; editor must refresh before expiry. + public const int LOCK_TTL = 1800; + + public function __construct( + IDBConnection $db, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'office_wopi_locks', WopiLock::class); + } + + /** + * Return the current non-expired lock for a file, or null if none exists. + */ + public function findByFileId(int $fileId): ?WopiLock { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('office_wopi_locks') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + + try { + $lock = $this->findEntity($qb); + return $lock; + } catch (\OCP\AppFramework\Db\DoesNotExistException) { + return null; + } + } + + /** + * Create or refresh a lock for a file. + * If a lock already exists for the file it is updated in-place. + */ + public function upsertLock(int $fileId, string $lockId): void { + $expiry = $this->timeFactory->getTime() + self::LOCK_TTL; + $existing = $this->findByFileId($fileId); + + if ($existing !== null) { + $existing->setLockId($lockId); + $existing->setExpiry($expiry); + $this->update($existing); + return; + } + + $lock = new WopiLock(); + $lock->setFileid($fileId); + $lock->setLockId($lockId); + $lock->setExpiry($expiry); + + try { + $this->insert($lock); + } catch (\OCP\DB\Exception $e) { + if ($e->getReason() !== \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + // A concurrent request raced us between SELECT and INSERT; update the row they created. + $raced = $this->findByFileId($fileId); + if ($raced === null) { + throw $e; + } + $raced->setLockId($lockId); + $raced->setExpiry($expiry); + $this->update($raced); + } + } + + /** + * Return IDs of expired lock rows for the cleanup job. + * + * @return int[] + */ + public function getExpiredLockIds(int $limit = 500): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from('office_wopi_locks') + ->where($qb->expr()->lt('expiry', $qb->createNamedParameter($this->timeFactory->getTime(), IQueryBuilder::PARAM_INT))) + ->setMaxResults($limit); + + return array_map('intval', array_column($qb->executeQuery()->fetchAll(), 'id')); + } + + /** + * Delete lock rows by their primary-key IDs. + * + * @param int[] $ids + */ + public function deleteByIds(array $ids): void { + if (empty($ids)) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete('office_wopi_locks') + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + $qb->executeStatement(); + } +} diff --git a/lib/Db/WopiMapper.php b/lib/Db/WopiMapper.php new file mode 100644 index 00000000..2653bef3 --- /dev/null +++ b/lib/Db/WopiMapper.php @@ -0,0 +1,167 @@ + */ +class WopiMapper extends QBMapper { + private const int TOKEN_TTL = 36000; // 10 hours + + public function __construct( + IDBConnection $db, + private ISecureRandom $random, + private LoggerInterface $logger, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'office_wopi', Wopi::class); + } + + /** + * Generate and persist a WOPI token for an authenticated user. + */ + public function generateFileToken( + int $fileId, + string $ownerUid, + string $editorUid, + string $version, + bool $canWrite, + bool $hideDownload, + string $serverHost, + ): Wopi { + $token = $this->random->generate(32, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS); + + $wopi = $this->insert(Wopi::fromParams([ + 'fileid' => $fileId, + 'ownerUid' => $ownerUid, + 'editorUid' => $editorUid, + 'version' => $version, + 'canwrite' => $canWrite, + 'hideDownload' => $hideDownload, + 'serverHost' => $serverHost, + 'token' => $token, + 'expiry' => $this->newExpiry(), + ])); + + return $wopi; + } + + /** + * Generate and persist a WOPI token for a share-link session, bound to the share. + * $editorUid is the logged-in visitor, or null for an anonymous guest. + */ + public function generateGuestToken( + int $fileId, + string $ownerUid, + #[\SensitiveParameter] + string $shareToken, + ?string $editorUid, + string $guestDisplayname, + string $version, + bool $canWrite, + bool $hideDownload, + string $serverHost, + ): Wopi { + $token = $this->random->generate(32, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS); + + $wopi = $this->insert(Wopi::fromParams([ + 'fileid' => $fileId, + 'ownerUid' => $ownerUid, + 'editorUid' => $editorUid, + 'guestDisplayname' => $guestDisplayname, + 'shareToken' => $shareToken, + 'version' => $version, + 'canwrite' => $canWrite, + 'hideDownload' => $hideDownload, + 'serverHost' => $serverHost, + 'token' => $token, + 'expiry' => $this->newExpiry(), + ])); + + return $wopi; + } + + /** + * Look up and validate a WOPI token. + * + * @throws UnknownTokenException + * @throws ExpiredTokenException + */ + public function getWopiForToken( + #[\SensitiveParameter] + string $token, + ): Wopi { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('office_wopi') + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false) { + throw new UnknownTokenException('Could not find token.'); + } + + // Only identifiers: the row also holds the access token and the share token, both credentials. + $this->logger->debug('Loaded WOPI token record {id} for file {fileId}.', ['id' => $row['id'] ?? null, 'fileId' => $row['fileid'] ?? null]); + + $wopi = Wopi::fromRow($row); + + if ($wopi->isExpired()) { + throw new ExpiredTokenException('Provided token is expired.'); + } + + return $wopi; + } + + /** + * Return IDs of tokens that expired more than 60 seconds ago, for cleanup jobs. + * + * @return int[] + */ + public function getExpiredTokenIds(?int $limit = null, ?int $offset = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from('office_wopi') + ->where($qb->expr()->lt('expiry', $qb->createNamedParameter(time() - 60, IQueryBuilder::PARAM_INT))) + ->setFirstResult($offset ?? 0) + ->setMaxResults($limit); + + return array_map('intval', array_column($qb->executeQuery()->fetchAll(), 'id')); + } + + /** + * Delete WOPI rows by their primary-key IDs. + * + * @param int[] $ids + */ + public function deleteByIds(array $ids): void { + if (empty($ids)) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete('office_wopi') + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + $qb->executeStatement(); + } + + private function newExpiry(): int { + return $this->timeFactory->getTime() + self::TOKEN_TTL; + } +} diff --git a/lib/Exception/ExpiredTokenException.php b/lib/Exception/ExpiredTokenException.php new file mode 100644 index 00000000..6242e402 --- /dev/null +++ b/lib/Exception/ExpiredTokenException.php @@ -0,0 +1,13 @@ +addAllowedFrameDomain($origin); + $policy->addAllowedFormActionDomain($origin); + return $policy; + } + + private static function origin(#[\SensitiveParameter] string $url): string { + $parts = parse_url($url); + $scheme = is_array($parts) ? strtolower($parts['scheme'] ?? '') : ''; + $host = is_array($parts) ? ($parts['host'] ?? '') : ''; + + if (!in_array($scheme, ['http', 'https'], true) + || preg_match('/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:.]+\])$/i', $host) !== 1) { + throw new \InvalidArgumentException('Editor URL has no valid http(s) origin'); + } + + return $scheme . '://' . $host . (isset($parts['port']) ? ':' . $parts['port'] : ''); + } +} diff --git a/lib/Listener/LoadAdditionalScriptsListener.php b/lib/Listener/LoadAdditionalScriptsListener.php new file mode 100644 index 00000000..ae3ac33f --- /dev/null +++ b/lib/Listener/LoadAdditionalScriptsListener.php @@ -0,0 +1,62 @@ + + */ +final class LoadAdditionalScriptsListener implements IEventListener { + public function __construct( + private DiscoveryService $discoveryService, + private IInitialState $initialState, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof LoadAdditionalScriptsEvent && !$this->isPublicSharePage($event)) { + return; + } + + try { + $mimes = $this->discoveryService->getSupportedMimeTypes(); + } catch (\Throwable) { + $mimes = []; + } + + $this->initialState->provideInitialState('supported-mimes', $mimes); + + Util::addInitScript(Application::APP_ID, 'office-file-actions'); + } + + private function isPublicSharePage(Event $event): bool { + if (!$event instanceof BeforeTemplateRenderedEvent) { + return false; + } + + $response = $event->getResponse(); + return $response instanceof PublicTemplateResponse + && $response->getApp() === 'files' + && $response->getTemplateName() === 'index'; + } +} diff --git a/lib/Middleware/WopiAllowlistMiddleware.php b/lib/Middleware/WopiAllowlistMiddleware.php new file mode 100644 index 00000000..1621827b --- /dev/null +++ b/lib/Middleware/WopiAllowlistMiddleware.php @@ -0,0 +1,94 @@ +appConfig->getValueString(Application::APP_ID, 'wopi_allowlist', '')); + if ($allowlist === '') { + return; + } + + $remoteAddress = $this->request->getRemoteAddress(); + try { + $address = $this->ipFactory->addressFromString($remoteAddress); + } catch (\InvalidArgumentException) { + throw new WopiCallerNotAllowedException('Unparsable caller address'); + } + + $entries = preg_split('/[\s,;|]+/', $allowlist, -1, PREG_SPLIT_NO_EMPTY); + foreach ($entries === false ? [] : $entries as $entry) { + try { + if ($this->ipFactory->rangeFromString($entry)->contains($address)) { + return; + } + } catch (\InvalidArgumentException) { + // A typo must not open or close the allowlist silently: skip the entry and say so, + // once an hour rather than on every WOPI request. + if ($this->logThrottle->allows('wopi-allowlist-invalid', 3600)) { + $this->logger->warning('Ignoring invalid wopi_allowlist entry {entry}', ['entry' => $entry]); + } + } + } + + throw new WopiCallerNotAllowedException('Caller not in wopi_allowlist'); + } + + #[\Override] + public function afterException($controller, $methodName, \Exception $exception): Response { + if (!$exception instanceof WopiCallerNotAllowedException) { + throw $exception; + } + + $context = [ + 'fileId' => $this->request->getParam('fileId'), + 'remoteAddress' => $this->request->getRemoteAddress(), + 'reason' => $exception->getMessage(), + ]; + if ($this->logThrottle->allows('wopi-allowlist-rejected', 300)) { + $this->logger->warning('Rejected WOPI request for file {fileId} from {remoteAddress}: {reason} (repeats within 5 minutes are logged at debug level)', $context); + } else { + $this->logger->debug('Rejected WOPI request for file {fileId} from {remoteAddress}: {reason}', $context); + } + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/Middleware/WopiProofMiddleware.php b/lib/Middleware/WopiProofMiddleware.php new file mode 100644 index 00000000..4d4b44c8 --- /dev/null +++ b/lib/Middleware/WopiProofMiddleware.php @@ -0,0 +1,114 @@ +discoveryService->getProofKeys(); + if ($keys === null && $this->request->getHeader('X-WOPI-Proof') !== '') { + // The editor may have started signing since the keys were last read. + $keys = $this->discoveryService->refreshProofKeys(); + } + if ($keys === null) { + return; + } + + if (!$this->verifier->isFresh($this->request->getHeader('X-WOPI-TimeStamp'))) { + throw new WopiProofException('Missing or expired X-WOPI-TimeStamp'); + } + + if ($this->isSignedWith($keys)) { + return; + } + + // Keys regenerated on the editor (e.g. a recreated container) aren't known yet. + $current = $this->discoveryService->refreshProofKeys(); + if ($current !== null && $current != $keys && $this->isSignedWith($current)) { + return; + } + + throw new WopiProofException('Invalid X-WOPI-Proof'); + } + + private function isSignedWith(WopiProofKeys $keys): bool { + // The editor signs the URL it called: Nextcloud's own absolute URL, or the + // callback_url form of it when the editor reaches Nextcloud by another address. + $url = $this->urlGenerator->getAbsoluteURL($this->request->getRequestUri()); + $accessToken = (string)$this->request->getParam('access_token', ''); + foreach (array_unique([$url, $this->discoveryService->editorReachableUrl($url)]) as $signedUrl) { + if ($this->verifier->isValid( + $keys, + $accessToken, + $signedUrl, + $this->request->getHeader('X-WOPI-TimeStamp'), + $this->request->getHeader('X-WOPI-Proof'), + $this->request->getHeader('X-WOPI-ProofOld'), + )) { + return true; + } + } + + return false; + } + + #[\Override] + public function afterException($controller, $methodName, \Exception $exception): Response { + if (!$exception instanceof WopiProofException) { + throw $exception; + } + + $context = [ + 'fileId' => $this->request->getParam('fileId'), + 'reason' => $exception->getMessage(), + 'remoteAddress' => $this->request->getRemoteAddress(), + ]; + if ($this->logThrottle->allows('wopi-proof-rejected', 300)) { + $this->logger->warning('Rejected WOPI request for file {fileId} from {remoteAddress}: {reason} (repeats within 5 minutes are logged at debug level)', $context); + } else { + $this->logger->debug('Rejected WOPI request for file {fileId} from {remoteAddress}: {reason}', $context); + } + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/Migration/Version1000Date20260522000000.php b/lib/Migration/Version1000Date20260522000000.php new file mode 100644 index 00000000..f27b6d01 --- /dev/null +++ b/lib/Migration/Version1000Date20260522000000.php @@ -0,0 +1,80 @@ +hasTable('office_wopi')) { + return null; + } + + $table = $schema->createTable('office_wopi'); + + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('owner_uid', 'string', [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('editor_uid', 'string', [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('guest_displayname', 'string', [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('fileid', 'bigint', [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('version', 'string', [ + 'notnull' => false, + 'length' => 1024, + 'default' => '0', + ]); + $table->addColumn('canwrite', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + $table->addColumn('server_host', 'string', [ + 'notnull' => true, + 'default' => 'localhost', + ]); + $table->addColumn('token', 'string', [ + 'notnull' => false, + 'length' => 32, + 'default' => '', + ]); + $table->addColumn('expiry', 'bigint', [ + 'notnull' => false, + 'length' => 20, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['token'], 'office_wopi_token_idx'); + $table->addIndex(['fileid'], 'office_wopi_fileid_idx'); + + return $schema; + } +} diff --git a/lib/Migration/Version1001Date20260523000000.php b/lib/Migration/Version1001Date20260523000000.php new file mode 100644 index 00000000..ef445d90 --- /dev/null +++ b/lib/Migration/Version1001Date20260523000000.php @@ -0,0 +1,53 @@ +hasTable('office_wopi_locks')) { + return null; + } + + $table = $schema->createTable('office_wopi_locks'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'unsigned' => true, + 'notnull' => true, + ]); + // One lock per file — fileid is unique. + $table->addColumn('fileid', Types::BIGINT, [ + 'notnull' => true, + ]); + // Opaque lock ID supplied by the WOPI client (up to 1024 chars per spec). + $table->addColumn('lock_id', Types::STRING, [ + 'length' => 1024, + 'notnull' => true, + ]); + // Unix timestamp when the lock expires (WOPI locks default to 30 minutes). + $table->addColumn('expiry', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['fileid'], 'office_wopi_locks_fileid_idx'); + + return $schema; + } +} diff --git a/lib/Migration/Version1002Date20260523000000.php b/lib/Migration/Version1002Date20260523000000.php new file mode 100644 index 00000000..64bbebec --- /dev/null +++ b/lib/Migration/Version1002Date20260523000000.php @@ -0,0 +1,40 @@ +hasTable('office_wopi')) { + return null; + } + + $table = $schema->getTable('office_wopi'); + + if ($table->hasColumn('hide_download')) { + return null; + } + + $table->addColumn('hide_download', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + + return $schema; + } +} diff --git a/lib/Migration/Version1003Date20261026000000.php b/lib/Migration/Version1003Date20261026000000.php new file mode 100644 index 00000000..d6e01191 --- /dev/null +++ b/lib/Migration/Version1003Date20261026000000.php @@ -0,0 +1,44 @@ +hasTable('office_wopi')) { + return null; + } + + $table = $schema->getTable('office_wopi'); + + if ($table->hasColumn('share_token')) { + return null; + } + + $table->addColumn('share_token', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + return $schema; + } +} diff --git a/lib/Migration/Version1004Date20261026000001.php b/lib/Migration/Version1004Date20261026000001.php new file mode 100644 index 00000000..d43c3797 --- /dev/null +++ b/lib/Migration/Version1004Date20261026000001.php @@ -0,0 +1,40 @@ +hasTable('office_wopi')) { + return null; + } + + $table = $schema->getTable('office_wopi'); + + if ($table->hasIndex('office_wopi_expiry_idx')) { + return null; + } + + $table->addIndex(['expiry'], 'office_wopi_expiry_idx'); + + return $schema; + } +} diff --git a/lib/Service/DiscoveryService.php b/lib/Service/DiscoveryService.php new file mode 100644 index 00000000..a85376fc --- /dev/null +++ b/lib/Service/DiscoveryService.php @@ -0,0 +1,338 @@ +cacheFactory->createDistributed('office'); + /** @psalm-suppress MixedAssignment ICache::get() is untyped; only a string is a cached document */ + $cached = $cache->get(self::CACHE_KEY); + if (is_string($cached)) { + return $cached; + } + + $localCache = $this->cacheFactory->createLocal('office'); + if ($this->getEditorUrl() === '' || $localCache->get(self::FAILURE_CACHE_KEY) !== null) { + return null; + } + + try { + return $this->fetch(); + } catch (\Throwable $e) { + $localCache->set(self::FAILURE_CACHE_KEY, true, self::FAILURE_CACHE_TTL); + $this->logger->error('Failed to fetch WOPI discovery: ' . $e->getMessage(), ['exception' => $e]); + return null; + } + } + + /** + * Fetch discovery XML from the editor server and cache it. + * + * @throws \Exception if the request fails + */ + public function fetch(): string { + $client = $this->clientService->newClient(); + $url = $this->getEditorUrl() . '/hosting/discovery'; + + $response = $client->get($url, $this->getRequestOptions()); + $body = (string)$response->getBody(); + + $cache = $this->cacheFactory->createDistributed('office'); + $cache->set(self::CACHE_KEY, $body, self::CACHE_TTL); + + return $body; + } + + public function resetCache(): void { + $cache = $this->cacheFactory->createDistributed('office'); + $cache->remove(self::CACHE_KEY); + // Only clears this node; other nodes retry once their marker expires. + $this->cacheFactory->createLocal('office')->remove(self::FAILURE_CACHE_KEY); + // A different editor server has different keys. + $this->appConfig->deleteKey(Application::APP_ID, self::PROOF_KEYS_CONFIG); + $this->appConfig->deleteKey(Application::APP_ID, self::PROOF_KEYS_CHECKED_AT_CONFIG); + } + + /** + * Whether discovery can be read right now, so callers can tell an unreachable or + * unconfigured editor server apart from a file type it doesn't handle. + */ + public function isAvailable(): bool { + return $this->get() !== null; + } + + /** + * Return the urlsrc template for the given file extension and action name. + * + * @param string $extension e.g. 'docx' + * @param string $action e.g. 'edit' or 'view' + * @return string|null urlsrc template string, or null if not found + */ + public function getUrlSrc(string $extension, string $action = 'edit'): ?string { + // Allowlist: file extensions are plain ASCII alnum — reject anything else to prevent XPath injection. + if (!preg_match('/^[a-zA-Z0-9]{1,20}$/', $extension)) { + return null; + } + + $xml = $this->get(); + if ($xml === null) { + return null; + } + + try { + // LIBXML_NONET prevents libxml from making network requests while parsing (e.g. external DTD subsets). + $parsed = new SimpleXMLElement($xml, LIBXML_NONET | LIBXML_NOCDATA); + } catch (\Exception $e) { + $this->logger->error('Failed to parse WOPI discovery XML: ' . $e->getMessage()); + return null; + } + + $actions = $parsed->xpath( + sprintf('//app/action[@ext="%s" and @name="%s"]', $extension, $action) + ); + + if (!is_array($actions) || $actions === []) { + return null; + } + + return (string)$actions[0]['urlsrc']; + } + + /** + * Return all MIME types advertised by the editor's discovery XML. + * + * Returns an empty array if discovery XML is unavailable or unparseable. + * + * @return string[] + */ + public function getSupportedMimeTypes(): array { + $xml = $this->get(); + if ($xml === null) { + return []; + } + + try { + $parsed = new SimpleXMLElement($xml, LIBXML_NONET | LIBXML_NOCDATA); + } catch (\Exception $e) { + $this->logger->error('Failed to parse WOPI discovery XML: ' . $e->getMessage()); + return []; + } + + $names = $parsed->xpath('//app/@name'); + $mimes = []; + foreach (is_array($names) ? $names : [] as $name) { + $mime = (string)$name; + if ($mime !== '' && str_contains($mime, '/')) { + $mimes[] = $mime; + } + } + + return array_values(array_unique($mimes)); + } + + /** + * The editor's proof keys, or null when it doesn't sign its requests. + * + * Rechecked against discovery once per PROOF_KEYS_TTL. While discovery can't be read, + * the last known keys stay in force. + */ + public function getProofKeys(): ?WopiProofKeys { + $now = $this->timeFactory->getTime(); + if ($this->appConfig->getValueInt(Application::APP_ID, self::PROOF_KEYS_CHECKED_AT_CONFIG) > $now - self::PROOF_KEYS_TTL) { + return $this->storedProofKeys(); + } + + $xml = $this->get(); + if ($xml === null) { + return $this->storedProofKeys(); + } + + try { + $keys = $this->parseProofKeys($xml); + } catch (\Exception $e) { + $this->logger->error('Failed to parse WOPI discovery XML: ' . $e->getMessage()); + return $this->storedProofKeys(); + } + + // Four base64 values, which never contain '|'. + $this->appConfig->setValueString(Application::APP_ID, self::PROOF_KEYS_CONFIG, $keys === null + ? '' + : implode('|', [$keys->modulus, $keys->exponent, $keys->oldModulus, $keys->oldExponent])); + $this->appConfig->setValueInt(Application::APP_ID, self::PROOF_KEYS_CHECKED_AT_CONFIG, $now); + + return $keys; + } + + /** + * Reread the proof keys from the editor now, at most once per PROOF_REFRESH_PERIOD; + * otherwise return the known keys. + */ + public function refreshProofKeys(): ?WopiProofKeys { + $now = $this->timeFactory->getTime(); + if ($this->appConfig->getValueInt(Application::APP_ID, self::PROOF_KEYS_REFRESHED_AT_CONFIG) <= $now - self::PROOF_REFRESH_PERIOD) { + $this->appConfig->setValueInt(Application::APP_ID, self::PROOF_KEYS_REFRESHED_AT_CONFIG, $now); + $this->appConfig->setValueInt(Application::APP_ID, self::PROOF_KEYS_CHECKED_AT_CONFIG, 0); + $this->cacheFactory->createDistributed('office')->remove(self::CACHE_KEY); + } + + return $this->getProofKeys(); + } + + /** + * @throws \Exception on malformed XML + */ + private function parseProofKeys(string $xml): ?WopiProofKeys { + $parsed = new SimpleXMLElement($xml, LIBXML_NONET | LIBXML_NOCDATA); + + $elements = $parsed->xpath('//proof-key'); + if (!is_array($elements) || $elements === []) { + return null; + } + + $attributes = $elements[0]->attributes(); + $modulus = (string)($attributes['modulus'] ?? ''); + $exponent = (string)($attributes['exponent'] ?? ''); + if ($modulus === '' || $exponent === '') { + return null; + } + + $oldModulus = (string)($attributes['oldmodulus'] ?? ''); + $oldExponent = (string)($attributes['oldexponent'] ?? ''); + + return new WopiProofKeys( + $modulus, + $exponent, + $oldModulus !== '' ? $oldModulus : $modulus, + $oldExponent !== '' ? $oldExponent : $exponent, + ); + } + + private function storedProofKeys(): ?WopiProofKeys { + $parts = explode('|', $this->appConfig->getValueString(Application::APP_ID, self::PROOF_KEYS_CONFIG)); + if (count($parts) !== 4 || in_array('', $parts, true)) { + return null; + } + + return new WopiProofKeys($parts[0], $parts[1], $parts[2], $parts[3]); + } + + /** + * Build the final editor URL by substituting the wopisrc template parameter. + * + * The WOPI urlsrc is a template like: + * http://editor/hosting/wopi/word/edit?& + * This method replaces with the actual WOPISrc value. + * + * @param string $urlsrc Raw urlsrc from discovery XML + * @param string $wopiSrc The WOPI host URL (our CheckFileInfo endpoint) + * @param string $token WOPI access token + */ + public function buildEditorUrl(string $urlsrc, string $wopiSrc, string $token): string { + // Rewrite the internal wopi_url origin to the public_wopi_url origin so browsers can reach the editor. + // NC server fetches discovery via the internal Docker hostname (wopi_url); the browser must use the + // public hostname (public_wopi_url). Mirrors the eurooffice DocumentServerInternalUrl/DocumentServerUrl split. + $internalUrl = $this->getEditorUrl(); + $publicUrl = rtrim($this->appConfig->getValueString('office', 'public_wopi_url', ''), '/'); + if ($publicUrl !== '' && $internalUrl !== '' && str_starts_with($urlsrc, $internalUrl)) { + $urlsrc = $publicUrl . substr($urlsrc, strlen($internalUrl)); + } + + $wopiSrc = $this->editorReachableUrl($wopiSrc); + + // Strip WOPI template placeholders like leaving bare ? and & separators. + $url = rtrim((string)preg_replace('/<[^>]+>/', '', $urlsrc), '?&'); + // Preserve the ? already in the base URL if present; otherwise start the query string. + $separator = str_contains($url, '?') ? '&' : '?'; + $url .= $separator . 'wopisrc=' . urlencode($wopiSrc) . '&access_token=' . urlencode($token); + return $url; + } + + /** + * A Nextcloud URL as the editor server reaches it: the origin swapped for callback_url + * when one is configured. Nextcloud generates absolute URLs from the browser-facing + * host, which may not resolve inside the editor's network. Mirrors the eurooffice + * "server address for internal requests" setting. + */ + public function editorReachableUrl(string $url): string { + $callbackUrl = rtrim($this->appConfig->getValueString('office', 'callback_url', ''), '/'); + if ($callbackUrl === '') { + return $url; + } + + $parts = parse_url($url); + if (!is_array($parts) || !isset($parts['host'])) { + return $url; + } + + $origin = ($parts['scheme'] ?? 'http') . '://' . $parts['host'] + . (isset($parts['port']) ? ':' . $parts['port'] : ''); + return $callbackUrl . substr($url, strlen($origin)); + } + + private function getEditorUrl(): string { + return rtrim($this->appConfig->getValueString('office', 'wopi_url', ''), '/'); + } + + private function getRequestOptions(): array { + $options = [ + 'connect_timeout' => 5, + 'timeout' => 15, + 'nextcloud' => ['allow_local_address' => true], + ]; + + if ($this->appConfig->getValueString('office', 'disable_certificate_verification') === 'yes') { + $options['verify'] = false; + } + + return $options; + } +} diff --git a/lib/Service/LogThrottle.php b/lib/Service/LogThrottle.php new file mode 100644 index 00000000..b486632c --- /dev/null +++ b/lib/Service/LogThrottle.php @@ -0,0 +1,34 @@ +cacheFactory->createLocal('office-log-throttle'); + if ($cache->get($key) !== null) { + return false; + } + + $cache->set($key, true, $periodSeconds); + return true; + } +} diff --git a/lib/Service/WopiAccess.php b/lib/Service/WopiAccess.php new file mode 100644 index 00000000..b089e4bc --- /dev/null +++ b/lib/Service/WopiAccess.php @@ -0,0 +1,24 @@ +isShareSession()) { + return $this->resolveThroughShare($wopi); + } + + $editorUid = $wopi->getEditorUid(); + if ($editorUid === null) { + // Share-link token from before tokens were bound to their share. + throw new NotFoundException('WOPI token has neither a user nor a share'); + } + + $nodes = $this->rootFolder->getUserFolder($editorUid)->getById($wopi->getFileid()); + + // Prefer nodes with write permission when multiple exist (e.g. same file mounted in several places) + usort($nodes, static function (Node $a, Node $b): int { + return ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE); + }); + + $file = $nodes[0] ?? null; + if (!$file instanceof File) { + throw new NotFoundException('File not found for WOPI token'); + } + + return new WopiAccess($file, $wopi->getCanwrite(), $wopi->getHideDownload()); + } + + /** + * @throws NotFoundException + */ + private function resolveThroughShare(Wopi $wopi): WopiAccess { + try { + $share = $this->shareManager->getShareByToken((string)$wopi->getShareToken()); + } catch (ShareNotFound) { + throw new NotFoundException('The share of this WOPI token no longer exists'); + } + + $permissions = $share->getPermissions(); + if (($permissions & Constants::PERMISSION_READ) === 0) { + throw new NotFoundException('The share of this WOPI token is no longer readable'); + } + + // The share's node carries the owner's permissions; the share's own permissions decide. + $node = $share->getNode(); + if ($node instanceof Folder) { + // Scoped to the shared folder: a token can't reach files outside it. + $node = $node->getFirstNodeById($wopi->getFileid()); + } + if (!$node instanceof File || $node->getId() !== $wopi->getFileid()) { + throw new NotFoundException('The share of this WOPI token no longer contains its file'); + } + + return new WopiAccess( + $node, + $wopi->getCanwrite() && ($permissions & Constants::PERMISSION_UPDATE) !== 0, + $wopi->getHideDownload() || $share->getHideDownload(), + ); + } +} diff --git a/lib/Service/WopiProofKeys.php b/lib/Service/WopiProofKeys.php new file mode 100644 index 00000000..7beddde6 --- /dev/null +++ b/lib/Service/WopiProofKeys.php @@ -0,0 +1,24 @@ +: + * base64 big-endian modulus and exponent. + */ +final class WopiProofKeys { + public function __construct( + public readonly string $modulus, + public readonly string $exponent, + public readonly string $oldModulus, + public readonly string $oldExponent, + ) { + } +} diff --git a/lib/Service/WopiProofVerifier.php b/lib/Service/WopiProofVerifier.php new file mode 100644 index 00000000..3ba172bc --- /dev/null +++ b/lib/Service/WopiProofVerifier.php @@ -0,0 +1,115 @@ + 19) { + return false; + } + + $unixSeconds = intdiv((int)$timestamp - self::EPOCH_TICKS, 10_000_000); + return $unixSeconds >= $this->timeFactory->getTime() - self::MAX_AGE_SECONDS; + } + + /** + * Valid if X-WOPI-Proof verifies with the current or the old key, or X-WOPI-ProofOld + * with the current key: the combinations MS-WOPI allows across a key rotation. + */ + public function isValid( + WopiProofKeys $keys, + #[\SensitiveParameter] + string $accessToken, + #[\SensitiveParameter] + string $url, + string $timestamp, + string $proof, + string $proofOld, + ): bool { + $signed = $this->signedData($accessToken, $url, $timestamp); + $current = $this->publicKey($keys->modulus, $keys->exponent); + $old = $this->publicKey($keys->oldModulus, $keys->oldExponent); + + return $this->verify($current, $signed, $proof) + || $this->verify($old, $signed, $proof) + || $this->verify($current, $signed, $proofOld); + } + + private function signedData(string $accessToken, string $url, string $timestamp): string { + $upperUrl = mb_strtoupper($url, 'UTF-8'); + + return pack('N', strlen($accessToken)) . $accessToken + . pack('N', strlen($upperUrl)) . $upperUrl + . pack('N', 8) . pack('J', (int)$timestamp); + } + + private function publicKey(string $modulus, string $exponent): ?RSA\PublicKey { + $n = base64_decode($modulus, true); + $e = base64_decode($exponent, true); + if ($n === false || $e === false || $n === '' || $e === '') { + return null; + } + + try { + $key = PublicKeyLoader::loadPublicKey([ + 'n' => new BigInteger($n, 256), + 'e' => new BigInteger($e, 256), + ]); + } catch (\Throwable) { + return null; + } + + if (!$key instanceof RSA\PublicKey) { + return null; + } + + // withHash() and withPadding() return a configured clone but declare no return type. + /** @var RSA\PublicKey $key */ + $key = $key->withHash('sha256'); + /** @var RSA\PublicKey $key */ + $key = $key->withPadding(RSA::SIGNATURE_PKCS1); + return $key; + } + + private function verify(?RSA\PublicKey $key, string $signed, string $proof): bool { + $signature = base64_decode($proof, true); + if ($key === null || $signature === false || $signature === '') { + return false; + } + + try { + return $key->verify($signed, $signature); + } catch (\Throwable) { + return false; + } + } +} diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php new file mode 100644 index 00000000..b3ca9859 --- /dev/null +++ b/lib/Settings/Admin.php @@ -0,0 +1,50 @@ + $this->appConfig->getValueString(Application::APP_ID, 'wopi_url', ''), + 'public_wopi_url' => $this->appConfig->getValueString(Application::APP_ID, 'public_wopi_url', ''), + 'callback_url' => $this->appConfig->getValueString(Application::APP_ID, 'callback_url', ''), + 'disable_certificate_verification' => $this->appConfig->getValueString(Application::APP_ID, 'disable_certificate_verification', 'no'), + // Warn when nothing checks who calls the WOPI endpoints: the editor publishes no + // proof keys and no wopi_allowlist is set. Unknown (null) while discovery is unreachable. + 'wopi_caller_unchecked' => $this->discoveryService->isAvailable() + ? $this->discoveryService->getProofKeys() === null + && trim($this->appConfig->getValueString(Application::APP_ID, 'wopi_allowlist', '')) === '' + : null, + ]); + } + + #[\Override] + public function getSection(): string { + return 'connected-accounts'; + } + + #[\Override] + public function getPriority(): int { + return 50; + } +} diff --git a/lib/TokenManager.php b/lib/TokenManager.php new file mode 100644 index 00000000..6923b160 --- /dev/null +++ b/lib/TokenManager.php @@ -0,0 +1,141 @@ +rootFolder->getUserFolder((string)$this->userId); + + $file = $userFolder->getFirstNodeById($fileId); + + if (!$file instanceof File || !$file->isReadable()) { + throw new NotPermittedException(); + } + + $canWrite = $file->isUpdateable(); + $hideDownload = $this->isDownloadDisabled($userFolder->getById($fileId)); + + $owner = $file->getOwner(); + $ownerUid = $owner !== null ? $owner->getUID() : (string)$this->userId; + + // Fire the read event so audit logging picks it up. + $this->eventDispatcher->dispatchTyped(new BeforeNodeReadEvent($file)); + + $serverHost = $this->urlGenerator->getAbsoluteURL('/'); + $version = (string)$file->getMtime(); + + return $this->wopiMapper->generateFileToken( + fileId: $fileId, + ownerUid: $ownerUid, + editorUid: (string)$this->userId, + version: $version, + canWrite: $canWrite, + hideDownload: $hideDownload, + serverHost: $serverHost, + ); + } + + /** + * Whether a share the user received the file through forbids downloading it. The same + * file can reach a user along several paths; one that forbids it is enough. + * + * @param Node[] $nodes + */ + private function isDownloadDisabled(array $nodes): bool { + foreach ($nodes as $node) { + try { + $storage = $node->getStorage(); + } catch (NotFoundException) { + continue; + } + if (!$storage->instanceOfStorage(ISharedStorage::class)) { + continue; + } + if ($storage->getShare()->getAttributes()?->getAttribute('permissions', 'download') === false) { + return true; + } + } + + return false; + } + + /** + * Generate a WOPI token for a guest opening a file via a share link. + * + * @param int $fileId NC file ID (must be reachable via $ownerUid) + * @param string $ownerUid File owner whose storage is used for I/O + * @param string $shareToken The share the session is opened through; access is re-checked against it + * @param string|null $editorUid The logged-in visitor, or null for an anonymous guest + * @param string $guestName Display name shown in the editor + * @param bool $canWrite Whether the share allows editing + */ + public function generateGuestToken( + int $fileId, + string $ownerUid, + #[\SensitiveParameter] + string $shareToken, + ?string $editorUid, + string $guestName, + bool $canWrite, + bool $hideDownload, + ): Wopi { + $ownerFolder = $this->rootFolder->getUserFolder($ownerUid); + $file = $ownerFolder->getFirstNodeById($fileId); + + if (!$file instanceof File || !$file->isReadable()) { + throw new NotPermittedException(); + } + + $this->eventDispatcher->dispatchTyped(new BeforeNodeReadEvent($file)); + + $serverHost = $this->urlGenerator->getAbsoluteURL('/'); + $version = (string)$file->getMtime(); + + return $this->wopiMapper->generateGuestToken( + fileId: $fileId, + ownerUid: $ownerUid, + shareToken: $shareToken, + editorUid: $editorUid, + guestDisplayname: $guestName, + version: $version, + canWrite: $canWrite, + hideDownload: $hideDownload, + serverHost: $serverHost, + ); + } +} diff --git a/package-lock.json b/package-lock.json index e62532df..0ec60de6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "office", - "version": "2.0.0-dev.0", + "version": "2.0.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "office", - "version": "2.0.0-dev.0", + "version": "2.0.0-dev.1", "license": "AGPL-3.0-or-later", "dependencies": { "@mdi/js": "^7.4.47", @@ -16,6 +16,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.4.0", "@nextcloud/vue": "^9.13.1", "vue": "^3.5.43", "vue-router": "^5.3.1" diff --git a/package.json b/package.json index 2126f6e1..397512b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "office", - "version": "2.0.0-dev.0", + "version": "2.0.0-dev.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { @@ -24,6 +24,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.4.0", "@nextcloud/vue": "^9.13.1", "vue": "^3.5.43", "vue-router": "^5.3.1" diff --git a/psalm.xml b/psalm.xml index 36e7a873..9f2aec0f 100644 --- a/psalm.xml +++ b/psalm.xml @@ -16,6 +16,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 00000000..4adefb6c --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import Editor from './views/Editor.vue' + +const app = createApp(Editor) +app.mount('#office-editor') diff --git a/src/file-actions.ts b/src/file-actions.ts new file mode 100644 index 00000000..422991cb --- /dev/null +++ b/src/file-actions.ts @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { DefaultType, Permission, registerFileAction } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' + +const supportedMimes: string[] = loadState('office', 'supported-mimes', []) + +registerFileAction({ + id: 'office-open', + + displayName: () => 'Open in Office', + + iconSvgInline: () => '', + + default: DefaultType.DEFAULT, + + order: 10, + + enabled: ({ nodes }) => { + if (nodes.length !== 1) { + return false + } + const node = nodes[0] + if ((node.permissions & Permission.READ) === 0) { + return false + } + return supportedMimes.includes(node.mime ?? '') + }, + + exec: async ({ nodes }) => { + const node = nodes[0] + const fileId = node.fileid + + if (fileId === undefined) { + return false + } + + if (isPublicShare()) { + const token = getSharingToken() + if (!token) { + return false + } + window.location.href = generateUrl('/apps/office/open/share/{token}', { token }) + + '?fileId=' + encodeURIComponent(String(fileId)) + } else { + window.location.href = generateUrl('/apps/office/open') + '?fileId=' + encodeURIComponent(String(fileId)) + } + + return true + }, +}) diff --git a/src/settings-admin.ts b/src/settings-admin.ts new file mode 100644 index 00000000..2aaaf303 --- /dev/null +++ b/src/settings-admin.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import AdminSettings from './settings/AdminSettings.vue' + +const app = createApp(AdminSettings) +app.mount('#office-settings-admin') diff --git a/src/settings/AdminSettings.spec.ts b/src/settings/AdminSettings.spec.ts new file mode 100644 index 00000000..f7474732 --- /dev/null +++ b/src/settings/AdminSettings.spec.ts @@ -0,0 +1,96 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { flushPromises, mount } from '@vue/test-utils' +import NcButton from '@nextcloud/vue/components/NcButton' +import { generateUrl } from '@nextcloud/router' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AdminSettings from './AdminSettings.vue' + +const { postMock } = vi.hoisted(() => ({ postMock: vi.fn() })) + +vi.mock('@nextcloud/axios', () => ({ + default: { post: postMock }, + isAxiosError: (e: unknown) => (e as { isAxiosError?: boolean })?.isAxiosError === true, +})) + +function setAdminData(data: Record) { + const el = document.createElement('script') + el.id = 'office-admin-data' + el.type = 'application/json' + el.textContent = JSON.stringify(data) + document.body.appendChild(el) +} + +describe('AdminSettings', () => { + beforeEach(() => { + document.body.innerHTML = '' + postMock.mockReset() + setAdminData({ + wopi_url: 'http://eo-internal', + public_wopi_url: 'https://office.example.com', + callback_url: '', + disable_certificate_verification: 'no', + }) + }) + + it('renders the form with the stored settings', () => { + const wrapper = mount(AdminSettings) + + expect(wrapper.find('h2').text()).toBe('Office') + const values = wrapper.findAll('input[type="url"]').map((input) => (input.element as HTMLInputElement).value) + expect(values).toEqual(['http://eo-internal', 'https://office.example.com', '']) + }) + + it.each([ + [true, true], + [false, false], + [null, false], + ])('warns about unchecked WOPI callers only when the server reports them (%s)', (unchecked, shown) => { + document.body.innerHTML = '' + setAdminData({ wopi_url: 'http://eo-internal', public_wopi_url: '', callback_url: '', disable_certificate_verification: 'no', wopi_caller_unchecked: unchecked }) + + const wrapper = mount(AdminSettings) + + expect(wrapper.text().includes('does not sign its requests')).toBe(shown) + }) + + it('styles the save button as the primary action', () => { + const button = mount(AdminSettings).findComponent(NcButton) + + expect(button.props('variant')).toBe('primary') + }) + + it('saves through the router-generated URL so subdirectory installs work', async () => { + postMock.mockResolvedValue({ data: {} }) + const wrapper = mount(AdminSettings) + + await wrapper.findComponent(NcButton).trigger('click') + await flushPromises() + + expect(generateUrl).toHaveBeenCalledWith('/apps/office/settings/admin') + expect(postMock).toHaveBeenCalledWith('/apps/office/settings/admin', { + wopi_url: 'http://eo-internal', + public_wopi_url: 'https://office.example.com', + callback_url: '', + disable_certificate_verification: 'no', + }) + expect(wrapper.text()).toContain('Settings saved') + }) + + it('shows the server\'s validation message when saving is rejected', async () => { + postMock.mockRejectedValue({ + isAxiosError: true, + message: 'Request failed with status code 400', + response: { data: { error: 'wopi_url must use the http or https scheme' } }, + }) + const wrapper = mount(AdminSettings) + + await wrapper.findComponent(NcButton).trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('wopi_url must use the http or https scheme') + }) +}) diff --git a/src/settings/AdminSettings.vue b/src/settings/AdminSettings.vue new file mode 100644 index 00000000..0d5047f2 --- /dev/null +++ b/src/settings/AdminSettings.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/views/Editor.spec.ts b/src/views/Editor.spec.ts new file mode 100644 index 00000000..b74aecf0 --- /dev/null +++ b/src/views/Editor.spec.ts @@ -0,0 +1,67 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Editor from './Editor.vue' + +function setEditorData(data: Record) { + const el = document.createElement('script') + el.id = 'office-editor-data' + el.type = 'application/json' + el.textContent = JSON.stringify(data) + document.body.appendChild(el) +} + +describe('Editor', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('submits a hidden POST form instead of a GET iframe src, and keeps wopisrc in the URL while moving access_token to a form field', () => { + // EO's WOPI editor route only registers a POST handler, so an + // `