Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
331 changes: 191 additions & 140 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Nextcloud <info@nextcloud.com>"
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/whiteboard"

[[annotations]]
path = [".gitattributes", ".editorconfig", "babel.config.js", ".php-cs-fixer.dist.php", "package-lock.json", "package.json", "composer.json", "composer.lock", "webpack.js", "stylelint.config.js", ".eslintrc.js", "cypress/.eslintrc.json", ".gitignore", ".jshintrc", ".l10nignore", "action/.gitignore", "action/package.json", "action/package-lock.json", "action/dist/index.js", "tests/**", "psalm.xml", "vendor-bin/**/composer.json", "vendor-bin/**/composer.lock", ".tx/config", "webpack.config.js", "js/vendor.LICENSE.txt", ".github/CODEOWNERS", "vite.config.js", "stylelint.config.cjs", "composer/**.php", "composer/composer.**", "tsconfig.json", "jsconfig.json", "krankerl.toml", "renovate.json", ".github/ISSUE_TEMPLATE/**", ".nextcloudignore", "CHANGELOG.md", ".tsconfig.json"]
path = [".gitattributes", ".editorconfig", "babel.config.js", ".php-cs-fixer.dist.php", "package-lock.json", "package.json", "composer.json", "composer.lock", "webpack.js", "stylelint.config.js", ".eslintrc.js", "cypress/.eslintrc.json", ".gitignore", ".jshintrc", ".l10nignore", "action/.gitignore", "action/package.json", "action/package-lock.json", "action/dist/index.js", "tests/**", "psalm.xml", "vendor-bin/**/composer.json", "vendor-bin/**/composer.lock", ".tx/config", "webpack.config.js", "js/vendor.LICENSE.txt", ".github/CODEOWNERS", "vite.config.js", "stylelint.config.cjs", "composer/**.php", "composer/composer.**", "tsconfig.json", "jsconfig.json", "krankerl.toml", "renovate.json", ".github/ISSUE_TEMPLATE/**", ".nextcloudignore", "CHANGELOG.md", ".tsconfig.json", "websocket_server/package.json", "websocket_server/package-lock.json"]
precedence = "aggregate"
SPDX-FileCopyrightText = "none"
SPDX-License-Identifier = "CC0-1.0"
Expand Down
91 changes: 91 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

namespace OCA\Whiteboard\AppInfo;

use OCA\AppAPI\Middleware\AppAPIAuthMiddleware;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Viewer\Event\LoadViewer;
use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener;
use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener;
use OCA\Whiteboard\Listener\LoadViewerListener;
use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener;
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExAppService;
use OCA\Whiteboard\Settings\SetupCheck;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
Expand All @@ -24,8 +27,12 @@
use OCP\Files\Template\ITemplateManager;
use OCP\Files\Template\RegisterTemplateCreatorEvent;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use OCP\Util;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;

/**
* @psalm-suppress UndefinedClass
Expand All @@ -47,6 +54,13 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerSetupCheck(SetupCheck::class);

if (class_exists(AppAPIAuthMiddleware::class) && $this->getExAppService()->isWhiteboardWebsocketEnabled()) {
$context->registerMiddleware(AppAPIAuthMiddleware::class);
}

// Auto-configure collaboration URL and JWT secret if ExApp is detected
$this->configureExAppCollaboration();
}

#[\Override]
Expand All @@ -60,4 +74,81 @@ public function boot(IBootContext $context): void {
});
}
}

/**
* Automatically configure collaboration URL and JWT secret when ExApp is detected
*/
private function configureExAppCollaboration(): void {
try {
$container = $this->getContainer();
$exAppService = $container->get(ExAppService::class);
$configService = $container->get(ConfigService::class);
$urlGenerator = $container->get(IURLGenerator::class);

if ($exAppService->isWhiteboardWebsocketEnabled()) {
// Generate the ExApp collaboration URL
$baseUrl = $urlGenerator->getAbsoluteURL('');
$exAppUrl = rtrim($baseUrl, '/') . '/exapps/nextcloud_whiteboard';

// Check current URL configuration
$currentUrl = $configService->getCollabBackendUrl();

// Force update to ExApp URL when ExApp is detected (for dynamic configuration)
if ($currentUrl !== $exAppUrl) {
$configService->setCollabBackendUrl($exAppUrl);
}

// Configure JWT secret synchronization with ExApp
$this->configureExAppJwtSecret($exAppService, $configService);
}
} catch (\Exception) {
// Silently fail - this is auto-configuration, shouldn't break app registration
}
}

