From 0d074ba976380491f2e0d2201cc5d3e2e61676b2 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 07:23:24 -0400 Subject: [PATCH 01/21] fix(setup): derive WOPI URLs for builtin CODE more reliably Signed-off-by: Josh --- lib/AppConfig.php | 53 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/lib/AppConfig.php b/lib/AppConfig.php index f3bc849f27..998b116478 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -12,8 +12,10 @@ use OCP\AppFramework\Services\IAppConfig; use OCP\GlobalScale\IConfig as GlobalScaleConfig; use OCP\IConfig; +use OCP\IURLGenerator; class AppConfig { + public const SERVER_MODE = 'server_mode'; // URL that Nextcloud will use to connect to Collabora public const WOPI_URL = 'wopi_url'; // URL that the browser will use to connect to Collabora (inherited from the discovery endpoint of Collabora, @@ -61,6 +63,7 @@ public function __construct( private IAppConfig $appConfig, private IAppManager $appManager, private GlobalScaleConfig $globalScaleConfig, + private IURLGenerator $urlGenerator, ) { } @@ -134,6 +137,33 @@ public function getAppSettings() { return $result; } + public function getServerMode(): string { + return $this->config->getAppValue(Application::APPNAME, self::SERVER_MODE, ''); + } + + public function isBuiltinServer(): bool { + return $this->getServerMode() === 'builtin'; + } + + /** + * Returns the built-in CODE proxy URL derived at runtime from IURLGenerator. + * Never stored - always fresh, so it survives Nextcloud URL/domain changes. + * Returns null if CODE is not installed or not supported on this platform. + */ + public function getBuiltinServerUrl(): ?string { + $arch = php_uname('m'); + $supportedArchs = ['x86_64', 'aarch64']; + if (PHP_OS_FAMILY !== 'Linux' || !in_array($arch, $supportedArchs)) { + return null; + } + $CODEAppID = ($arch === 'aarch64') ? 'richdocumentscode_arm64' : 'richdocumentscode'; + if (!$this->appManager->isInstalled($CODEAppID)) { + return null; + } + $relativeUrl = $this->urlGenerator->linkTo($CODEAppID, '') . 'proxy.php'; + return $this->urlGenerator->getAbsoluteURL($relativeUrl) . '?req='; + } + /** * Returns a list of trusted domains from the gs.trustedHosts config */ @@ -148,12 +178,31 @@ public function isTrustedDomainAllowedForFederation(): bool { return $this->config->getAppValue(Application::APPNAME, self::FEDERATION_USE_TRUSTED_DOMAINS, 'no') === 'yes'; } + /** + * For builtin mode, public_wopi_url is always Nextcloud's own public origin — + * CODE has no separate hostname. Derived from IURLGenerator so it is correct + * in both HTTP and CLI contexts (the latter requires overwrite.cli.url to be set). + * Falls back to stored value for custom/standalone servers. + */ public function getCollaboraUrlPublic(): string { - return rtrim($this->config->getAppValue(Application::APPNAME, self::PUBLIC_WOPI_URL, $this->getCollaboraUrlInternal()), '/'); + if ($this->isBuiltinServer()) { + $nextcloudUrl = $this->urlGenerator->getAbsoluteURL('/'); + return rtrim($this->domainOnly($nextcloudUrl), '/'); + } + return rtrim($this->config->getAppValue(Application::APPNAME, self::PUBLIC_WOPI_URL, + $this->getCollaboraUrlInternal()), '/'); } + /** + * For builtin mode, wopi_url is derived at runtime rather than read from storage. + * This ensures correctness after domain changes and avoids the CLI/browser + * context mismatch that arises from storing an absolute URL at configuration time. + */ public function getCollaboraUrlInternal(): string { - return rtrim($this->config->getAppValue(Application::APPNAME, self::WOPI_URL, ''), '/'); + if ($this->isBuiltinServer()) { + return $this->getBuiltinServerUrl() ?? ''; + } + return rtrim($this->config->getAppValue(Application::APPNAME, self::WOPI_URL, ''), '/'); } public function getNextcloudUrl(): string { From 42249298eab7058ceb45cdc7aa6a79216ef826bd Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 07:29:36 -0400 Subject: [PATCH 02/21] chore(setup): bypass autoConfigurePublicUrl if using builtin CODE Signed-off-by: Josh --- lib/Service/ConnectivityService.php | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/Service/ConnectivityService.php b/lib/Service/ConnectivityService.php index cbe6b9c93e..76d0896833 100644 --- a/lib/Service/ConnectivityService.php +++ b/lib/Service/ConnectivityService.php @@ -49,14 +49,28 @@ public function testCapabilities(OutputInterface $output): void { } /** - * Detect public URL of the WOPI server for setting CSP on Nextcloud + * Detect public URL of the WOPI server for setting CSP on Nextcloud. * * This value is not meant to be set manually. If this turns out to be the wrong URL - * it is likely a misconfiguration on your WOPI server. Collabora will inherit the URL to use - * form the request and the ssl.enable/ssl.termination settings and server_name (if configured) + * it is likely a misconfiguration either of the Collabora (i.e. server_name) or + * Nextcloud itself (i.e. overwrite.cli.url). + * + * Skipped for the built-in CODE server: public_wopi_url for builtin is always + * Nextcloud's own public origin, derived directly from IURLGenerator in AppConfig. + * Running discovery-based detection server-side would be redundant and would produce + * incorrect results in CLI context where overwrite.cli.url may differ from the + * public-facing URL that CODE's ProxyPrefix would return to a browser. + * + * For standalone Collabora, server_name in coolwsd.xml makes urlsrc deterministic + * regardless of request context, so server-side detection remains appropriate. */ public function autoConfigurePublicUrl(): void { - $determinedUrl = $this->parser->getUrlSrcValue('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + if ($this->appConfig->isBuiltinServer()) { + return; + } + $determinedUrl = $this->parser->getUrlSrcValue( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); $detectedUrl = $this->appConfig->domainOnly($determinedUrl); $this->appConfig->setAppValue('public_wopi_url', $detectedUrl); } From 44f86be089c71c65ca67b8bf4d471c1b86cda210 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 07:38:31 -0400 Subject: [PATCH 03/21] chore(setup): adjust SettingsController for builtin URL handling Signed-off-by: Josh --- lib/Controller/SettingsController.php | 38 ++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index d633f192ae..ac9235a3e2 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -120,12 +120,15 @@ private function getSettingsData(): array { 'esignature_base_url' => $this->appConfig->getAppValue('esignature_base_url'), 'esignature_client_id' => $this->appConfig->getAppValue('esignature_client_id'), 'esignature_secret' => $this->appConfig->getAppValue('esignature_secret'), - 'userId' => $this->userId + 'userId' => $this->userId, + 'server_mode' => $this->appConfig->getServerMode(), + 'builtin_server_url' => $this->appConfig->getBuiltinServerUrl(), ]; } public function setSettings( ?string $wopi_url, + ?string $server_mode, ?string $wopi_allowlist, ?bool $disable_certificate_verification, ?string $edit_groups, @@ -137,8 +140,23 @@ public function setSettings( ?string $esignature_client_id, ?string $esignature_secret, ): JSONResponse { - if ($wopi_url !== null) { - $this->appConfig->setAppValue('wopi_url', $wopi_url); + if ($server_mode === 'builtin') { + $builtinUrl = $this->appConfig->getBuiltinServerUrl(); + if ($builtinUrl === null) { + return new JSONResponse([ + 'status' => 'error', + 'data' => ['message' => 'Built-in CODE server is not installed or not supported on this platform.'], + ], Http::STATUS_BAD_REQUEST); + } + // Store server_mode as authoritative; wopi_url stored for backward compatibility only. + // public_wopi_url is now derived dynamically in AppConfig::getCollaboraUrlPublic() + // so no stored value is needed. + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $builtinUrl); + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); + $this->appConfig->setAppValue('disable_certificate_verification', 'yes'); + } elseif ($wopi_url !== null) { + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopi_url); + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); } if ($wopi_allowlist !== null) { @@ -146,10 +164,8 @@ public function setSettings( } if ($disable_certificate_verification !== null) { - $this->appConfig->setAppValue( - 'disable_certificate_verification', - $disable_certificate_verification ? 'yes' : '' - ); + $this->appConfig->setAppValue('disable_certificate_verification', + $disable_certificate_verification ? 'yes' : ''); } if ($edit_groups !== null) { @@ -188,7 +204,7 @@ public function setSettings( $output = new NullOutput(); $this->connectivityService->testDiscovery($output); $this->connectivityService->testCapabilities($output); - $this->connectivityService->autoConfigurePublicUrl(); + $this->connectivityService->autoConfigurePublicUrl(); // no-op for builtin } catch (\Throwable $e) { return new JSONResponse([ 'status' => 'error', @@ -196,15 +212,13 @@ public function setSettings( ], 500); } - $response = [ + return new JSONResponse([ 'status' => 'success', 'data' => [ 'message' => $this->l10n->t('Saved'), 'settings' => $this->getSettingsData(), ] - ]; - - return new JSONResponse($response); + ]); } public function updateWatermarkSettings($settings = []): JSONResponse { From b7d9a9548e4a6897cfb7b0c2b8660e18c3fd4569 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 07:40:18 -0400 Subject: [PATCH 04/21] chore(setup): add mode/builtin settings Signed-off-by: Josh --- lib/Settings/Admin.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 067876f481..78a2ebde1c 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -41,6 +41,8 @@ public function getForm(): TemplateResponse { 'admin', [ 'settings' => [ + 'server_mode' => $this->appConfig->getServerMode(), + 'builtin_server_url' => $this->appConfig->getBuiltinServerUrl(), 'wopi_url' => $this->appConfig->getCollaboraUrlInternal(), 'public_wopi_url' => $this->appConfig->getCollaboraUrlPublic(), 'wopi_callback_url' => $this->appConfig->getNextcloudUrl(), From a781f948a4d47152da02031270d2f508c877570b Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 08:02:27 -0400 Subject: [PATCH 05/21] feat(setup): add builtin CODE specific support to ActivateConfig Signed-off-by: Josh --- lib/Command/ActivateConfig.php | 105 +++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 11 deletions(-) diff --git a/lib/Command/ActivateConfig.php b/lib/Command/ActivateConfig.php index 652e9c199e..3f260a0142 100644 --- a/lib/Command/ActivateConfig.php +++ b/lib/Command/ActivateConfig.php @@ -5,7 +5,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Richdocuments\Command; use OCA\Richdocuments\AppConfig; @@ -29,16 +28,25 @@ protected function configure(): void { $this ->setName('richdocuments:activate-config') ->setAliases(['richdocuments:setup']) - ->addOption('wopi-url', 'w', InputOption::VALUE_REQUIRED, 'URL that the Nextcloud server will use to connect to Collabora', null) - ->addOption('callback-url', 'c', InputOption::VALUE_REQUIRED, 'URL that is passed to Collabora to connect back to Nextcloud', null) + ->addOption('wopi-url', 'w', InputOption::VALUE_REQUIRED, + 'URL that Nextcloud will use to connect to Collabora', null) + ->addOption('builtin', null, InputOption::VALUE_NONE, + 'Configure the built-in CODE server (richdocumentscode app must be installed)') + ->addOption('callback-url', 'c', InputOption::VALUE_REQUIRED, + 'URL that is passed to Collabora to connect back to Nextcloud', null) ->setDescription('Activate config changes'); } protected function execute(InputInterface $input, OutputInterface $output): int { try { + if ($input->getOption('builtin')) { + return $this->executeBuiltin($input, $output); + } + if ($input->getOption('wopi-url') !== null) { $wopiUrl = $input->getOption('wopi-url'); $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopiUrl); + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); $output->writeln('✓ Set WOPI url to ' . $wopiUrl . ''); } @@ -60,7 +68,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->connectivityService->testDiscovery($output); } catch (\Throwable $e) { - $output->writeln('Failed to fetch discovery endpoint from ' . $this->appConfig->getCollaboraUrlInternal()); + $output->writeln('Failed to fetch discovery endpoint from ' . $this->appConfig->getCollaboraUrlInternal() . ''); $output->writeln($e->getMessage()); return 1; } @@ -68,13 +76,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->connectivityService->testCapabilities($output); } catch (\Throwable $e) { - // FIXME: Optional when allowing generic WOPI servers - $output->writeln('Failed to fetch capabilities endpoint from ' . $this->capabilitiesService->getCapabilitiesEndpoint()); + $output->writeln('Failed to fetch capabilities endpoint from ' . $this->capabilitiesService->getCapabilitiesEndpoint() . ''); $output->writeln($e->getMessage()); return 1; } try { + // For custom servers, derives public_wopi_url from discovery urlsrc $this->connectivityService->autoConfigurePublicUrl(); } catch (\Throwable $e) { $output->writeln('Failed to determine public URL from discovery response'); @@ -83,20 +91,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Summarize URLs for easier debugging - $output->writeln(''); $output->writeln('Collabora URL (used for Nextcloud to contact the Collabora server):'); $output->writeln(' ' . $this->appConfig->getCollaboraUrlInternal()); - $output->writeln('Collabora public URL (used in the browser to open Collabora):'); $output->writeln(' ' . $this->appConfig->getCollaboraUrlPublic()); - $output->writeln('Callback URL (used by Collabora to connect back to Nextcloud):'); $callbackUrl = $this->appConfig->getNextcloudUrl(); if ($callbackUrl === '') { - $output->writeln(' autodetected (will use the same URL as your user for browsing Nextcloud)'); + $output->writeln(' autodetected (will use the same URL as your users for browsing Nextcloud)'); } else { - $output->writeln(' ' . $this->appConfig->getNextcloudUrl()); + $output->writeln(' ' . $callbackUrl); } return 0; @@ -107,4 +112,82 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } } + + private function executeBuiltin(InputInterface $input, OutputInterface $output): int { + // getBuiltinServerUrl() already checks OS/arch/installed + $builtinUrl = $this->appConfig->getBuiltinServerUrl(); + if ($builtinUrl === null) { + $output->writeln('Built-in CODE server is not available.'); + $output->writeln('Check: richdocumentscode (or richdocumentscode_arm64) is installed,' + . ' OS is Linux, arch is x86_64 or aarch64.'); + return 1; + } + + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $builtinUrl); + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); + $this->appConfig->setAppValue('disable_certificate_verification', 'yes'); + $output->writeln('✓ Configured built-in CODE server'); + + // Derive public_wopi_url from IURLGenerator -- same source, same result as HTTP context, + // provided overwrite.cli.url is correctly set (a standard Nextcloud requirement). + // For builtin, public_wopi_url is simply Nextcloud's own public origin. + $publicUrl = $this->appConfig->getCollaboraUrlPublic(); // delegates to IURLGenerator for builtin + + // Sanity check: if the derived URL looks internal, overwrite.cli.url is probably wrong. + // Fail fast with an actionable message rather than silently storing a broken value. + // Often be a false positive warning in test environments. + $host = parse_url($publicUrl, PHP_URL_HOST); + if ($host === 'localhost' || $host === '127.0.0.1' || str_ends_with($host, '.local')) { + $output->writeln('Derived public URL looks internal: ' . $publicUrl . ''); + $output->writeln('Is "overwrite.cli.url" set correctly in config.php?'); + $output->writeln('This is required for any occ command that generates absolute URLs.'); + // TODO: Roll back unless forced: don't leave a broken server_mode=builtin with an internal public URL + // $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); + // return 1; + } + + $output->writeln(''); + $output->writeln('Built-in CODE URL (Nextcloud → CODE, server-side):'); + $output->writeln(' ' . $builtinUrl); + $output->writeln('Public URL (browser → CODE, via Nextcloud proxy):'); + $output->writeln(' ' . $publicUrl); + + // Test internal connectivity (server-to-server via proxy.php — valid in CLI context) + try { + $this->connectivityService->testDiscovery($output); + } catch (\Throwable $e) { + $output->writeln('Failed to reach built-in CODE server internally: ' + . $e->getMessage() . ''); + // TODO: don't rollback if forced + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); + return 1; + } + + try { + $this->connectivityService->testCapabilities($output); + } catch (\Throwable $e) { + $output->writeln('Failed to fetch capabilities from built-in CODE server: ' + . $e->getMessage() . ''); + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); + return 1; + } + + // autoConfigurePublicUrl() is intentionally not called here: + // it is a no-op for builtin (see ConnectivityService), and public_wopi_url + // is derived dynamically by AppConfig::getCollaboraUrlPublic() for builtin mode. + + if ($input->getOption('callback-url') !== null) { + $callbackUrl = $input->getOption('callback-url'); + $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl); + $output->writeln('✓ Set callback url to ' . $callbackUrl . ''); + } else { + $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, ''); + $output->writeln('✓ Callback URL: autodetect'); + } + + $output->writeln(''); + $output->writeln('✓ Built-in CODE server configured successfully.'); + + return 0; + } } From d97e63a5f8bac328a0dacb2719d6f1dac9290149 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 08:29:19 -0400 Subject: [PATCH 06/21] chore(setup): adjust AdminSettings to handle new builtin server_mode Signed-off-by: Josh --- src/components/AdminSettings.vue | 79 ++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 9f8c331ec8..dbb43f8e2a 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -662,7 +662,6 @@ export default { try { result = await axios.get(generateUrl('/apps/richdocuments/settings/check')) this.serverError = SERVER_STATE_OK - } catch (e) { this.serverError = SERVER_STATE_CONNECTION_ERROR result = e.response @@ -675,22 +674,76 @@ export default { const { settings } = result?.data?.data || {} for (const settingKey in settings) { if (settingKey === 'use_groups' || settingKey === 'edit_groups') { - this.settings[settingKey] = settings[settingKey] ? settings[settingKey].split('|') : [] + this.settings[settingKey] = settings[settingKey] + ? settings[settingKey].split('|') + : [] continue } this.settings[settingKey] = settings[settingKey] } + + this.checkIfDemoServerIsActive() this.checkFrontend() }, async checkFrontend() { try { - await fetch(this.settings.public_wopi_url + '/hosting/discovery', { mode: 'no-cors' }) - await fetch(this.settings.public_wopi_url + '/hosting/capabilities', { mode: 'no-cors' }) + // For builtin: proxy.php is same-origin so the discovery response is fully readable. + // CODE's ProxyPrefix rewrites urlsrc to reflect the browser's host/scheme -- + // giving us the correct public_wopi_url as a self-healing side-effect on every load. + if (this.serverMode === 'builtin' && this.settings.wopi_url) { + const discoveryRes = await fetch( + this.settings.wopi_url + '/hosting/discovery' + ) + if (discoveryRes.ok) { + const xml = await discoveryRes.text() + const detectedOrigin = this.extractOriginFromDiscovery(xml) + if (detectedOrigin && detectedOrigin !== this.settings.public_wopi_url) { + // Persist corrected public_wopi_url back to the server. + // This self-heals after domain migrations without requiring + // manual reconfiguration. + await axios.post( + generateUrl('/apps/richdocuments/settings/admin'), + { public_wopi_url: detectedOrigin } + ) + this.settings.public_wopi_url = detectedOrigin + } + } + // Verify capabilities endpoint also reachable from browser + await fetch( + this.settings.wopi_url + '/hosting/capabilities', + { mode: 'no-cors' } + ) + } else { + // For custom/standalone: public_wopi_url is set server-side; just verify reachability. + await fetch( + this.settings.public_wopi_url + '/hosting/discovery', + { mode: 'no-cors' } + ) + await fetch( + this.settings.public_wopi_url + '/hosting/capabilities', + { mode: 'no-cors' } + ) + } } catch (e) { console.error(e) this.serverError = SERVER_STATE_BROWSER_CONNECTION_ERROR } }, + // Extract scheme+host from any urlsrc in the discovery XML. + // For builtin, ProxyPrefix ensures this reflects the browser's public origin. + extractOriginFromDiscovery(xmlString) { + try { + const xml = new DOMParser().parseFromString(xmlString, 'text/xml') + const action = xml.querySelector('action[urlsrc]') + if (action) { + const urlsrc = action.getAttribute('urlsrc') + return new URL(urlsrc).origin // e.g. "https://cloud.example.com" + } + } catch (e) { + console.error('Failed to parse origin from discovery XML', e) + } + return null + }, async fetchDemoServers() { try { const result = await axios.get(generateUrl('/apps/richdocuments/settings/demo')) @@ -823,15 +876,17 @@ export default { } }, checkIfDemoServerIsActive() { - this.settings.demoUrl = this.demoServers ? this.demoServers.find((server) => server.demo_url === this.settings.wopi_url) : null - this.settings.CODEUrl = this.CODEInstalled ? window.location.protocol + '//' + window.location.host + generateFilePath(this.CODEAppID, '', '') + 'proxy.php?req=' : null + this.settings.demoUrl = this.demoServers + ? this.demoServers.find((server) => server.demo_url === this.settings.wopi_url) + : null + if (this.settings.wopi_url && this.settings.wopi_url !== '') { this.serverMode = 'custom' } if (this.settings.demoUrl) { this.serverMode = 'demo' this.approvedDemoModal = true - } else if (this.settings.CODEUrl && this.settings.CODEUrl === this.settings.wopi_url) { + } else if (this.settings.server_mode === 'builtin') { this.serverMode = 'builtin' } }, @@ -843,10 +898,14 @@ export default { this.settings.disable_certificate_verification = false await this.updateServer() }, + // Tell the server to activate builtin mode; it derives wopi_url via IURLGenerator. async setBuiltinServer() { - this.settings.wopi_url = this.settings.CODEUrl - this.settings.disable_certificate_verification = false - await this.updateServer() + await this.updateSettings({ + server_mode: 'builtin', + disable_certificate_verification: false, + }) + // updateSettings() applies the returned settings (including server_mode, + // wopi_url, builtin_server_url) and calls checkFrontend(); no extra work needed. }, checkUrlProtocol(string) { let url From 57f5907b0df4113a44bda1d8c1bcebce042af7a7 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 11:43:48 -0400 Subject: [PATCH 07/21] chore(setup): backup fallback for legacy builtin installs in AppConfig Signed-off-by: Josh --- lib/AppConfig.php | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/AppConfig.php b/lib/AppConfig.php index 998b116478..ce1e12c399 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -137,8 +137,21 @@ public function getAppSettings() { return $result; } + /** + * Returns the configured server mode ('builtin', 'custom', 'demo', or ''). + */ public function getServerMode(): string { - return $this->config->getAppValue(Application::APPNAME, self::SERVER_MODE, ''); + $stored = $this->config->getAppValue(Application::APPNAME, self::SERVER_MODE, ''); + if ($stored !== '') { + return $stored; + } + // Fallback for legacy builtin installs: if the migration step has not yet run (or was + // somehow skipped), detect builtin from the stored wopi_url. + $wopiUrl = $this->config->getAppValue(Application::APPNAME, self::WOPI_URL, ''); + if ($wopiUrl !== '' && str_contains($wopiUrl, 'proxy.php?req=')) { + return 'builtin'; + } + return ''; } public function isBuiltinServer(): bool { @@ -181,8 +194,10 @@ public function isTrustedDomainAllowedForFederation(): bool { /** * For builtin mode, public_wopi_url is always Nextcloud's own public origin — * CODE has no separate hostname. Derived from IURLGenerator so it is correct - * in both HTTP and CLI contexts (the latter requires overwrite.cli.url to be set). - * Falls back to stored value for custom/standalone servers. + * in both HTTP and CLI contexts (the latter requires overwrite.cli.url to be set, + * which is a standard Nextcloud prerequisite). + * + * For custom/standalone servers, returns the stored public_wopi_url. */ public function getCollaboraUrlPublic(): string { if ($this->isBuiltinServer()) { @@ -193,13 +208,10 @@ public function getCollaboraUrlPublic(): string { $this->getCollaboraUrlInternal()), '/'); } - /** - * For builtin mode, wopi_url is derived at runtime rather than read from storage. - * This ensures correctness after domain changes and avoids the CLI/browser - * context mismatch that arises from storing an absolute URL at configuration time. - */ public function getCollaboraUrlInternal(): string { if ($this->isBuiltinServer()) { + // Derives the internal URL at runtime from IURLGenerator. + // This avoids the CLI/browser context mismatch that otherwise arise since built-in uses ProxyPrefix not server_name/etc return $this->getBuiltinServerUrl() ?? ''; } return rtrim($this->config->getAppValue(Application::APPNAME, self::WOPI_URL, ''), '/'); From 8eefd485cc5f07a0d34630bfe78f0c465e30d109 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 16:10:31 -0400 Subject: [PATCH 08/21] chore(setup): add builtin CODE rollback and force mode Signed-off-by: Josh --- lib/Controller/SettingsController.php | 73 ++++++++++++++++++++------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index ac9235a3e2..11e55a266b 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -129,6 +129,7 @@ private function getSettingsData(): array { public function setSettings( ?string $wopi_url, ?string $server_mode, + ?bool $force, // skip connectivity checks ?string $wopi_allowlist, ?bool $disable_certificate_verification, ?string $edit_groups, @@ -148,13 +149,48 @@ public function setSettings( 'data' => ['message' => 'Built-in CODE server is not installed or not supported on this platform.'], ], Http::STATUS_BAD_REQUEST); } - // Store server_mode as authoritative; wopi_url stored for backward compatibility only. - // public_wopi_url is now derived dynamically in AppConfig::getCollaboraUrlPublic() - // so no stored value is needed. - $this->appConfig->setAppValue(AppConfig::WOPI_URL, $builtinUrl); + + // Capture current state for rollback before writing anything + $previousMode = $this->appConfig->getServerMode(); + $previousWopiUrl = $this->config->getAppValue('richdocuments', 'wopi_url', ''); + + // Activate builtin mode: clear stored wopi_url and public_wopi_url so they + // are henceforth derived dynamically. server_mode is the only stored value. + $this->config->deleteAppValue('richdocuments', 'wopi_url'); + $this->config->deleteAppValue('richdocuments', 'public_wopi_url'); $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); $this->appConfig->setAppValue('disable_certificate_verification', 'yes'); - } elseif ($wopi_url !== null) { + + if (!$force) { + try { + $output = new NullOutput(); + $this->connectivityService->testDiscovery($output); + $this->connectivityService->testCapabilities($output); + $this->connectivityService->autoConfigurePublicUrl(); // no-op for builtin + } catch (\Throwable $e) { + // Rollback atomically — restore exactly what was there before + if ($previousWopiUrl !== '') { + $this->config->setAppValue('richdocuments', 'wopi_url', $previousWopiUrl); + } + if ($previousMode !== '') { + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, $previousMode); + } else { + $this->config->deleteAppValue('richdocuments', 'server_mode'); + } + return new JSONResponse([ + 'status' => 'error', + 'data' => ['message' => 'Failed to connect to built-in CODE server: ' . $e->getMessage()], + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + return new JSONResponse([ + 'status' => 'success', + 'data' => ['message' => $this->l10n->t('Saved'), 'settings' => $this->getSettingsData()], + ]); + } + + if ($wopi_url !== null) { $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopi_url); $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); } @@ -200,24 +236,23 @@ public function setSettings( $this->appConfig->setAppValue('esignature_secret', $esignature_secret); } - try { - $output = new NullOutput(); - $this->connectivityService->testDiscovery($output); - $this->connectivityService->testCapabilities($output); - $this->connectivityService->autoConfigurePublicUrl(); // no-op for builtin - } catch (\Throwable $e) { - return new JSONResponse([ - 'status' => 'error', - 'data' => ['message' => 'Failed to connect to the remote server: ' . $e->getMessage()] - ], 500); + if (!$force) { + try { + $output = new NullOutput(); + $this->connectivityService->testDiscovery($output); + $this->connectivityService->testCapabilities($output); + $this->connectivityService->autoConfigurePublicUrl(); + } catch (\Throwable $e) { + return new JSONResponse([ + 'status' => 'error', + 'data' => ['message' => 'Failed to connect to the remote server: ' . $e->getMessage()] + ], 500); + } } return new JSONResponse([ 'status' => 'success', - 'data' => [ - 'message' => $this->l10n->t('Saved'), - 'settings' => $this->getSettingsData(), - ] + 'data' => ['message' => $this->l10n->t('Saved'), 'settings' => $this->getSettingsData()], ]); } From c11443f3998cea2162cb91342fda081124904798 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 16:30:50 -0400 Subject: [PATCH 09/21] chore(setup): add force option to ActivateConfig Signed-off-by: Josh --- lib/Command/ActivateConfig.php | 266 +++++++++++++++++++-------------- 1 file changed, 150 insertions(+), 116 deletions(-) diff --git a/lib/Command/ActivateConfig.php b/lib/Command/ActivateConfig.php index 3f260a0142..38c1803259 100644 --- a/lib/Command/ActivateConfig.php +++ b/lib/Command/ActivateConfig.php @@ -34,6 +34,8 @@ protected function configure(): void { 'Configure the built-in CODE server (richdocumentscode app must be installed)') ->addOption('callback-url', 'c', InputOption::VALUE_REQUIRED, 'URL that is passed to Collabora to connect back to Nextcloud', null) + ->addOption('force', null, InputOption::VALUE_NONE, + 'Persist configuration even if connectivity or sanity checks fail') ->setDescription('Activate config changes'); } @@ -42,69 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($input->getOption('builtin')) { return $this->executeBuiltin($input, $output); } - - if ($input->getOption('wopi-url') !== null) { - $wopiUrl = $input->getOption('wopi-url'); - $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopiUrl); - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); - $output->writeln('✓ Set WOPI url to ' . $wopiUrl . ''); - } - - if ($input->getOption('callback-url') !== null) { - $callbackUrl = $input->getOption('callback-url'); - $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl); - $output->writeln('✓ Set callback url to ' . $callbackUrl . ''); - } else { - $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, ''); - $output->writeln('✓ Reset callback url autodetect'); - } - - $output->writeln('Checking configuration'); - $output->writeln('🛈 Configured WOPI URL: ' . $this->appConfig->getCollaboraUrlInternal()); - $output->writeln('🛈 Configured public WOPI URL: ' . $this->appConfig->getCollaboraUrlPublic()); - $output->writeln('🛈 Configured callback URL: ' . $this->appConfig->getNextcloudUrl()); - $output->writeln(''); - - try { - $this->connectivityService->testDiscovery($output); - } catch (\Throwable $e) { - $output->writeln('Failed to fetch discovery endpoint from ' . $this->appConfig->getCollaboraUrlInternal() . ''); - $output->writeln($e->getMessage()); - return 1; - } - - try { - $this->connectivityService->testCapabilities($output); - } catch (\Throwable $e) { - $output->writeln('Failed to fetch capabilities endpoint from ' . $this->capabilitiesService->getCapabilitiesEndpoint() . ''); - $output->writeln($e->getMessage()); - return 1; - } - - try { - // For custom servers, derives public_wopi_url from discovery urlsrc - $this->connectivityService->autoConfigurePublicUrl(); - } catch (\Throwable $e) { - $output->writeln('Failed to determine public URL from discovery response'); - $output->writeln($e->getMessage()); - return 1; - } - - // Summarize URLs for easier debugging - $output->writeln(''); - $output->writeln('Collabora URL (used for Nextcloud to contact the Collabora server):'); - $output->writeln(' ' . $this->appConfig->getCollaboraUrlInternal()); - $output->writeln('Collabora public URL (used in the browser to open Collabora):'); - $output->writeln(' ' . $this->appConfig->getCollaboraUrlPublic()); - $output->writeln('Callback URL (used by Collabora to connect back to Nextcloud):'); - $callbackUrl = $this->appConfig->getNextcloudUrl(); - if ($callbackUrl === '') { - $output->writeln(' autodetected (will use the same URL as your users for browsing Nextcloud)'); - } else { - $output->writeln(' ' . $callbackUrl); - } - - return 0; + return $this->executeCustom($input, $output); } catch (\Exception $e) { $output->writeln('Failed to activate any config changes'); $output->writeln($e->getMessage()); @@ -114,7 +54,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } private function executeBuiltin(InputInterface $input, OutputInterface $output): int { - // getBuiltinServerUrl() already checks OS/arch/installed + $force = (bool)$input->getOption('force'); + + // Validate preconditions before writing anything: getBuiltinServerUrl() checks OS/arch/installed $builtinUrl = $this->appConfig->getBuiltinServerUrl(); if ($builtinUrl === null) { $output->writeln('Built-in CODE server is not available.'); @@ -123,71 +65,163 @@ private function executeBuiltin(InputInterface $input, OutputInterface $output): return 1; } - $this->appConfig->setAppValue(AppConfig::WOPI_URL, $builtinUrl); - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); - $this->appConfig->setAppValue('disable_certificate_verification', 'yes'); - $output->writeln('✓ Configured built-in CODE server'); - - // Derive public_wopi_url from IURLGenerator -- same source, same result as HTTP context, - // provided overwrite.cli.url is correctly set (a standard Nextcloud requirement). - // For builtin, public_wopi_url is simply Nextcloud's own public origin. - $publicUrl = $this->appConfig->getCollaboraUrlPublic(); // delegates to IURLGenerator for builtin + // public_wopi_url for builtin is always Nextcloud's own public origin, + // derived from IURLGenerator. This requires overwrite.cli.url to be set - + // the same standard prerequisite as any occ command generating absolute URLs. + $publicUrl = $this->appConfig->getCollaboraUrlPublic(); // delegates to IURLGenerator for builtin // Sanity check: if the derived URL looks internal, overwrite.cli.url is probably wrong. // Fail fast with an actionable message rather than silently storing a broken value. - // Often be a false positive warning in test environments. + // Often will be a false positive warning in test environments, but can be forced if necessary still. $host = parse_url($publicUrl, PHP_URL_HOST); - if ($host === 'localhost' || $host === '127.0.0.1' || str_ends_with($host, '.local')) { + $looksInternal = $host === 'localhost' || $host === '127.0.0.1' || str_ends_with($host, '.local'); + + if ($looksInternal && !$force) { $output->writeln('Derived public URL looks internal: ' . $publicUrl . ''); - $output->writeln('Is "overwrite.cli.url" set correctly in config.php?'); + $output->writeln('"overwrite.cli.url" in config.php must be set to your public Nextcloud URL.'); $output->writeln('This is required for any occ command that generates absolute URLs.'); - // TODO: Roll back unless forced: don't leave a broken server_mode=builtin with an internal public URL - // $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); - // return 1; - } - - $output->writeln(''); - $output->writeln('Built-in CODE URL (Nextcloud → CODE, server-side):'); - $output->writeln(' ' . $builtinUrl); - $output->writeln('Public URL (browser → CODE, via Nextcloud proxy):'); - $output->writeln(' ' . $publicUrl); - - // Test internal connectivity (server-to-server via proxy.php — valid in CLI context) - try { - $this->connectivityService->testDiscovery($output); - } catch (\Throwable $e) { - $output->writeln('Failed to reach built-in CODE server internally: ' - . $e->getMessage() . ''); - // TODO: don't rollback if forced - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); + $output->writeln('(cron jobs, notifications, shares, etc.).'); + $output->writeln('To override and persist anyway: --force'); return 1; } - try { - $this->connectivityService->testCapabilities($output); - } catch (\Throwable $e) { - $output->writeln('Failed to fetch capabilities from built-in CODE server: ' - . $e->getMessage() . ''); - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); - return 1; + if ($looksInternal) { + $output->writeln('⚠ Warning: public URL looks internal (' . $publicUrl . ').' + . ' Proceeding anyway due to --force.'); } - // autoConfigurePublicUrl() is intentionally not called here: - // it is a no-op for builtin (see ConnectivityService), and public_wopi_url - // is derived dynamically by AppConfig::getCollaboraUrlPublic() for builtin mode. - - if ($input->getOption('callback-url') !== null) { - $callbackUrl = $input->getOption('callback-url'); - $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl); - $output->writeln('✓ Set callback url to ' . $callbackUrl . ''); - } else { - $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, ''); - $output->writeln('✓ Callback URL: autodetect'); - } + // Test connectivity against the (not-yet-saved) builtin URL. + // ConnectivityService uses AppConfig::getCollaboraUrlInternal(), which already + // returns the IURLGenerator-derived URL for builtin mode (via getServerMode() + // legacy fallback or the mode we're about to set — but since we haven't set + // server_mode yet, we temporarily pass the URL directly). + // To avoid this chicken-and-egg, set server_mode transiently for the test, + // capture previous state for clean rollback. + $previousMode = $this->appConfig->getServerMode(); + $previousWopiUrl = $this->appConfig->getAppValue(AppConfig::WOPI_URL); + + // Set mode now so ConnectivityService picks up the right internal URL + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); + + if (!$force) { + try { + $this->connectivityService->testDiscovery($output); + $this->connectivityService->testCapabilities($output); + } catch (\Throwable $e) { + // Rollback: nothing persisted except server_mode which we restore + if ($previousMode !== '') { + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, $previousMode); + } else { + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); + } + $output->writeln('Failed to reach built-in CODE server: ' . $e->getMessage() . ''); + $output->writeln('To configure without connectivity: --force'); + return 1; + } + } + + // Connectivity confirmed (or --force). Commit: clear stale stored URLs — + // they are now derived dynamically from IURLGenerator. + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); + $this->appConfig->setAppValue('disable_certificate_verification', 'yes'); + // Explicitly delete any previously stored wopi_url and public_wopi_url + // so there is no ambiguity about which value is canonical. + $this->appConfig->setAppValue(AppConfig::WOPI_URL, ''); + $this->appConfig->setAppValue(AppConfig::PUBLIC_WOPI_URL, ''); + + if ($input->getOption('callback-url') !== null) { + $callbackUrl = $input->getOption('callback-url'); + $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl); + $output->writeln('✓ Set callback URL to ' . $callbackUrl . ''); + } else { + $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, ''); + } - $output->writeln(''); - $output->writeln('✓ Built-in CODE server configured successfully.'); + $output->writeln(''); + $output->writeln('✓ Built-in CODE server configured successfully.'); + $output->writeln(''); + $output->writeln('Built-in CODE URL (Nextcloud → CODE, server-side):'); + $output->writeln(' ' . $this->appConfig->getCollaboraUrlInternal()); + $output->writeln('Public URL (browser → CODE, via Nextcloud proxy):'); + $output->writeln(' ' . $this->appConfig->getCollaboraUrlPublic()); + $output->writeln('Callback URL (Collabora → Nextcloud):'); + $callbackUrl = $this->appConfig->getNextcloudUrl(); + $output->writeln($callbackUrl === '' + ? ' autodetected (uses the same URL as the user\'s browser)' + : ' ' . $callbackUrl); return 0; } + + private function executeCustom(InputInterface $input, OutputInterface $output): int { + $force = (bool)$input->getOption('force'); + + if ($input->getOption('wopi-url') !== null) { + $wopiUrl = $input->getOption('wopi-url'); + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopiUrl); + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); + $output->writeln('✓ Set WOPI url to ' . $wopiUrl . ''); + } + + if ($input->getOption('callback-url') !== null) { + $callbackUrl = $input->getOption('callback-url'); + $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl); + $output->writeln('✓ Set callback url to ' . $callbackUrl . ''); + } else { + $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, ''); + $output->writeln('✓ Reset callback url autodetect'); + } + + $output->writeln('Checking configuration'); + $output->writeln('🛈 Configured WOPI URL: ' . $this->appConfig->getCollaboraUrlInternal()); + $output->writeln('🛈 Configured public WOPI URL: ' . $this->appConfig->getCollaboraUrlPublic()); + $output->writeln('🛈 Configured callback URL: ' . $this->appConfig->getNextcloudUrl()); + $output->writeln(''); + + if (!$force) { + try { + $this->connectivityService->testDiscovery($output); + } catch (\Throwable $e) { + $output->writeln('Failed to fetch discovery endpoint from ' + . $this->appConfig->getCollaboraUrlInternal() . ''); + $output->writeln($e->getMessage()); + $output->writeln('To configure without connectivity: --force'); + return 1; + } + + try { + $this->connectivityService->testCapabilities($output); + } catch (\Throwable $e) { + $output->writeln('Failed to fetch capabilities from ' + . $this->capabilitiesService->getCapabilitiesEndpoint() . ''); + $output->writeln($e->getMessage()); + $output->writeln('To configure without connectivity: --force'); + return 1; + } + + try { + $this->connectivityService->autoConfigurePublicUrl(); + } catch (\Throwable $e) { + $output->writeln('Failed to determine public URL from discovery response'); + $output->writeln($e->getMessage()); + $output->writeln('To configure without connectivity: --force'); + return 1; + } + } else { + $output->writeln('⚠ Skipping connectivity checks (--force).'); + } + + $output->writeln(''); + $output->writeln('Collabora URL (used for Nextcloud to contact the Collabora server):'); + $output->writeln(' ' . $this->appConfig->getCollaboraUrlInternal()); + $output->writeln('Collabora public URL (used in the browser to open Collabora):'); + $output->writeln(' ' . $this->appConfig->getCollaboraUrlPublic()); + $output->writeln('Callback URL (used by Collabora to connect back to Nextcloud):'); + $callbackUrl = $this->appConfig->getNextcloudUrl(); + $output->writeln($callbackUrl === '' + ? ' autodetected (will use the same URL as your user for browsing Nextcloud)' + : ' ' . $callbackUrl); + + return 0; + } } From ae6691bb0010b9f117ed27bc309898fd2289bd54 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 16:41:25 -0400 Subject: [PATCH 10/21] chore(setup): drop self-healing for CODE from AdminSettings Signed-off-by: Josh --- src/components/AdminSettings.vue | 39 ++++++-------------------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index dbb43f8e2a..db43dd2ba5 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -688,25 +688,16 @@ export default { async checkFrontend() { try { // For builtin: proxy.php is same-origin so the discovery response is fully readable. - // CODE's ProxyPrefix rewrites urlsrc to reflect the browser's host/scheme -- - // giving us the correct public_wopi_url as a self-healing side-effect on every load. if (this.serverMode === 'builtin' && this.settings.wopi_url) { + // Full read (no mode: 'no-cors') because proxy.php is same-origin. + // We parse the response only to surface a clear error if CODE is + // unreachable or returning unexpected content; no writeback needed. const discoveryRes = await fetch( this.settings.wopi_url + '/hosting/discovery' ) - if (discoveryRes.ok) { - const xml = await discoveryRes.text() - const detectedOrigin = this.extractOriginFromDiscovery(xml) - if (detectedOrigin && detectedOrigin !== this.settings.public_wopi_url) { - // Persist corrected public_wopi_url back to the server. - // This self-heals after domain migrations without requiring - // manual reconfiguration. - await axios.post( - generateUrl('/apps/richdocuments/settings/admin'), - { public_wopi_url: detectedOrigin } - ) - this.settings.public_wopi_url = detectedOrigin - } + if (!discoveryRes.ok) { + this.serverError = SERVER_STATE_BROWSER_CONNECTION_ERROR + return } // Verify capabilities endpoint also reachable from browser await fetch( @@ -714,7 +705,6 @@ export default { { mode: 'no-cors' } ) } else { - // For custom/standalone: public_wopi_url is set server-side; just verify reachability. await fetch( this.settings.public_wopi_url + '/hosting/discovery', { mode: 'no-cors' } @@ -729,21 +719,6 @@ export default { this.serverError = SERVER_STATE_BROWSER_CONNECTION_ERROR } }, - // Extract scheme+host from any urlsrc in the discovery XML. - // For builtin, ProxyPrefix ensures this reflects the browser's public origin. - extractOriginFromDiscovery(xmlString) { - try { - const xml = new DOMParser().parseFromString(xmlString, 'text/xml') - const action = xml.querySelector('action[urlsrc]') - if (action) { - const urlsrc = action.getAttribute('urlsrc') - return new URL(urlsrc).origin // e.g. "https://cloud.example.com" - } - } catch (e) { - console.error('Failed to parse origin from discovery XML', e) - } - return null - }, async fetchDemoServers() { try { const result = await axios.get(generateUrl('/apps/richdocuments/settings/demo')) @@ -902,7 +877,7 @@ export default { async setBuiltinServer() { await this.updateSettings({ server_mode: 'builtin', - disable_certificate_verification: false, + //disable_certificate_verification: false, }) // updateSettings() applies the returned settings (including server_mode, // wopi_url, builtin_server_url) and calls checkFrontend(); no extra work needed. From 2c8c3b8fb09020f8a2190fbbde78c4de1bd0f39f Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 16:59:26 -0400 Subject: [PATCH 11/21] feat(ConnectivityService): add a method to test w/o touching config Signed-off-by: Josh --- lib/Service/ConnectivityService.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/Service/ConnectivityService.php b/lib/Service/ConnectivityService.php index 76d0896833..49dff8e578 100644 --- a/lib/Service/ConnectivityService.php +++ b/lib/Service/ConnectivityService.php @@ -48,6 +48,32 @@ public function testCapabilities(OutputInterface $output): void { $output->writeln('✓ Detected WOPI server: ' . $this->capabilitiesService->getServerProductName() . ' ' . $this->capabilitiesService->getProductVersion() . ''); } + /** + * Test discovery and capabilities reachability against an explicit URL. + * Used when the URL to test has not yet been committed to config — avoids + * the need to transiently mutate server_mode just to resolve the right URL. + */ + public function testUrl(string $wopiUrl, OutputInterface $output): void { + // Temporarily override the URL for the duration of this test by driving + // DiscoveryService and CapabilitiesService directly with the given URL, + // rather than going through AppConfig. + $previousUrl = $this->appConfig->getAppValue(AppConfig::WOPI_URL); + $previousMode = $this->appConfig->getServerMode(); + + // Write only the raw wopi_url key — not server_mode — so AppConfig's + // getCollaboraUrlInternal() custom path (builtin vs stored) is bypassed + // and the explicit URL is used directly. + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopiUrl); + + try { + $this->testDiscovery($output); + $this->testCapabilities($output); + } finally { + // Always restore, whether the test passed or threw + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $previousUrl); + } + } + /** * Detect public URL of the WOPI server for setting CSP on Nextcloud. * From 0ba24ca5c54dd38212841c976ecafac5d05975b5 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 17:13:24 -0400 Subject: [PATCH 12/21] chore(setup): drop need for transient config mutation and rollback Signed-off-by: Josh --- lib/Command/ActivateConfig.php | 60 +++++++++------------------------- 1 file changed, 16 insertions(+), 44 deletions(-) diff --git a/lib/Command/ActivateConfig.php b/lib/Command/ActivateConfig.php index 38c1803259..39ee4a7b76 100644 --- a/lib/Command/ActivateConfig.php +++ b/lib/Command/ActivateConfig.php @@ -65,12 +65,12 @@ private function executeBuiltin(InputInterface $input, OutputInterface $output): return 1; } - // public_wopi_url for builtin is always Nextcloud's own public origin, - // derived from IURLGenerator. This requires overwrite.cli.url to be set - - // the same standard prerequisite as any occ command generating absolute URLs. - $publicUrl = $this->appConfig->getCollaboraUrlPublic(); // delegates to IURLGenerator for builtin + // Validate public URL looks correct before writing anything. + // For builtin, the public URL is always Nextcloud's own origin — derived + // directly from IURLGenerator without requiring server_mode to be set yet. + $publicUrl = $this->appConfig->deriveBuiltinPublicUrl(); - // Sanity check: if the derived URL looks internal, overwrite.cli.url is probably wrong. + // If the derived URL looks internal, overwrite.cli.url is probably wrong. // Fail fast with an actionable message rather than silently storing a broken value. // Often will be a false positive warning in test environments, but can be forced if necessary still. $host = parse_url($publicUrl, PHP_URL_HOST); @@ -80,7 +80,6 @@ private function executeBuiltin(InputInterface $input, OutputInterface $output): $output->writeln('Derived public URL looks internal: ' . $publicUrl . ''); $output->writeln('"overwrite.cli.url" in config.php must be set to your public Nextcloud URL.'); $output->writeln('This is required for any occ command that generates absolute URLs.'); - $output->writeln('(cron jobs, notifications, shares, etc.).'); $output->writeln('To override and persist anyway: --force'); return 1; } @@ -90,65 +89,38 @@ private function executeBuiltin(InputInterface $input, OutputInterface $output): . ' Proceeding anyway due to --force.'); } - // Test connectivity against the (not-yet-saved) builtin URL. - // ConnectivityService uses AppConfig::getCollaboraUrlInternal(), which already - // returns the IURLGenerator-derived URL for builtin mode (via getServerMode() - // legacy fallback or the mode we're about to set — but since we haven't set - // server_mode yet, we temporarily pass the URL directly). - // To avoid this chicken-and-egg, set server_mode transiently for the test, - // capture previous state for clean rollback. - $previousMode = $this->appConfig->getServerMode(); - $previousWopiUrl = $this->appConfig->getAppValue(AppConfig::WOPI_URL); - - // Set mode now so ConnectivityService picks up the right internal URL - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); - + // Test connectivity against the derived URL directly; no config mutation needed. if (!$force) { try { - $this->connectivityService->testDiscovery($output); - $this->connectivityService->testCapabilities($output); + $this->connectivityService->testUrl($builtinUrl, $output); } catch (\Throwable $e) { - // Rollback: nothing persisted except server_mode which we restore - if ($previousMode !== '') { - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, $previousMode); - } else { - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); - } + // Nothing was writtent o config; no rollback needed $output->writeln('Failed to reach built-in CODE server: ' . $e->getMessage() . ''); $output->writeln('To configure without connectivity: --force'); return 1; } } - // Connectivity confirmed (or --force). Commit: clear stale stored URLs — - // they are now derived dynamically from IURLGenerator. + // All checks passed (or --force). Now commit atomically. $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); $this->appConfig->setAppValue('disable_certificate_verification', 'yes'); - // Explicitly delete any previously stored wopi_url and public_wopi_url - // so there is no ambiguity about which value is canonical. + // Explicitly delete any previously stored wopi_url and public_wopi_url to avoid ambiguity. $this->appConfig->setAppValue(AppConfig::WOPI_URL, ''); $this->appConfig->setAppValue(AppConfig::PUBLIC_WOPI_URL, ''); if ($input->getOption('callback-url') !== null) { $callbackUrl = $input->getOption('callback-url'); $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl); - $output->writeln('✓ Set callback URL to ' . $callbackUrl . ''); } else { $this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, ''); } - $output->writeln(''); - $output->writeln('✓ Built-in CODE server configured successfully.'); - $output->writeln(''); - $output->writeln('Built-in CODE URL (Nextcloud → CODE, server-side):'); - $output->writeln(' ' . $this->appConfig->getCollaboraUrlInternal()); - $output->writeln('Public URL (browser → CODE, via Nextcloud proxy):'); - $output->writeln(' ' . $this->appConfig->getCollaboraUrlPublic()); - $output->writeln('Callback URL (Collabora → Nextcloud):'); - $callbackUrl = $this->appConfig->getNextcloudUrl(); - $output->writeln($callbackUrl === '' - ? ' autodetected (uses the same URL as the user\'s browser)' - : ' ' . $callbackUrl); + $output->writeln('✓ Built-in CODE server configured successfully.'); + $output->writeln('Built-in CODE URL (Nextcloud → CODE): ' . $this->appConfig->getCollaboraUrlInternal()); + $output->writeln('Public URL (browser → CODE): ' . $this->appConfig->getCollaboraUrlPublic()); + $callbackUrl = $this->appConfig->getNextcloudUrl(); + $output->writeln('Callback URL (Collabora → Nextcloud): ' + . ($callbackUrl === '' ? 'autodetected' : $callbackUrl)); return 0; } From 71a8a904eafeb5d00edb34f1b3b68f78e2df388a Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 17:14:41 -0400 Subject: [PATCH 13/21] chore(AppConfig): add helper so URL can be derived w/o server_mode being set Signed-off-by: Josh --- lib/AppConfig.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/AppConfig.php b/lib/AppConfig.php index ce1e12c399..93defc27e1 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -317,6 +317,15 @@ private function getGSDomains(): array { return $this->getGlobalScaleTrustedHosts(); } + /** + * Derives the public URL for the built-in CODE server directly from IURLGenerator, + * without requiring server_mode to already be set. Used during initial setup/CLI + * where we want to validate the URL before committing server_mode to config. + */ + public function deriveBuiltinPublicUrl(): string { + return rtrim($this->domainOnly($this->urlGenerator->getAbsoluteURL('/')), '/'); + } + /** * Strips the path and query parameters from the URL. */ From 7b49da5521d6bc1d94b78a22633d31685dfa8ed5 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 17:16:11 -0400 Subject: [PATCH 14/21] chore: use new deriveBuiltinPublicUrl in getCollaboraUrlPublic Signed-off-by: Josh --- lib/AppConfig.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/AppConfig.php b/lib/AppConfig.php index 93defc27e1..b3711ce314 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -201,8 +201,7 @@ public function isTrustedDomainAllowedForFederation(): bool { */ public function getCollaboraUrlPublic(): string { if ($this->isBuiltinServer()) { - $nextcloudUrl = $this->urlGenerator->getAbsoluteURL('/'); - return rtrim($this->domainOnly($nextcloudUrl), '/'); + return $this->deriveBuiltinPublicUrl(); } return rtrim($this->config->getAppValue(Application::APPNAME, self::PUBLIC_WOPI_URL, $this->getCollaboraUrlInternal()), '/'); From 5aa525964a39631be6c352618ec3a97ff4ae61c9 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 17:23:19 -0400 Subject: [PATCH 15/21] chore(Settings): fix asymetric rollback Signed-off-by: Josh --- lib/Controller/SettingsController.php | 37 +++++++++++++-------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 11e55a266b..7737aac151 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -150,32 +150,31 @@ public function setSettings( ], Http::STATUS_BAD_REQUEST); } - // Capture current state for rollback before writing anything - $previousMode = $this->appConfig->getServerMode(); - $previousWopiUrl = $this->config->getAppValue('richdocuments', 'wopi_url', ''); - - // Activate builtin mode: clear stored wopi_url and public_wopi_url so they - // are henceforth derived dynamically. server_mode is the only stored value. - $this->config->deleteAppValue('richdocuments', 'wopi_url'); - $this->config->deleteAppValue('richdocuments', 'public_wopi_url'); + // Capture full previous state before any mutation for symmetric rollback + $snapshot = [ + AppConfig::SERVER_MODE => $this->appConfig->getServerMode(), + AppConfig::WOPI_URL => $this->config->getAppValue('richdocuments', AppConfig::WOPI_URL, ''), + AppConfig::PUBLIC_WOPI_URL => $this->config->getAppValue('richdocuments', AppConfig::PUBLIC_WOPI_URL, ''), + 'disable_certificate_verification' => $this->config->getAppValue( + 'richdocuments', 'disable_certificate_verification', ''), + ]; + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'builtin'); $this->appConfig->setAppValue('disable_certificate_verification', 'yes'); + $this->config->deleteAppValue('richdocuments', AppConfig::WOPI_URL); + $this->config->deleteAppValue('richdocuments', AppConfig::PUBLIC_WOPI_URL); if (!$force) { try { $output = new NullOutput(); - $this->connectivityService->testDiscovery($output); - $this->connectivityService->testCapabilities($output); - $this->connectivityService->autoConfigurePublicUrl(); // no-op for builtin + $this->connectivityService->testUrl($builtinUrl, $output); } catch (\Throwable $e) { - // Rollback atomically — restore exactly what was there before - if ($previousWopiUrl !== '') { - $this->config->setAppValue('richdocuments', 'wopi_url', $previousWopiUrl); - } - if ($previousMode !== '') { - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, $previousMode); - } else { - $this->config->deleteAppValue('richdocuments', 'server_mode'); + foreach ($snapshot as $key => $value) { + if ($value !== '') { + $this->config->setAppValue('richdocuments', $key, $value); + } else { + $this->config->deleteAppValue('richdocuments', $key); + } } return new JSONResponse([ 'status' => 'error', From b1cfc620c97b064558b6a2ba71913a801dd10f1f Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 19:58:38 -0400 Subject: [PATCH 16/21] chore(Connectivity): temporarily force server_mode when testing connectivity Signed-off-by: Josh --- lib/Service/ConnectivityService.php | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/Service/ConnectivityService.php b/lib/Service/ConnectivityService.php index 49dff8e578..1abe7d0270 100644 --- a/lib/Service/ConnectivityService.php +++ b/lib/Service/ConnectivityService.php @@ -50,19 +50,20 @@ public function testCapabilities(OutputInterface $output): void { /** * Test discovery and capabilities reachability against an explicit URL. - * Used when the URL to test has not yet been committed to config — avoids - * the need to transiently mutate server_mode just to resolve the right URL. + * + * This temporarily forces custom mode so AppConfig resolves the provided + * wopi_url directly instead of builtin-mode derivation logic. + * Previous config values are always restored afterwards. */ public function testUrl(string $wopiUrl, OutputInterface $output): void { // Temporarily override the URL for the duration of this test by driving // DiscoveryService and CapabilitiesService directly with the given URL, // rather than going through AppConfig. $previousUrl = $this->appConfig->getAppValue(AppConfig::WOPI_URL); - $previousMode = $this->appConfig->getServerMode(); + $previousMode = $this->appConfig->getAppValue(AppConfig::SERVER_MODE); - // Write only the raw wopi_url key — not server_mode — so AppConfig's - // getCollaboraUrlInternal() custom path (builtin vs stored) is bypassed - // and the explicit URL is used directly. + // Force explicit-URL resolution through the stored wopi_url path. + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopiUrl); try { @@ -70,7 +71,17 @@ public function testUrl(string $wopiUrl, OutputInterface $output): void { $this->testCapabilities($output); } finally { // Always restore, whether the test passed or threw - $this->appConfig->setAppValue(AppConfig::WOPI_URL, $previousUrl); + if ($previousMode !== '') { + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, $previousMode); + } else { + $this->appConfig->deleteAppValue(AppConfig::SERVER_MODE); + } + + if ($previousUrl !== '') { + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $previousUrl); + } else { + $this->appConfig->deleteAppValue(AppConfig::WOPI_URL); + } } } From 8dbb27acf35874dcb7e3ef539fc66961cc1e81ad Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 20:23:40 -0400 Subject: [PATCH 17/21] chore(setup): adjust ConnectivityService w/o upstream AppConfig Signed-off-by: Josh --- lib/Service/ConnectivityService.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/Service/ConnectivityService.php b/lib/Service/ConnectivityService.php index 1abe7d0270..0ea330e0aa 100644 --- a/lib/Service/ConnectivityService.php +++ b/lib/Service/ConnectivityService.php @@ -59,8 +59,8 @@ public function testUrl(string $wopiUrl, OutputInterface $output): void { // Temporarily override the URL for the duration of this test by driving // DiscoveryService and CapabilitiesService directly with the given URL, // rather than going through AppConfig. - $previousUrl = $this->appConfig->getAppValue(AppConfig::WOPI_URL); - $previousMode = $this->appConfig->getAppValue(AppConfig::SERVER_MODE); + $previousUrl = $this->appConfig->getAppValue(AppConfig::WOPI_URL, ''); + $previousMode = $this->appConfig->getAppValue(AppConfig::SERVER_MODE, ''); // Force explicit-URL resolution through the stored wopi_url path. $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); @@ -74,13 +74,14 @@ public function testUrl(string $wopiUrl, OutputInterface $output): void { if ($previousMode !== '') { $this->appConfig->setAppValue(AppConfig::SERVER_MODE, $previousMode); } else { - $this->appConfig->deleteAppValue(AppConfig::SERVER_MODE); + // TODO: rename "appConfig" (which isn't; it's not actually the Server AppFramework version I expected)) + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, ''); } if ($previousUrl !== '') { $this->appConfig->setAppValue(AppConfig::WOPI_URL, $previousUrl); } else { - $this->appConfig->deleteAppValue(AppConfig::WOPI_URL); + $this->appConfig->setAppValue(AppConfig::WOPI_URL, ''); } } } From ca9c07e04b86dfe0a4c149244ae2e88b15a44d1d Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 20:27:27 -0400 Subject: [PATCH 18/21] chore(settings): honour $server_mode instead of hardcoding custom Signed-off-by: Josh --- lib/Controller/SettingsController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 7737aac151..fdf3fe1026 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -190,8 +190,10 @@ public function setSettings( } if ($wopi_url !== null) { - $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopi_url); - $this->appConfig->setAppValue(AppConfig::SERVER_MODE, 'custom'); + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopi_url); + // Use the provided server_mode if given; default to 'custom' for + // backward-compatible callers that don't send it (e.g. direct API calls). + $this->appConfig->setAppValue(AppConfig::SERVER_MODE, $server_mode ?? 'custom'); } if ($wopi_allowlist !== null) { From 4faefc24e184362f4e17e3475313a0c5fbbdac06 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 7 May 2026 20:30:23 -0400 Subject: [PATCH 19/21] chore(settings): use updateSettings directly to specify mode Signed-off-by: Josh --- src/components/AdminSettings.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index db43dd2ba5..ea63b5595b 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -871,7 +871,12 @@ export default { async setDemoServer(server) { this.settings.wopi_url = server.demo_url this.settings.disable_certificate_verification = false - await this.updateServer() + await this.updateSettings({ + server_mode: 'demo', + wopi_url: server.demo_url, + disable_certificate_verification: false, + }) + this.checkIfDemoServerIsActive() }, // Tell the server to activate builtin mode; it derives wopi_url via IURLGenerator. async setBuiltinServer() { From 6550afa8ecedd5b99b9ad5a63c504fd3c2008581 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 8 May 2026 10:21:50 -0400 Subject: [PATCH 20/21] chore: add add'l AppConfig arg in AddContentSecurityPolicyListenerTest Signed-off-by: Josh --- tests/lib/Listener/AddContentSecurityPolicyListenerTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php b/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php index 0de81b8a54..4f920b95ad 100644 --- a/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php +++ b/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php @@ -18,6 +18,7 @@ use OCP\GlobalScale\IConfig as GlobalScaleConfig; use OCP\IConfig; use OCP\IRequest; +use OCP\IURLGenerator; use OCP\Security\CSP\AddContentSecurityPolicyEvent; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -36,6 +37,7 @@ class AddContentSecurityPolicyListenerTest extends TestCase { private $gsConfig; /** @var FederationService|MockObject */ private $federationService; + private IURLGenerator|MockObject $urlGenerator; private CapabilitiesService|MockObject $capabilitiesService; private AddContentSecurityPolicyListener $listener; @@ -45,6 +47,7 @@ public function setUp(): void { $this->appManager = $this->createMock(IAppManager::class); $this->gsConfig = $this->createMock(GlobalScaleConfig::class); $this->federationService = $this->createMock(FederationService::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->overwriteService(FederationService::class, $this->federationService); @@ -58,6 +61,7 @@ public function setUp(): void { $this->createMock(IAppConfig::class), $this->appManager, $this->gsConfig, + $this->urlGenerator, ]) ->onlyMethods(['getCollaboraUrlPublic', 'getGlobalScaleTrustedHosts']) ->getMock(); From 35b96d7a37261c8bd637594629cf2c4e54568be7 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 8 May 2026 10:23:44 -0400 Subject: [PATCH 21/21] chore: add add'l AppConfig arg in AppConfigTest Signed-off-by: Josh --- tests/lib/AppConfigTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/lib/AppConfigTest.php b/tests/lib/AppConfigTest.php index 03fd045093..4565a9c366 100644 --- a/tests/lib/AppConfigTest.php +++ b/tests/lib/AppConfigTest.php @@ -12,6 +12,7 @@ use OCP\AppFramework\Services\IAppConfig; use OCP\GlobalScale\IConfig as IGlobalScaleConfig; use OCP\IConfig; +use OCP\IURLGenerator; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -20,6 +21,7 @@ class AppConfigTest extends TestCase { private $config; /** @var IAppConfig */ private $appConfig; + private IURLGenerator|MockObject $urlGenerator; public function setUp(): void { parent::setUp(); @@ -27,8 +29,9 @@ public function setUp(): void { $this->appManager = $this->createMock(IAppManager::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->gsConfig = $this->createMock(IGlobalScaleConfig::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); - $this->appConfig = new AppConfig($this->config, $this->appConfig, $this->appManager, $this->gsConfig); + $this->appConfig = new AppConfig($this->config, $this->appConfig, $this->appManager, $this->gsConfig, $this->urlGenerator); } public function testGetAppValueArrayWithValues() {