Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0d074ba
fix(setup): derive WOPI URLs for builtin CODE more reliably
joshtrichards May 7, 2026
4224929
chore(setup): bypass autoConfigurePublicUrl if using builtin CODE
joshtrichards May 7, 2026
44f86be
chore(setup): adjust SettingsController for builtin URL handling
joshtrichards May 7, 2026
b7d9a95
chore(setup): add mode/builtin settings
joshtrichards May 7, 2026
a781f94
feat(setup): add builtin CODE specific support to ActivateConfig
joshtrichards May 7, 2026
d97e63a
chore(setup): adjust AdminSettings to handle new builtin server_mode
joshtrichards May 7, 2026
57f5907
chore(setup): backup fallback for legacy builtin installs in AppConfig
joshtrichards May 7, 2026
8eefd48
chore(setup): add builtin CODE rollback and force mode
joshtrichards May 7, 2026
c11443f
chore(setup): add force option to ActivateConfig
joshtrichards May 7, 2026
ae6691b
chore(setup): drop self-healing for CODE from AdminSettings
joshtrichards May 7, 2026
2c8c3b8
feat(ConnectivityService): add a method to test w/o touching config
joshtrichards May 7, 2026
0ba24ca
chore(setup): drop need for transient config mutation and rollback
joshtrichards May 7, 2026
71a8a90
chore(AppConfig): add helper so URL can be derived w/o server_mode be…
joshtrichards May 7, 2026
7b49da5
chore: use new deriveBuiltinPublicUrl in getCollaboraUrlPublic
joshtrichards May 7, 2026
5aa5259
chore(Settings): fix asymetric rollback
joshtrichards May 7, 2026
b1cfc62
chore(Connectivity): temporarily force server_mode when testing conne…
joshtrichards May 7, 2026
8dbb27a
chore(setup): adjust ConnectivityService w/o upstream AppConfig
joshtrichards May 8, 2026
ca9c07e
chore(settings): honour $server_mode instead of hardcoding custom
joshtrichards May 8, 2026
4faefc2
chore(settings): use updateSettings directly to specify mode
joshtrichards May 8, 2026
6550afa
chore: add add'l AppConfig arg in AddContentSecurityPolicyListenerTest
joshtrichards May 8, 2026
35b96d7
chore: add add'l AppConfig arg in AppConfigTest
joshtrichards May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions lib/AppConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,6 +63,7 @@ public function __construct(
private IAppConfig $appConfig,
private IAppManager $appManager,
private GlobalScaleConfig $globalScaleConfig,
private IURLGenerator $urlGenerator,
) {
}

Expand Down Expand Up @@ -134,6 +137,46 @@ public function getAppSettings() {
return $result;
}

/**
* Returns the configured server mode ('builtin', 'custom', 'demo', or '').
*/
public function getServerMode(): string {
$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 {
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
*/
Expand All @@ -148,12 +191,29 @@ 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,
* which is a standard Nextcloud prerequisite).
*
* For custom/standalone servers, returns the stored public_wopi_url.
*/
public function getCollaboraUrlPublic(): string {
return rtrim($this->config->getAppValue(Application::APPNAME, self::PUBLIC_WOPI_URL, $this->getCollaboraUrlInternal()), '/');
if ($this->isBuiltinServer()) {
return $this->deriveBuiltinPublicUrl();
}
return rtrim($this->config->getAppValue(Application::APPNAME, self::PUBLIC_WOPI_URL,
$this->getCollaboraUrlInternal()), '/');
}

public function getCollaboraUrlInternal(): string {
return rtrim($this->config->getAppValue(Application::APPNAME, self::WOPI_URL, ''), '/');
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, ''), '/');
}

public function getNextcloudUrl(): string {
Expand Down Expand Up @@ -256,6 +316,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.
*/
Expand Down
221 changes: 155 additions & 66 deletions lib/Command/ActivateConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/


namespace OCA\Richdocuments\Command;

use OCA\Richdocuments\AppConfig;
Expand All @@ -29,82 +28,172 @@ 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)
->addOption('force', null, InputOption::VALUE_NONE,
'Persist configuration even if connectivity or sanity checks fail')
->setDescription('Activate config changes');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
try {
if ($input->getOption('wopi-url') !== null) {
$wopiUrl = $input->getOption('wopi-url');
$this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopiUrl);
$output->writeln('<info>✓ Set WOPI url to ' . $wopiUrl . '</info>');
}

if ($input->getOption('callback-url') !== null) {
$callbackUrl = $input->getOption('callback-url');
$this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl);
$output->writeln('<info>✓ Set callback url to ' . $callbackUrl . '</info>');
} else {
$this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, '');
$output->writeln('<info>✓ Reset callback url autodetect</info>');
}