/**
* Configure JWT secret synchronization between Nextcloud and ExApp
*/
private function configureExAppJwtSecret(ExAppService $exAppService, ConfigService $configService): void {
try {
$logger = $this->getContainer()->get(LoggerInterface::class);
$logger->debug('Starting JWT secret synchronization for ExApp');

// Get the ExApp secret from app_api
$exAppSecret = $exAppService->getWhiteboardExAppSecret();

if ($exAppSecret !== null && $exAppSecret !== '') {
$logger->debug('ExApp secret retrieved successfully');

// Get current JWT secret from whiteboard config
$currentJwtSecret = $configService->getJwtSecretKey();

// Update JWT secret if it's different from ExApp secret
if ($currentJwtSecret !== $exAppSecret) {
$logger->info('Updating whiteboard JWT secret to match ExApp secret');
$configService->setWhiteboardSharedSecret($exAppSecret);
} else {
$logger->debug('JWT secret already matches ExApp secret, no update needed');
}
} else {
$logger->warning('ExApp secret is null or empty, cannot synchronize JWT secret');
}
} catch (\Exception $e) {
// Log the error but don't break app registration
try {
$logger = $this->getContainer()->get(LoggerInterface::class);
$logger->error('Failed to configure ExApp JWT secret', ['error' => $e->getMessage()]);
} catch (\Exception) {
// Silently fail if we can't even get the logger
}
}
}

/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function getExAppService(): ExAppService {
return $this->getContainer()->get(ExAppService::class);
}
}
16 changes: 16 additions & 0 deletions lib/Consts/ExAppConsts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\Whiteboard\Consts;

final class ExAppConsts {
public const APP_API_ID = 'app_api';
public const WHITEBOARD_EX_APP_ID = 'nextcloud_whiteboard';
public const WHITEBOARD_EX_APP_ENABLED_KEY = 'isWhiteboardExAppEnabled';
}
5 changes: 5 additions & 0 deletions lib/Listener/LoadViewerListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use OCA\Viewer\Event\LoadViewer;
use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExAppService;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
Expand All @@ -22,6 +23,7 @@ class LoadViewerListener implements IEventListener {
public function __construct(
private IInitialState $initialState,
private ConfigService $configService,
private ExAppService $exAppService,
) {
}

Expand All @@ -42,5 +44,8 @@ public function handle(Event $event): void {
'maxFileSize',
$this->configService->getMaxFileSize()
);

// Initialize ExApp frontend state
$this->exAppService->initFrontendState();
}
}
152 changes: 152 additions & 0 deletions lib/Service/ExAppService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Whiteboard\Service;

