From 1551027aef559059d9146b1a37eefebf94fa3f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Thu, 23 Nov 2023 10:24:37 +0100 Subject: [PATCH 1/5] fix: Properly handle internal urls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve handling of different URLs. From now on we will allow to configure: - wopi_url Used by Nextcloud to connect to Collabora in the backend - wop_callback_url Passed to collabora to connect back to Nextcloud (optional, determined from the browser URL if not set) The public_wopi_url which was only partly working is no longer ment to be manually set and will be overwritten depending on the /hosting/discovery response. Further this PR improves: - Add setup check on occ and admin page - Give proper error on individual failures of the setup check - Display configured and detected URLs to make setup issue debugging easier - Refactor services to have a cleaner structure for setup checks, discovery and capabilities fetching Signed-off-by: Julius Härtl --- composer.json | 3 +- composer/composer/autoload_classmap.php | 3 +- composer/composer/autoload_static.php | 3 +- lib/AppConfig.php | 11 +- lib/AppInfo/Application.php | 6 +- lib/Backgroundjobs/ObtainCapabilities.php | 2 +- lib/Capabilities.php | 5 +- lib/Command/ActivateConfig.php | 103 +++++++--- lib/Command/ConvertToBigInt.php | 10 +- lib/Controller/SettingsController.php | 194 +++++++----------- lib/Middleware/WOPIMiddleware.php | 7 + lib/Service/CapabilitiesService.php | 33 ++- lib/Service/ConnectivityService.php | 78 +++++++ .../DiscoveryService.php} | 26 ++- lib/Service/InitialStateService.php | 3 + lib/Settings/Admin.php | 51 ++--- lib/TokenManager.php | 2 +- lib/WOPI/Parser.php | 45 ++-- src/components/AdminSettings.vue | 147 ++++++++----- src/document.js | 2 + src/helpers/url.js | 20 +- src/view/Office.vue | 1 + templates/admin.php | 2 +- 23 files changed, 460 insertions(+), 297 deletions(-) create mode 100644 lib/Service/ConnectivityService.php rename lib/{WOPI/DiscoveryManager.php => Service/DiscoveryService.php} (80%) diff --git a/composer.json b/composer.json index fb4b43f711..17c99099dc 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ } }, "require": { - "ext-json": "*" + "ext-json": "*", + "ext-simplexml": "*" }, "require-dev": { "roave/security-advisories": "dev-master", diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index b8bd7aa4bc..2e92308c27 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -61,7 +61,9 @@ 'OCA\\Richdocuments\\Preview\\Pdf' => $baseDir . '/../lib/Preview/Pdf.php', 'OCA\\Richdocuments\\Reference\\OfficeTargetReferenceProvider' => $baseDir . '/../lib/Reference/OfficeTargetReferenceProvider.php', 'OCA\\Richdocuments\\Service\\CapabilitiesService' => $baseDir . '/../lib/Service/CapabilitiesService.php', + 'OCA\\Richdocuments\\Service\\ConnectivityService' => $baseDir . '/../lib/Service/ConnectivityService.php', 'OCA\\Richdocuments\\Service\\DemoService' => $baseDir . '/../lib/Service/DemoService.php', + 'OCA\\Richdocuments\\Service\\DiscoveryService' => $baseDir . '/../lib/Service/DiscoveryService.php', 'OCA\\Richdocuments\\Service\\FederationService' => $baseDir . '/../lib/Service/FederationService.php', 'OCA\\Richdocuments\\Service\\FileTargetService' => $baseDir . '/../lib/Service/FileTargetService.php', 'OCA\\Richdocuments\\Service\\FontService' => $baseDir . '/../lib/Service/FontService.php', @@ -75,6 +77,5 @@ 'OCA\\Richdocuments\\Template\\CollaboraTemplateProvider' => $baseDir . '/../lib/Template/CollaboraTemplateProvider.php', 'OCA\\Richdocuments\\TokenManager' => $baseDir . '/../lib/TokenManager.php', 'OCA\\Richdocuments\\UploadException' => $baseDir . '/../lib/UploadException.php', - 'OCA\\Richdocuments\\WOPI\\DiscoveryManager' => $baseDir . '/../lib/WOPI/DiscoveryManager.php', 'OCA\\Richdocuments\\WOPI\\Parser' => $baseDir . '/../lib/WOPI/Parser.php', ); diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index aea309c791..401deeb4e8 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -76,7 +76,9 @@ class ComposerStaticInitRichdocuments 'OCA\\Richdocuments\\Preview\\Pdf' => __DIR__ . '/..' . '/../lib/Preview/Pdf.php', 'OCA\\Richdocuments\\Reference\\OfficeTargetReferenceProvider' => __DIR__ . '/..' . '/../lib/Reference/OfficeTargetReferenceProvider.php', 'OCA\\Richdocuments\\Service\\CapabilitiesService' => __DIR__ . '/..' . '/../lib/Service/CapabilitiesService.php', + 'OCA\\Richdocuments\\Service\\ConnectivityService' => __DIR__ . '/..' . '/../lib/Service/ConnectivityService.php', 'OCA\\Richdocuments\\Service\\DemoService' => __DIR__ . '/..' . '/../lib/Service/DemoService.php', + 'OCA\\Richdocuments\\Service\\DiscoveryService' => __DIR__ . '/..' . '/../lib/Service/DiscoveryService.php', 'OCA\\Richdocuments\\Service\\FederationService' => __DIR__ . '/..' . '/../lib/Service/FederationService.php', 'OCA\\Richdocuments\\Service\\FileTargetService' => __DIR__ . '/..' . '/../lib/Service/FileTargetService.php', 'OCA\\Richdocuments\\Service\\FontService' => __DIR__ . '/..' . '/../lib/Service/FontService.php', @@ -90,7 +92,6 @@ class ComposerStaticInitRichdocuments 'OCA\\Richdocuments\\Template\\CollaboraTemplateProvider' => __DIR__ . '/..' . '/../lib/Template/CollaboraTemplateProvider.php', 'OCA\\Richdocuments\\TokenManager' => __DIR__ . '/..' . '/../lib/TokenManager.php', 'OCA\\Richdocuments\\UploadException' => __DIR__ . '/..' . '/../lib/UploadException.php', - 'OCA\\Richdocuments\\WOPI\\DiscoveryManager' => __DIR__ . '/..' . '/../lib/WOPI/DiscoveryManager.php', 'OCA\\Richdocuments\\WOPI\\Parser' => __DIR__ . '/..' . '/../lib/WOPI/Parser.php', ); diff --git a/lib/AppConfig.php b/lib/AppConfig.php index 6e98e10e06..b0a6406ab3 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -18,8 +18,13 @@ use OCP\GlobalScale\IConfig as GlobalScaleConfig; class AppConfig { + // 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, + // either wopi_url or what is configured as server_name) public const PUBLIC_WOPI_URL = 'public_wopi_url'; + // URL that should be used by Collabora to connect back to Nextcloud (defaults to the users browser host) + public const WOPI_CALLBACK_URL = 'wopi_callback_url'; public const FEDERATION_USE_TRUSTED_DOMAINS = 'federation_use_trusted_domains'; @@ -144,6 +149,10 @@ public function getCollaboraUrlInternal(): string { return $this->config->getAppValue(Application::APPNAME, self::WOPI_URL, ''); } + public function getNextcloudUrl(): string { + return $this->config->getAppValue(Application::APPNAME, self::WOPI_CALLBACK_URL, ''); + } + public function getDisableCertificateValidation(): bool { return $this->config->getAppValue(Application::APPNAME, 'disable_certificate_verification', 'no') === 'yes'; } @@ -235,7 +244,7 @@ private function getGSDomains(): array { /** * Strips the path and query parameters from the URL. */ - private function domainOnly(string $url): string { + public function domainOnly(string $url): string { $parsedUrl = parse_url($url); $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : ''; $host = $parsedUrl['host'] ?? ''; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index aab6db3484..5b33f4fbcd 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -45,8 +45,8 @@ use OCA\Richdocuments\Preview\Pdf; use OCA\Richdocuments\Reference\OfficeTargetReferenceProvider; use OCA\Richdocuments\Service\CapabilitiesService; +use OCA\Richdocuments\Service\DiscoveryService; use OCA\Richdocuments\Template\CollaboraTemplateProvider; -use OCA\Richdocuments\WOPI\DiscoveryManager; use OCA\Viewer\Event\LoadViewer; use OCP\App\IAppManager; use OCP\AppFramework\App; @@ -220,10 +220,10 @@ public function checkAndEnableCODEServer() { $appConfig->setAppValue('wopi_url', $new_wopi_url); $appConfig->setAppValue('disable_certificate_verification', 'yes'); - $discoveryManager = $this->getContainer()->get(DiscoveryManager::class); + $discoveryService = $this->getContainer()->get(DiscoveryService::class); $capabilitiesService = $this->getContainer()->get(CapabilitiesService::class); - $discoveryManager->refetch(); + $discoveryService->refetch(); $capabilitiesService->clear(); $capabilitiesService->refetch(); } diff --git a/lib/Backgroundjobs/ObtainCapabilities.php b/lib/Backgroundjobs/ObtainCapabilities.php index effc885be0..c763aff616 100644 --- a/lib/Backgroundjobs/ObtainCapabilities.php +++ b/lib/Backgroundjobs/ObtainCapabilities.php @@ -37,6 +37,6 @@ public function __construct(CapabilitiesService $capabilitiesService) { } protected function run($argument) { - $this->capabilitiesService->refetch(); + $this->capabilitiesService->fetchFromRemote(); } } diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 96e811dd17..3954bebbc9 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -154,8 +154,9 @@ public function getCapabilities() { 'productName' => $this->capabilitiesService->getProductName(), 'editonline_endpoint' => $this->urlGenerator->linkToRouteAbsolute('richdocuments.document.editOnline'), 'config' => [ - 'wopi_url' => $this->config->getAppValue('wopi_url'), - 'public_wopi_url' => $this->config->getAppValue('public_wopi_url'), + 'wopi_url' => $this->config->getCollaboraUrlInternal(), + 'public_wopi_url' => $this->config->getCollaboraUrlPublic(), + 'wopi_callback_url' => $this->config->getNextcloudUrl(), 'disable_certificate_verification' => $this->config->getAppValue('disable_certificate_verification'), 'edit_groups' => $this->config->getAppValue('edit_groups'), 'use_groups' => $this->config->getAppValue('use_groups'), diff --git a/lib/Command/ActivateConfig.php b/lib/Command/ActivateConfig.php index 91379abf29..e7955e7c14 100644 --- a/lib/Command/ActivateConfig.php +++ b/lib/Command/ActivateConfig.php @@ -26,54 +26,95 @@ use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Service\CapabilitiesService; -use OCA\Richdocuments\WOPI\DiscoveryManager; -use OCA\Richdocuments\WOPI\Parser; +use OCA\Richdocuments\Service\ConnectivityService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class ActivateConfig extends Command { - /** @var AppConfig */ - private $appConfig; - - /** @var CapabilitiesService */ - private $capabilitiesService; - - /** @var DiscoveryManager */ - private $discoveryManager; - - /** @var Parser */ - private $wopiParser; - - public function __construct(AppConfig $appConfig, CapabilitiesService $capabilitiesService, DiscoveryManager $discoveryManager, Parser $wopiParser) { + public function __construct( + private AppConfig $appConfig, + private ConnectivityService $connectivityService, + private CapabilitiesService $capabilitiesService, + ) { parent::__construct(); - - $this->appConfig = $appConfig; - $this->capabilitiesService = $capabilitiesService; - $this->discoveryManager = $discoveryManager; - $this->wopiParser = $wopiParser; } protected function configure() { $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) ->setDescription('Activate config changes'); } protected function execute(InputInterface $input, OutputInterface $output) { try { - $this->discoveryManager->refetch(); - $this->capabilitiesService->clear(); - $capaUrlSrc = $this->wopiParser->getUrlSrc('Capabilities'); - if (is_array($capaUrlSrc) && $capaUrlSrc['action'] === 'getinfo') { - $public_wopi_url = str_replace('/hosting/capabilities', '', $capaUrlSrc['urlsrc']); - if ($public_wopi_url !== null) { - $this->appConfig->setAppValue('public_wopi_url', $public_wopi_url); - } + if ($input->getOption('wopi-url') !== null) { + $wopiUrl = $input->getOption('wopi-url'); + $this->appConfig->setAppValue(AppConfig::WOPI_URL, $wopiUrl); + $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; } - $this->capabilitiesService->clear(); - $this->capabilitiesService->refetch(); - $output->writeln('Activated any config changes'); + + 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($e->getMessage()); + return 1; + } + + try { + $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 user for browsing Nextcloud)'); + } else { + $output->writeln(' ' . $this->appConfig->getNextcloudUrl()); + } + return 0; } catch (\Exception $e) { $output->writeln('Failed to activate any config changes'); diff --git a/lib/Command/ConvertToBigInt.php b/lib/Command/ConvertToBigInt.php index beefed0598..3e3bad0d06 100644 --- a/lib/Command/ConvertToBigInt.php +++ b/lib/Command/ConvertToBigInt.php @@ -29,21 +29,13 @@ use OC\DB\Connection; use OC\DB\SchemaWrapper; use OCP\DB\Types; -use OCP\IDBConnection; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; class ConvertToBigInt extends Command { - /** @var IDBConnection */ - private $connection; - - /** - * @param IDBConnection $connection - */ - public function __construct(Connection $connection) { - $this->connection = $connection; + public function __construct(private Connection $connection) { parent::__construct(); } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 7fec58e6fe..a00c475cde 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -11,28 +11,31 @@ namespace OCA\Richdocuments\Controller; -use \OCP\AppFramework\Controller; -use \OCP\IL10N; -use \OCP\IRequest; use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Service\CapabilitiesService; +use OCA\Richdocuments\Service\ConnectivityService; use OCA\Richdocuments\Service\DemoService; +use OCA\Richdocuments\Service\DiscoveryService; use OCA\Richdocuments\Service\FontService; use OCA\Richdocuments\UploadException; -use OCA\Richdocuments\WOPI\DiscoveryManager; -use OCA\Richdocuments\WOPI\Parser; +use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\JSONResponse; -use OCP\AppFramework\Http\NotFoundResponse; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; use OCP\PreConditionNotMetException; use OCP\Util; use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Output\NullOutput; class SettingsController extends Controller { // TODO adapt overview generation if we add more font mimetypes @@ -45,120 +48,88 @@ class SettingsController extends Controller { 'application/vnd.ms-opentype', ]; - /** @var IL10N */ - private $l10n; - /** @var AppConfig */ - private $appConfig; - /** @var IConfig */ - private $config; - /** @var DiscoveryManager */ - private $discoveryManager; - /** @var Parser */ - private $wopiParser; - /** @var string */ - private $userId; - /** @var CapabilitiesService */ - private $capabilitiesService; - /** @var DemoService */ - private $demoService; - /** @var LoggerInterface */ - private $logger; - /** - * @var FontService - */ - private $fontService; - public function __construct($appName, IRequest $request, - IL10N $l10n, - AppConfig $appConfig, - IConfig $config, - DiscoveryManager $discoveryManager, - Parser $wopiParser, - CapabilitiesService $capabilitiesService, - DemoService $demoService, - FontService $fontService, - LoggerInterface $logger, - $userId + private IL10N $l10n, + private AppConfig $appConfig, + private IConfig $config, + private ConnectivityService $connectivityService, + private DiscoveryService $discoveryService, + private CapabilitiesService $capabilitiesService, + private DemoService $demoService, + private FontService $fontService, + private LoggerInterface $logger, + private ?string $userId ) { parent::__construct($appName, $request); - $this->l10n = $l10n; - $this->appConfig = $appConfig; - $this->config = $config; - $this->discoveryManager = $discoveryManager; - $this->wopiParser = $wopiParser; - $this->capabilitiesService = $capabilitiesService; - $this->demoService = $demoService; - $this->logger = $logger; - $this->userId = $userId; - $this->fontService = $fontService; - $this->request = $request; } - /** - * @PublicPage - * @NoCSRFRequired - * @throws \Exception - */ - public function checkSettings() { + #[PublicPage] + #[NoCSRFRequired] + public function checkSettings(): DataResponse { try { - $response = $this->discoveryManager->fetchFromRemote(); + $output = new NullOutput(); + $this->connectivityService->testDiscovery($output); + $this->connectivityService->testCapabilities($output); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new DataResponse([ 'status' => $e->getCode(), - 'message' => 'Could not fetch discovery details' + 'data' => [ + // FIXME ONLY AS ADMIN + 'message' => 'Failed to connect to the remote server: ' . $e->getMessage(), + 'settings' => $this->getSettingsData(), + ], ], Http::STATUS_INTERNAL_SERVER_ERROR); } - return new DataResponse(); + return new DataResponse([ + 'status' => 'success', + 'data' => [ + 'settings' => $this->getSettingsData(), + ] + ]); } - public function demoServers() { + public function demoServers(): DataResponse { $demoServers = $this->demoService->fetchDemoServers(true); if (count($demoServers) > 0) { return new DataResponse($demoServers); } - return new NotFoundResponse(); + return new DataResponse([], Http::STATUS_NOT_FOUND); } - /** - * @NoAdminRequired - * - * @return JSONResponse - */ - public function getSettings() { - return new JSONResponse([ - 'wopi_url' => $this->appConfig->getAppValue('wopi_url'), + #[NoAdminRequired] + public function getSettings(): JSONResponse { + return new JSONResponse($this->getSettingsData()); + } + + private function getSettingsData(): array { + return [ + 'wopi_url' => $this->appConfig->getCollaboraUrlInternal(), + 'public_wopi_url' => $this->appConfig->getCollaboraUrlPublic(), + 'wopi_callback_url' => $this->appConfig->getNextcloudUrl(), 'wopi_allowlist' => $this->appConfig->getAppValue('wopi_allowlist'), - 'public_wopi_url' => $this->appConfig->getAppValue('public_wopi_url'), 'disable_certificate_verification' => $this->appConfig->getAppValue('disable_certificate_verification') === 'yes', 'edit_groups' => $this->appConfig->getAppValue('edit_groups'), 'use_groups' => $this->appConfig->getAppValue('use_groups'), 'doc_format' => $this->appConfig->getAppValue('doc_format'), - ]); + 'product_name' => $this->capabilitiesService->getServerProductName(), + 'product_version' => $this->capabilitiesService->getProductVersion(), + 'product_hash' => $this->capabilitiesService->getProductHash(), + ]; } - /** - * @param string $wopi_url - * @param string $disable_certificate_verification - * @param string $edit_groups - * @param string $use_groups - * @param string $doc_format - * @param string $external_apps - * @param string $canonical_webroot - * @return JSONResponse - */ - public function setSettings($wopi_url, - $wopi_allowlist, - $disable_certificate_verification, - $edit_groups, - $use_groups, - $doc_format, - $external_apps, - $canonical_webroot) { - $message = $this->l10n->t('Saved'); - + public function setSettings( + ?string $wopi_url, + ?string $wopi_allowlist, + ?bool $disable_certificate_verification, + ?string $edit_groups, + ?string $use_groups, + ?string $doc_format, + ?string $external_apps, + ?string $canonical_webroot + ): JSONResponse { if ($wopi_url !== null) { $this->appConfig->setAppValue('wopi_url', $wopi_url); } @@ -170,7 +141,7 @@ public function setSettings($wopi_url, if ($disable_certificate_verification !== null) { $this->appConfig->setAppValue( 'disable_certificate_verification', - $disable_certificate_verification === true ? 'yes' : '' + $disable_certificate_verification ? 'yes' : '' ); } @@ -194,47 +165,30 @@ public function setSettings($wopi_url, $this->appConfig->setAppValue('canonical_webroot', $canonical_webroot); } - $this->discoveryManager->refetch(); - $this->capabilitiesService->clear(); try { - $capaUrlSrc = $this->wopiParser->getUrlSrc('Capabilities'); - if (is_array($capaUrlSrc) && $capaUrlSrc['action'] === 'getinfo') { - $public_wopi_url = str_replace('/hosting/capabilities', '', $capaUrlSrc['urlsrc']); - if ($public_wopi_url !== null) { - $this->appConfig->setAppValue('public_wopi_url', $public_wopi_url); - $colon = strpos($public_wopi_url, ':', 0); - if ($this->request->getServerProtocol() !== substr($public_wopi_url, 0, $colon)) { - $message = $this->l10n->t('Saved with error: Collabora Online should expose the same protocol as the server installation. Please check the ssl.enable and ssl.termination settings of your Collabora Online server.'); - } - } - } - } catch (\Exception $e) { - if ($wopi_url !== null) { - return new JSONResponse([ - 'status' => 'error', - 'data' => ['message' => 'Failed to connect to the remote server'] - ], 500); - } - } - - $this->capabilitiesService->clear(); - $this->capabilitiesService->refetch(); - if ($this->capabilitiesService->getCapabilities() === []) { + $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', 'hint' => 'missing_capabilities'] + 'data' => ['message' => 'Failed to connect to the remote server: ' . $e->getMessage()] ], 500); } $response = [ 'status' => 'success', - 'data' => ['message' => $message] + 'data' => [ + 'message' => $this->l10n->t('Saved'), + 'settings' => $this->getSettingsData(), + ] ]; return new JSONResponse($response); } - public function updateWatermarkSettings($settings = []) { + public function updateWatermarkSettings($settings = []): JSONResponse { $supportedOptions = [ 'watermark_text', 'watermark_enabled', diff --git a/lib/Middleware/WOPIMiddleware.php b/lib/Middleware/WOPIMiddleware.php index a3879e3f28..c8ed42bbbd 100644 --- a/lib/Middleware/WOPIMiddleware.php +++ b/lib/Middleware/WOPIMiddleware.php @@ -97,6 +97,13 @@ public function afterException($controller, $methodName, \Exception $exception): return new JSONResponse([], Http::STATUS_FORBIDDEN); } + if ($controller instanceof WopiController) { + $this->logger->error('Uncaught error: ' . $exception->getMessage(), [ 'exception' => $exception ]); + return new JSONResponse([ + 'message' => 'Error' + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + throw $exception; } diff --git a/lib/Service/CapabilitiesService.php b/lib/Service/CapabilitiesService.php index 57f7c008ea..df9df32b80 100644 --- a/lib/Service/CapabilitiesService.php +++ b/lib/Service/CapabilitiesService.php @@ -70,7 +70,7 @@ public function getCapabilities() { $isCODEEnabled = strpos($this->config->getAppValue('richdocuments', 'wopi_url'), 'proxy.php?req=') !== false; $shouldRecheckCODECapabilities = $isCODEInstalled && $isCODEEnabled && ($this->capabilities === null || count($this->capabilities) === 0); if ($this->capabilities === null || $shouldRecheckCODECapabilities) { - $this->refetch(); + $this->fetchFromRemote(); } if (!is_array($this->capabilities)) { @@ -80,6 +80,18 @@ public function getCapabilities() { return $this->capabilities; } + public function getProductVersion(): ?string { + return $this->getCapabilities()['productVersion'] ?? null; + } + + public function getProductHash(): ?string { + return $this->getCapabilities()['productVersionHash'] ?? null; + } + + public function getServerProductName(): ?string { + return $this->getCapabilities()['productName'] ?? null; + } + public function hasNextcloudBranding(): bool { $productVersion = $this->getCapabilities()['productVersion'] ?? '0.0.0.0'; return version_compare($productVersion, '21.11', '>='); @@ -124,16 +136,22 @@ public function hasOtherOOXMLApps(): bool { return false; } - public function clear(): void { + public function resetCache(): void { $this->cache->remove('capabilities'); } - public function refetch(): void { + public function getCapabilitiesEndpoint(): ?string { $remoteHost = $this->config->getAppValue('richdocuments', 'wopi_url'); if ($remoteHost === '') { + return null; + } + return rtrim($remoteHost, '/') . '/hosting/capabilities'; + } + + public function fetchFromRemote($throw = false): void { + if (!$this->getCapabilitiesEndpoint()) { return; } - $capabilitiesEndpoint = rtrim($remoteHost, '/') . '/hosting/capabilities'; $client = $this->clientService->newClient(); $options = ['timeout' => 45, 'nextcloud' => ['allow_local_address' => true]]; @@ -144,9 +162,9 @@ public function refetch(): void { try { $startTime = microtime(true); - $response = $client->get($capabilitiesEndpoint, $options); + $response = $client->get($this->getCapabilitiesEndpoint(), $options); $duration = round(((microtime(true) - $startTime)), 3); - $this->logger->info('Fetched capabilities endpoint from ' . $capabilitiesEndpoint. ' in ' . $duration . ' seconds'); + $this->logger->info('Fetched capabilities endpoint from ' . $this->getCapabilitiesEndpoint(). ' in ' . $duration . ' seconds'); $responseBody = $response->getBody(); $capabilities = \json_decode($responseBody, true); @@ -155,6 +173,9 @@ public function refetch(): void { } } catch (\Exception $e) { $this->logger->error('Failed to fetch the Collabora capabilities endpoint: ' . $e->getMessage(), [ 'exception' => $e ]); + if ($throw) { + throw $e; + } $capabilities = []; } diff --git a/lib/Service/ConnectivityService.php b/lib/Service/ConnectivityService.php new file mode 100644 index 0000000000..94e3452cdf --- /dev/null +++ b/lib/Service/ConnectivityService.php @@ -0,0 +1,78 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Richdocuments\Service; + +use Exception; +use OCA\Richdocuments\AppConfig; +use OCA\Richdocuments\WOPI\Parser; +use Symfony\Component\Console\Output\OutputInterface; + +class ConnectivityService { + public function __construct( + private AppConfig $appConfig, + private DiscoveryService $discoveryService, + private CapabilitiesService $capabilitiesService, + private Parser $parser, + ) { + } + + /** + * @throws Exception + */ + public function testDiscovery(OutputInterface $output): void { + $this->discoveryService->resetCache(); + $this->discoveryService->fetchFromRemote(); + $output->writeln('✓ Fetched /hosting/discovery endpoint'); + + $this->parser->getUrlSrcValue('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + $output->writeln('✓ Valid mimetype response'); + + // FIXME: Optional when allowing generic WOPI servers + $this->parser->getUrlSrcValue('Capabilities'); + $output->writeln('✓ Valid capabilities entry'); + } + + public function testCapabilities(OutputInterface $output): void { + $this->capabilitiesService->resetCache(); + $this->capabilitiesService->fetchFromRemote(true); + $output->writeln('✓ Fetched /hosting/capabilities endpoint'); + + if ($this->capabilitiesService->getCapabilities() === []) { + throw new \Exception('Empty capabilities, unexpected result from ' . $this->capabilitiesService->getCapabilitiesEndpoint()); + } + $output->writeln('✓ Detected WOPI server: ' . $this->capabilitiesService->getServerProductName() . ' ' . $this->capabilitiesService->getProductVersion() . ''); + } + + /** + * 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) + */ + public function autoConfigurePublicUrl(): void { + $determinedUrl = $this->parser->getUrlSrcValue('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + $detectedUrl = $this->appConfig->domainOnly($determinedUrl); + $this->appConfig->setAppValue('public_wopi_url', $detectedUrl); + } +} diff --git a/lib/WOPI/DiscoveryManager.php b/lib/Service/DiscoveryService.php similarity index 80% rename from lib/WOPI/DiscoveryManager.php rename to lib/Service/DiscoveryService.php index 167413d9ac..c5db331d72 100644 --- a/lib/WOPI/DiscoveryManager.php +++ b/lib/Service/DiscoveryService.php @@ -1,4 +1,24 @@ + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ declare(strict_types=1); @@ -22,7 +42,7 @@ * */ -namespace OCA\Richdocuments\WOPI; +namespace OCA\Richdocuments\Service; use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; @@ -31,7 +51,7 @@ use OCP\IConfig; use Psr\Log\LoggerInterface; -class DiscoveryManager { +class DiscoveryService { private IClientService $clientService; private ICache $cache; private IConfig $config; @@ -93,7 +113,7 @@ public function fetchFromRemote(): IResponse { return $response; } - public function refetch(): void { + public function resetCache(): void { $this->cache->remove('discovery'); $this->discovery = null; } diff --git a/lib/Service/InitialStateService.php b/lib/Service/InitialStateService.php index b96376ed2d..5bc657f447 100644 --- a/lib/Service/InitialStateService.php +++ b/lib/Service/InitialStateService.php @@ -25,6 +25,7 @@ namespace OCA\Richdocuments\Service; +use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\AppInfo\Application; use OCA\Richdocuments\Db\Wopi; use OCP\AppFramework\Services\IInitialState; @@ -37,6 +38,7 @@ class InitialStateService { public function __construct( private IInitialState $initialState, + private AppConfig $appConfig, private CapabilitiesService $capabilitiesService, private IURLGenerator $urlGenerator, private Defaults $themingDefaults, @@ -54,6 +56,7 @@ public function provideCapabilities(): void { $this->initialState->provideInitialState('hasDrawSupport', $this->capabilitiesService->hasDrawSupport()); $this->initialState->provideInitialState('hasNextcloudBranding', $this->capabilitiesService->hasNextcloudBranding()); $this->initialState->provideInitialState('instanceId', $this->config->getSystemValue('instanceid')); + $this->initialState->provideInitialState('wopi_callback_url', $this->appConfig->getNextcloudUrl()); $this->provideOptions(); diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 0625d84818..5b0710f5d6 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -34,54 +34,27 @@ use OCP\Settings\ISettings; class Admin implements ISettings { - /** @var IConfig */ - private $config; - - /** @var AppConfig */ - private $appConfig; - - /** @var TemplateManager */ - private $manager; - - /** @var CapabilitiesService */ - private $capabilitiesService; - - /** @var DemoService */ - private $demoService; - - /** @var InitialStateService */ - private $initialState; - /** - * @var FontService - */ - private $fontService; - public function __construct( - IConfig $config, - AppConfig $appConfig, - TemplateManager $manager, - CapabilitiesService $capabilitiesService, - DemoService $demoService, - FontService $fontService, - InitialStateService $initialStateService + private IConfig $config, + private AppConfig $appConfig, + private TemplateManager $manager, + private CapabilitiesService $capabilitiesService, + private DemoService $demoService, + private FontService $fontService, + private InitialStateService $initialStateService ) { - $this->config = $config; - $this->appConfig = $appConfig; - $this->manager = $manager; - $this->capabilitiesService = $capabilitiesService; - $this->demoService = $demoService; - $this->initialState = $initialStateService; - $this->fontService = $fontService; } - public function getForm() { - $this->initialState->provideCapabilities(); + public function getForm(): TemplateResponse { + $this->initialStateService->provideCapabilities(); return new TemplateResponse( 'richdocuments', 'admin', [ 'settings' => [ - 'wopi_url' => $this->config->getAppValue('richdocuments', 'wopi_url'), + 'wopi_url' => $this->appConfig->getCollaboraUrlInternal(), + 'public_wopi_url' => $this->appConfig->getCollaboraUrlPublic(), + 'wopi_callback_url' => $this->appConfig->getNextcloudUrl(), 'wopi_allowlist' => $this->config->getAppValue('richdocuments', 'wopi_allowlist'), 'edit_groups' => $this->config->getAppValue('richdocuments', 'edit_groups'), 'use_groups' => $this->config->getAppValue('richdocuments', 'use_groups'), diff --git a/lib/TokenManager.php b/lib/TokenManager.php index c9627b5606..c58e846786 100644 --- a/lib/TokenManager.php +++ b/lib/TokenManager.php @@ -313,6 +313,6 @@ public function updateGuestName(string $accessToken, string $guestName) { } public function getUrlSrc(File $file): string { - return $this->wopiParser->getUrlSrc($file->getMimeType())['urlsrc']; + return $this->wopiParser->getUrlSrcValue($file->getMimeType()); } } diff --git a/lib/WOPI/Parser.php b/lib/WOPI/Parser.php index 8480bef812..141f74364f 100644 --- a/lib/WOPI/Parser.php +++ b/lib/WOPI/Parser.php @@ -21,40 +21,37 @@ namespace OCA\Richdocuments\WOPI; +use Exception; +use OCA\Richdocuments\Service\DiscoveryService; use Psr\Log\LoggerInterface; class Parser { - /** @var DiscoveryManager */ - private $discoveryManager; - - /** @var LoggerInterface */ - private $logger; + public function __construct( + private DiscoveryService $discoveryService, + private LoggerInterface $logger + ) { + } /** - * @param DiscoveryManager $discoveryManager - * @param LoggerInterface $logger + * @throws Exception */ - public function __construct(DiscoveryManager $discoveryManager, LoggerInterface $logger) { - $this->discoveryManager = $discoveryManager; - $this->logger = $logger; + public function getUrlSrcValue(string $appName): string { + $result = $this->getUrlSrc($appName)['urlsrc']; + + // Fix for potentially escaped urls that are misconfigured on the Collabora docker image + // https://github.com/nextcloud/richdocuments/issues/3262 + $result = str_replace('\.', '.', $result); + + return (string)$result; } /** - * @param $mimetype - * @return array - * @throws \Exception + * @throws Exception */ - public function getUrlSrc($mimetype) { - $discovery = $this->discoveryManager->get(); + private function getUrlSrc(string $mimetype): array { + $discovery = $this->discoveryService->get(); $this->logger->debug('WOPI::getUrlSrc discovery: {discovery}', ['discovery' => $discovery]); - if (\PHP_VERSION_ID < 80000) { - $loadEntities = libxml_disable_entity_loader(true); - $discoveryParsed = simplexml_load_string($discovery); - libxml_disable_entity_loader($loadEntities); - } else { - $discoveryParsed = simplexml_load_string($discovery); - } - + $discoveryParsed = simplexml_load_string($discovery); $result = $discoveryParsed->xpath(sprintf('/wopi-discovery/net-zone/app[@name=\'%s\']/action', $mimetype)); if ($result && count($result) > 0) { @@ -65,6 +62,6 @@ public function getUrlSrc($mimetype) { } $this->logger->error('Didn\'t find urlsrc for mimetype {mimetype} in this WOPI discovery response: {discovery}', ['mimetype' => $mimetype, 'discovery' => $discovery]); - throw new \Exception('Could not find urlsrc in WOPI'); + throw new Exception('Could not find urlsrc for ' . $mimetype . ' in WOPI discovery response'); } } diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 3ab6cf9623..f956f88246 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -24,50 +24,57 @@

{{ productName }}

-

+

{{ t('richdocuments', 'Nextcloud Office is a powerful Collabora Online based online office suite with collaborative editing, which supports all major documents, spreadsheet and presentation file formats and works together with all modern browsers.') }}

-

+

{{ t('richdocuments', 'Collabora Online is a powerful LibreOffice-based online office suite with collaborative editing, which supports all major documents, spreadsheet and presentation file formats and works together with all modern browsers.') }}

- -
- - +
+ +

{{ t('richdocuments', 'Could not establish connection to the Collabora Online server.') }}

+

{{ errorMessage }}

+ +

+ {{ t('richdocuments', 'This might be due to a missing configuration of your web server. For more information, please visit: ') }} + {{ t('richdocuments', 'Connecting Collabora Online Single Click with Nginx') }} +

+
+ +

{{ t('richdocuments', 'Setting up a new server') }}

+
+ +

{{ t('richdocuments', 'Collabora Online should use the same protocol as the server installation.') }}

+
+ +

{{ t('richdocuments', 'Collabora Online server is reachable.') }}

+

{{ settings.product_name }} {{ settings.product_version }} {{ settings.product_hash }}

+

+ {{ t('richdocuments', 'URL used by the browser:') }} {{ settings.public_wopi_url }}
+ {{ t('richdocuments', 'Nextcloud URL used by Collabora:') }} {{ callbackUrl }} + {{ settings.wopi_callback_url ? '' : '(Determined from the browser URL)' }} +

+
+
+ +

{{ t('richdocuments', 'Please configure a Collabora Online server to start editing documents') }}

+
+ + +

{{ t('richdocuments', 'You have not configured the allow-list for WOPI requests. Without this setting users may download restricted files via WOPI requests to the Nextcloud server.') }} {{ t('richdocuments', 'Click here for more info') }} - -

- -
-
- {{ t('richdocuments', 'Could not establish connection to the Collabora Online server. This might be due to a missing configuration of your web server. For more information, please visit: ') }}{{ t('richdocuments', 'Connecting Collabora Online Single Click with Nginx') }} -
-
- {{ t('richdocuments', 'Could not establish connection to the Collabora Online server.') }} -
-
- {{ t('richdocuments', 'Setting up a new server') }} -
-
- {{ t('richdocuments', 'Collabora Online should use the same protocol as the server installation.') }} -
-
- {{ t('richdocuments', 'Collabora Online server is reachable.') }} -
-
-
- {{ t('richdocuments', 'Please configure a Collabora Online server to start editing documents') }} -
+

+
@@ -101,7 +108,7 @@ :disabled="updating" @change="updateServer">
- {{ t('Enable if your Collabora Online server uses a self signed certificate') }} + {{ t('richdocuments', 'Enable if your Collabora Online server uses a self signed certificate') }}

@@ -398,7 +405,7 @@ import Vue from 'vue' import { loadState } from '@nextcloud/initial-state' import { generateUrl, generateFilePath } from '@nextcloud/router' import { showWarning, showError } from '@nextcloud/dialogs' -import { NcModal, NcMultiselect } from '@nextcloud/vue' +import { NcModal, NcMultiselect, NcNoteCard } from '@nextcloud/vue' import axios from '@nextcloud/axios' import SettingsCheckbox from './SettingsCheckbox.vue' import SettingsInputText from './SettingsInputText.vue' @@ -409,6 +416,7 @@ import SettingsInputFile from './SettingsInputFile.vue' import SettingsFontList from './SettingsFontList.vue' import '@nextcloud/dialogs/dist/index.css' +import { getCallbackBaseUrl } from '../helpers/url.js' const SERVER_STATE_OK = 0 const SERVER_STATE_LOADING = 1 @@ -433,6 +441,7 @@ export default { SettingsInputFile, SettingsFontList, NcModal, + NcNoteCard, }, props: { initial: { @@ -446,7 +455,8 @@ export default { hasNextcloudBranding: loadState('richdocuments', 'hasNextcloudBranding', true), serverMode: '', - serverError: Object.values(OC.getCapabilities().richdocuments.collabora).length > 0 ? SERVER_STATE_OK : SERVER_STATE_CONNECTION_ERROR, + serverError: SERVER_STATE_LOADING, + errorMessage: null, hostErrors: [window.location.host === 'localhost' || window.location.host === '127.0.0.1', window.location.protocol !== 'https:', false], demoServers: null, CODEInstalled: 'richdocumentscode' in OC.appswebroots, @@ -512,9 +522,12 @@ export default { ` }, + callbackUrl() { + return this.settings.wopi_callback_url ? this.settings.wopi_callback_url : getCallbackBaseUrl() + }, }, watch: { - 'settings.wopi_url'(newVal, oldVal) { + 'settings.public_wopi_url'(newVal, oldVal) { if (newVal !== oldVal) { const protocol = this.checkUrlProtocol(newVal) const nextcloudProtocol = this.checkUrlProtocol(window.location.href) @@ -522,6 +535,9 @@ export default { else this.serverError = Object.values(OC.getCapabilities().richdocuments.collabora).length > 0 ? SERVER_STATE_OK : SERVER_STATE_CONNECTION_ERROR } }, + isSetup() { + this.toggleTemplateSettings() + }, }, beforeMount() { for (const key in this.initial.settings) { @@ -566,8 +582,34 @@ export default { this.CODEAppID = 'richdocumentscode_arm64' } this.checkIfDemoServerIsActive() + this.checkSettings() + this.toggleTemplateSettings() }, methods: { + async checkSettings() { + this.errorMessage = null + this.updating = true + this.serverError = SERVER_STATE_LOADING + + let result + 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 + const { message } = e.response.data.data + this.errorMessage = message + } + + this.updating = false + + const { settings } = result?.data?.data || {} + for (const settingKey in settings) { + this.settings[settingKey] = settings[settingKey] + } + }, async fetchDemoServers() { try { const result = await axios.get(generateUrl('/apps/richdocuments/settings/demo')) @@ -654,6 +696,7 @@ export default { this.checkIfDemoServerIsActive() }, async updateSettings(data) { + this.errorMessage = null this.updating = true try { const result = await axios.post( @@ -663,15 +706,17 @@ export default { this.updating = false - const { message } = result?.data?.data || {} + const { settings } = result?.data?.data || {} - if (message && message.length > 0) { - showWarning(message) + for (const settingKey in settings) { + this.settings[settingKey] = settings[settingKey] } return result } catch (e) { this.updating = false + const { message } = e.response.data.data + this.errorMessage = message throw e } }, @@ -748,6 +793,13 @@ export default { this.settings.fonts.splice(index, 1) } }, + toggleTemplateSettings() { + if (this.isSetup) { + document.getElementById('richdocuments-templates').classList.remove('hidden') + } else { + document.getElementById('richdocuments-templates').classList.add('hidden') + } + }, }, } @@ -757,6 +809,14 @@ export default { margin-bottom: 15px; } + .notecard:deep(p:last-child) { + margin-bottom: 0; + } + + .description { + color: var(--color-text-maxcontrast); + } + p.checkbox-details { margin-left: 25px; margin-top: -10px; @@ -782,13 +842,6 @@ export default { border-bottom: 1px solid var(--color-border); } - #security-warning-state-failure, - #security-warning-state-warning, - #security-warning-state-ok { - margin-top: 10px; - margin-bottom: 20px; - } - .option-inline { margin-left: 25px; &:not(.multiselect) { diff --git a/src/document.js b/src/document.js index e72bb426f9..00e4d51362 100644 --- a/src/document.js +++ b/src/document.js @@ -797,6 +797,8 @@ $(document).ready(function() { OCA.RichDocuments.documentsMain = documentsMain + Config.update('wopi_callback_url', loadState('richdocuments', 'wopi_callback_url', '')) + if (shouldAskForGuestName()) { PostMessages.sendPostMessage('parent', 'NC_ShowNamePicker') $('#documents-content').guestNamePicker() diff --git a/src/helpers/url.js b/src/helpers/url.js index 2e605f8bfa..e83e406691 100644 --- a/src/helpers/url.js +++ b/src/helpers/url.js @@ -32,22 +32,29 @@ const getSearchParam = (name) => { return decodeURI(results[1]) || '' } -const getWopiUrl = ({ fileId, title, readOnly, closeButton, revisionHistory, target = undefined }) => { - // Only set the revision history parameter if the versions app is enabled - revisionHistory = revisionHistory && window?.oc_appswebroots?.files_versions +const getCallbackBaseUrl = () => { + const callbackUrl = Config.get('wopi_callback_url') + return callbackUrl || window.location.protocol + '//' + window.location.host + getRootUrl() + '/' +} +const getWopiSrc = (fileId) => { // WOPISrc - URL that loolwsd will access (ie. pointing to ownCloud) // index.php is forced here to avoid different wopi srcs for the same document - const wopiurl = window.location.protocol + '//' + window.location.host + getRootUrl() + '/index.php/apps/richdocuments/wopi/files/' + fileId + const wopiurl = getCallbackBaseUrl() + '/index.php/apps/richdocuments/wopi/files/' + fileId console.debug('[getWopiUrl] ' + wopiurl) - const wopisrc = encodeURIComponent(wopiurl) + return wopiurl +} + +const getWopiUrl = ({ fileId, title, readOnly, closeButton, revisionHistory, target = undefined }) => { + // Only set the revision history parameter if the versions app is enabled + revisionHistory = revisionHistory && window?.oc_appswebroots?.files_versions // urlsrc - the URL from discovery xml that we access for the particular // document; we add various parameters to that. // The discovery is available at // https://:9980/hosting/discovery return Config.get('urlsrc') - + 'WOPISrc=' + wopisrc + + 'WOPISrc=' + encodeURIComponent(getWopiSrc(fileId)) + '&title=' + encodeURIComponent(title) + '&lang=' + languageToBCP47() + (closeButton ? '&closebutton=1' : '') @@ -97,6 +104,7 @@ const getNextcloudUrl = () => { export { getSearchParam, getWopiUrl, + getCallbackBaseUrl, getDocumentUrlFromTemplate, getDocumentUrlForPublicFile, diff --git a/src/view/Office.vue b/src/view/Office.vue index d2cd28f785..2531920dab 100644 --- a/src/view/Office.vue +++ b/src/view/Office.vue @@ -243,6 +243,7 @@ export default { fileId: fileid, shareToken: this.shareToken, version, }) Config.update('urlsrc', data.urlSrc) + Config.update('wopi_callback_url', loadState('richdocuments', 'wopi_callback_url', '')) // Generate form and submit to the iframe const action = getWopiUrl({ diff --git a/templates/admin.php b/templates/admin.php index 2712120e4d..ca6dc462b3 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -7,7 +7,7 @@
-
+

t('Global templates')) ?> From 1562bcddf92b8b1862e1d4498052f7838baca688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Thu, 23 Nov 2023 10:29:38 +0100 Subject: [PATCH 2/5] chore: Update psalm baseline with missing symfony dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- tests/psalm-baseline.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index c55752e530..1679e0105f 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -51,6 +51,12 @@ + + + NullOutput + NullOutput + + fetchPreview($template, $x, $y, $a, $forceIcon, $mode)]]> @@ -114,6 +120,12 @@ Office + + + OutputInterface + OutputInterface + + is_array($trustedList) From b06f5649b0c69aba5c975a67b52e0d53f02e0ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Thu, 23 Nov 2023 11:22:14 +0100 Subject: [PATCH 3/5] feat: Add browser connectivity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- lib/Controller/SettingsController.php | 3 --- .../AddContentSecurityPolicyListener.php | 8 +++++++ src/components/AdminSettings.vue | 21 +++++++++++++++++++ src/view/Office.vue | 2 +- .../AddContentSecurityPolicyListenerTest.php | 3 +++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index a00c475cde..4d4dfbdc08 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -64,8 +64,6 @@ public function __construct($appName, parent::__construct($appName, $request); } - #[PublicPage] - #[NoCSRFRequired] public function checkSettings(): DataResponse { try { $output = new NullOutput(); @@ -76,7 +74,6 @@ public function checkSettings(): DataResponse { return new DataResponse([ 'status' => $e->getCode(), 'data' => [ - // FIXME ONLY AS ADMIN 'message' => 'Failed to connect to the remote server: ' . $e->getMessage(), 'settings' => $this->getSettingsData(), ], diff --git a/lib/Listener/AddContentSecurityPolicyListener.php b/lib/Listener/AddContentSecurityPolicyListener.php index a0fbbacfc7..3b7c309836 100644 --- a/lib/Listener/AddContentSecurityPolicyListener.php +++ b/lib/Listener/AddContentSecurityPolicyListener.php @@ -59,6 +59,10 @@ public function handle(Event $event): void { $policy->addAllowedImageDomain($url); } + if ($this->isSettingsPage()) { + $policy->addAllowedConnectDomain("*"); + } + $event->addPolicy($policy); } @@ -66,4 +70,8 @@ private function isPageLoad(): bool { $scriptNameParts = explode('/', $this->request->getScriptName()); return end($scriptNameParts) === 'index.php'; } + + private function isSettingsPage(): bool { + return str_starts_with($this->request->getPathInfo(), '/settings/admin/richdocuments'); + } } diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index f956f88246..73b01be088 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -51,6 +51,13 @@

{{ t('richdocuments', 'Collabora Online should use the same protocol as the server installation.') }}

+ +

+ {{ t('richdocuments', 'Your browser has been unable to connect to the Collabora server:') }} + {{ settings.public_wopi_url }} +

+

{{ t('richdocuments', 'This URL is determined on the Collabora server either from the configured URL or the server_name parameter in coolwsd.xml.') }}

+

{{ t('richdocuments', 'Collabora Online server is reachable.') }}

{{ settings.product_name }} {{ settings.product_version }} {{ settings.product_hash }}

@@ -422,6 +429,8 @@ const SERVER_STATE_OK = 0 const SERVER_STATE_LOADING = 1 const SERVER_STATE_CONNECTION_ERROR = 2 const PROTOCOL_MISMATCH = 3 +const SERVER_STATE_BROWSER_CONNECTION_ERROR = 4 + const fontMimes = [ 'font/ttf', 'application/font-sfnt', @@ -609,6 +618,16 @@ export default { for (const settingKey in settings) { this.settings[settingKey] = settings[settingKey] } + 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' }) + } catch (e) { + console.error(e) + this.serverError = SERVER_STATE_BROWSER_CONNECTION_ERROR + } }, async fetchDemoServers() { try { @@ -712,6 +731,8 @@ export default { this.settings[settingKey] = settings[settingKey] } + this.checkFrontend() + return result } catch (e) { this.updating = false diff --git a/src/view/Office.vue b/src/view/Office.vue index 2531920dab..252d4d24da 100644 --- a/src/view/Office.vue +++ b/src/view/Office.vue @@ -171,7 +171,7 @@ export default { }, computed: { showIframe() { - return this.loading >= LOADING_STATE.FRAME_READY + return this.loading >= LOADING_STATE.FRAME_READY || this.debug }, showLoadingIndicator() { return this.loading < LOADING_STATE.FRAME_READY diff --git a/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php b/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php index fe97e01bfa..e4d7924bc5 100644 --- a/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php +++ b/tests/lib/Listener/AddContentSecurityPolicyListenerTest.php @@ -65,6 +65,9 @@ public function setUp(): void { $this->overwriteService(FederationService::class, $this->federationService); $this->request = $this->createMock(IRequest::class); + $this->request->method('getPathInfo') + ->willReturn('/apps/files'); + $this->config = $this->getMockBuilder(AppConfig::class) ->setConstructorArgs([ $this->createMock(IConfig::class), From 362593844185c1867fa8ff3b4419b7ec7d1341b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Thu, 23 Nov 2023 11:54:21 +0100 Subject: [PATCH 4/5] tests: Fix selectors on settings tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- cypress/e2e/settings.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/settings.spec.js b/cypress/e2e/settings.spec.js index 3adefbb183..8ea1ead195 100644 --- a/cypress/e2e/settings.spec.js +++ b/cypress/e2e/settings.spec.js @@ -45,7 +45,8 @@ describe('Office admin settings', function() { .clear() .type((usesHttps ? 'https' : 'http') + '://invalid.example.com{enter}') cy.wait('@updateSettings').its('response.statusCode').should('equal', 500) - cy.get('#security-warning-state-failure .message') + cy.get('.notecard') + .first() .scrollIntoView() .should('be.visible') .should('contain.text', 'Could not establish connection to the Collabora Online server.') @@ -64,7 +65,8 @@ describe('Office admin settings', function() { .clear() .type(collaboraUrl + '{enter}') cy.wait('@updateSettings').its('response.statusCode').should('equal', 200) - cy.get('#security-warning-state-ok .message') + cy.get('.notecard') + .first() .scrollIntoView() .should('be.visible') .should('contain.text', 'Collabora Online server is reachable.') From f16f214fa7dbee504f867b00bd4ec1b7bb9375e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Fri, 24 Nov 2023 09:05:26 +0100 Subject: [PATCH 5/5] fix: Convert CSP idn domains to ascii (fix #3086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- lib/AppConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/AppConfig.php b/lib/AppConfig.php index b0a6406ab3..2cc826b84c 100644 --- a/lib/AppConfig.php +++ b/lib/AppConfig.php @@ -206,7 +206,7 @@ public function getDomainList(): array { $this->getGSDomains() ); - return array_filter($urls); + return array_map(fn ($url) => idn_to_ascii($url), array_filter($urls)); } private function getFederationDomains(): array {