$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('<error>Failed to fetch discovery endpoint from ' . $this->appConfig->getCollaboraUrlInternal());
$output->writeln($e->getMessage());
return 1;
}

try {
$this->connectivityService->testCapabilities($output);
} catch (\Throwable $e) {
// FIXME: Optional when allowing generic WOPI servers
$output->writeln('<error>Failed to fetch capabilities endpoint from ' . $this->capabilitiesService->getCapabilitiesEndpoint());
$output->writeln($e->getMessage());
return 1;
if ($input->getOption('builtin')) {
return $this->executeBuiltin($input, $output);
}

try {
$this->connectivityService->autoConfigurePublicUrl();
} catch (\Throwable $e) {
$output->writeln('<error>Failed to determine public URL from discovery response</error>');
$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 user for browsing Nextcloud)');
} else {
$output->writeln(' ' . $this->appConfig->getNextcloudUrl());
}

return 0;
return $this->executeCustom($input, $output);
} catch (\Exception $e) {
$output->writeln('<error>Failed to activate any config changes</error>');
$output->writeln($e->getMessage());
$output->writeln($e->getTraceAsString());
return 1;
}
}

private function executeBuiltin(InputInterface $input, OutputInterface $output): int {
$force = (bool)$input->getOption('force');

// Validate preconditions before writing anything: getBuiltinServerUrl() checks OS/arch/installed
$builtinUrl = $this->appConfig->getBuiltinServerUrl();
if ($builtinUrl === null) {
$output->writeln('<error>Built-in CODE server is not available.</error>');
$output->writeln('<error>Check: richdocumentscode (or richdocumentscode_arm64) is installed,'
. ' OS is Linux, arch is x86_64 or aarch64.</error>');
return 1;
}

// 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();

// 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);
$looksInternal = $host === 'localhost' || $host === '127.0.0.1' || str_ends_with($host, '.local');

if ($looksInternal && !$force) {
$output->writeln('<error>Derived public URL looks internal: ' . $publicUrl . '</error>');
$output->writeln('<error>"overwrite.cli.url" in config.php must be set to your public Nextcloud URL.</error>');
$output->writeln('<error>This is required for any occ command that generates absolute URLs.</error>');
$output->writeln('<comment>To override and persist anyway: --force</comment>');
return 1;
}

if ($looksInternal) {
$output->writeln('<comment>⚠ Warning: public URL looks internal (' . $publicUrl . ').'
. ' Proceeding anyway due to --force.</comment>');
}

// Test connectivity against the derived URL directly; no config mutation needed.
if (!$force) {
try {
$this->connectivityService->testUrl($builtinUrl, $output);
} catch (\Throwable $e) {
// Nothing was writtent o config; no rollback needed
$output->writeln('<error>Failed to reach built-in CODE server: ' . $e->getMessage() . '</error>');
$output->writeln('<comment>To configure without connectivity: --force</comment>');
return 1;
}
}

// 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 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);
} else {
$this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, '');
}

$output->writeln('<info>✓ Built-in CODE server configured successfully.</info>');
$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;
}

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('<info>✓ Set WOPI url to ' . $wopiUrl . '</info>');
}

if ($input->getOption('callback-url') !== null) {
$callbackUrl = $input->getOption('callback-url');
$this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, $callbackUrl);
$output->writeln('<info>✓ Set callback url to ' . $callbackUrl . '</info>');
} else {
$this->appConfig->setAppValue(AppConfig::WOPI_CALLBACK_URL, '');
$output->writeln('<info>✓ Reset callback url autodetect</info>');
}

$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('<error>Failed to fetch discovery endpoint from '
. $this->appConfig->getCollaboraUrlInternal() . '</error>');
$output->writeln($e->getMessage());
$output->writeln('<comment>To configure without connectivity: --force</comment>');
return 1;
}

try {
$this->connectivityService->testCapabilities($output);
} catch (\Throwable $e) {
$output->writeln('<error>Failed to fetch capabilities from '
. $this->capabilitiesService->getCapabilitiesEndpoint() . '</error>');
$output->writeln($e->getMessage());
$output->writeln('<comment>To configure without connectivity: --force</comment>');
return 1;
}

try {
$this->connectivityService->autoConfigurePublicUrl();
} catch (\Throwable $e) {
$output->writeln('<error>Failed to determine public URL from discovery response</error>');
$output->writeln($e->getMessage());
$output->writeln('<comment>To configure without connectivity: --force</comment>');
return 1;
}
} else {
$output->writeln('<comment>⚠ Skipping connectivity checks (--force).</comment>');
}

$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;
}
}
Loading
Loading