use OCA\AppAPI\Service\ExAppService as AppAPIService;
use OCA\Whiteboard\Consts\ExAppConsts;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IInitialState;
use OCP\IDBConnection;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* @psalm-suppress UndefinedClass
* @psalm-suppress MissingDependency
*/
final class ExAppService {
private ?AppAPIService $appAPIService = null;

public function __construct(
private IAppManager $appManager,
private ContainerInterface $container,
private IInitialState $initialState,
private LoggerInterface $logger,
private IDBConnection $dbConnection,
) {
$this->initAppAPIService();
}

private function initAppAPIService(): void {
$isAppAPIEnabled = $this->isAppAPIEnabled();

if (class_exists(AppAPIService::class) && $isAppAPIEnabled) {
try {
$this->appAPIService = $this->container->get(AppAPIService::class);
} catch (Throwable $e) {
$this->logger->error('exApp', [$e->getMessage()]);
}
}
}

private function isAppAPIEnabled(): bool {
return $this->appManager->isEnabledForUser(ExAppConsts::APP_API_ID);
}

public function isExAppEnabled(string $appId): bool {
if ($this->appAPIService === null) {
return false;
}

return $this->appAPIService->getExApp($appId)?->getEnabled() === 1;
}

public function isWhiteboardWebsocketEnabled(): bool {
return $this->isExAppEnabled(ExAppConsts::WHITEBOARD_EX_APP_ID);
}

public function initFrontendState(): void {
$this->initialState->provideInitialState(
ExAppConsts::WHITEBOARD_EX_APP_ENABLED_KEY,
$this->isWhiteboardWebsocketEnabled()
);
}

/**
* Get the ExApp secret for the whiteboard ExApp
* This secret is used for JWT authentication between Nextcloud and the ExApp
*/
public function getWhiteboardExAppSecret(): ?string {
if ($this->appAPIService === null) {
return null;
}

try {
$exApp = $this->appAPIService->getExApp(ExAppConsts::WHITEBOARD_EX_APP_ID);
if ($exApp === null) {
$this->logger->debug('ExApp not found', ['appId' => ExAppConsts::WHITEBOARD_EX_APP_ID]);
return null;
}

// Try to get the secret using the getSecret method
try {
if (method_exists($exApp, 'getSecret')) {
$secret = $exApp->getSecret();
if ($secret !== null && $secret !== '') {
$this->logger->debug('ExApp secret retrieved successfully via getSecret method');
return $secret;
}
}
} catch (Throwable $e) {
$this->logger->warning('Failed to get secret via getSecret method', [
'error' => $e->getMessage()
]);
}

$this->logger->info('ExApp secret not accessible via getSecret method, trying database fallback', [
'appId' => ExAppConsts::WHITEBOARD_EX_APP_ID
]);

// Fallback: try to get secret directly from database
return $this->getExAppSecretFromDatabase(ExAppConsts::WHITEBOARD_EX_APP_ID);
} catch (Throwable $e) {
$this->logger->error('Failed to retrieve ExApp secret', [
'appId' => ExAppConsts::WHITEBOARD_EX_APP_ID,
'error' => $e->getMessage()
]);

// Final fallback: try database access
return $this->getExAppSecretFromDatabase(ExAppConsts::WHITEBOARD_EX_APP_ID);
}
}

/**
* Fallback method to get ExApp secret directly from database
*/
private function getExAppSecretFromDatabase(string $appId): ?string {
try {
$this->logger->debug('Attempting to retrieve ExApp secret from database', ['appId' => $appId]);

$qb = $this->dbConnection->getQueryBuilder();
$qb->select('secret')
->from('ex_apps')
->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId)));

$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();

if ($row && isset($row['secret']) && $row['secret'] !== '') {
$this->logger->debug('ExApp secret retrieved successfully from database');
return $row['secret'];
}

$this->logger->warning('ExApp secret not found in database or is empty', ['appId' => $appId]);
return null;
} catch (Throwable $e) {
$this->logger->error('Failed to retrieve ExApp secret from database', [
'appId' => $appId,
'error' => $e->getMessage()
]);
return null;
}
}
}
6 changes: 6 additions & 0 deletions lib/Settings/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace OCA\Whiteboard\Settings;

use OCA\Whiteboard\Service\ConfigService;
use OCA\Whiteboard\Service\ExAppService;
use OCA\Whiteboard\Service\JWTService;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
Expand All @@ -19,6 +20,7 @@ public function __construct(
private IInitialState $initialState,
private ConfigService $configService,
private JWTService $jwtService,
private ExAppService $exAppService,
) {
}

Expand All @@ -44,6 +46,10 @@ public function getForm(): TemplateResponse {

#[\Override]
public function getSection() {
if ($this->exAppService->isWhiteboardWebsocketEnabled()) {
return null;
}

return 'whiteboard';
}

Expand Down
Loading
Loading