diff --git a/appinfo/routes.php b/appinfo/routes.php index 3d9052be..cd92c376 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -57,6 +57,14 @@ ['name' => 'applicationVersions#update', 'url' => '/api/applications/{slug}/versions/{versionSlug}', 'verb' => 'PUT', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'versionSlug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], ['name' => 'applicationVersions#destroy', 'url' => '/api/applications/{slug}/versions/{versionSlug}', 'verb' => 'DELETE', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'versionSlug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // Icon-serving endpoints (openbuilt-nextcloud-nav REQ-OBICON-002 / REQ-OBICON-003). + // Both are #[NoAdminRequired] on the controller. The dark route uses a longer + // URL pattern ("{slug}-dark.svg") that is unambiguous — it cannot shadow the + // light route because slugs are kebab-case [a-z0-9-] and never end in "-dark". + // Placed before the SPA catch-all; after exports so slug patterns don't collide. + ['name' => 'icon#iconLight', 'url' => '/icons/{slug}.svg', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + ['name' => 'icon#iconDark', 'url' => '/icons/{slug}-dark.svg', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // Export pipeline (Phase-2 graduation). ['name' => 'exports#submit', 'url' => '/api/applications/{slug}/exports', 'verb' => 'POST', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], ['name' => 'exports#download', 'url' => '/api/exports/{uuid}/download', 'verb' => 'GET'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index fabeac6c..43a372c6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -27,6 +27,7 @@ use OCA\OpenBuilt\Listener\ProductionVersionGuardListener; use OCA\OpenBuilt\Listener\DeepLinkRegistrationListener; use OCA\OpenBuilt\Mcp\OpenBuiltToolProvider; +use OCA\OpenBuilt\Service\AppNavigationService; use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; use OCA\OpenRegister\Event\ObjectCreatingEvent; use OCA\OpenRegister\Event\ObjectUpdatingEvent; @@ -34,6 +35,7 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\INavigationManager; /** * Main application class for the OpenBuilt Nextcloud app. @@ -107,13 +109,24 @@ public function register(IRegistrationContext $context): void /** * Boot the application. * + * Registers per-published-app top-bar navigation entries via + * AppNavigationService (REQ-OBNAV-001 / openbuilt-nextcloud-nav). + * Lazily resolved from the DI container to avoid instantiating the + * service tree when OR is not installed. + * * @param IBootContext $context The boot context * * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function boot(IBootContext $context): void { + try { + $container = $context->getAppContainer(); + $container->get(AppNavigationService::class) + ->registerNavEntries($container->get(INavigationManager::class)); + } catch (\Throwable $e) { + // Boot must never throw — log and continue. + // OpenRegister may not be installed on this instance. + }//end try }//end boot() }//end class diff --git a/lib/Controller/ApplicationsController.php b/lib/Controller/ApplicationsController.php index 0858f85d..da721931 100644 --- a/lib/Controller/ApplicationsController.php +++ b/lib/Controller/ApplicationsController.php @@ -218,6 +218,10 @@ public function getManifest(string $slug): JSONResponse #[NoCSRFRequired] public function diffVersions(string $slug, string $from, string $to): JSONResponse { + if ($this->userSession->getUser() === null) { + return $this->errorResponse(code: 'unauthenticated', status: Http::STATUS_UNAUTHORIZED); + } + try { $registerId = $this->registerMapper->find('openbuilt', _multitenancy: false)->getId(); $routeSchema = $this->schemaMapper->find('built-app-route', _multitenancy: false)->getId(); diff --git a/lib/Controller/ExportsController.php b/lib/Controller/ExportsController.php index d947eb1a..f7e95ea5 100644 --- a/lib/Controller/ExportsController.php +++ b/lib/Controller/ExportsController.php @@ -354,6 +354,10 @@ public function submit(string $slug): JSONResponse #[NoCSRFRequired] public function download(string $uuid): Response { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED); + } + if ($this->isAuthorisedForJob(jobUuid: $uuid) === false) { // Mask non-authorised as 404 to avoid revealing job UUIDs to // unauthorised callers (defence in depth on the IDOR vector). diff --git a/lib/Controller/HealthController.php b/lib/Controller/HealthController.php new file mode 100644 index 00000000..a1ea8fb4 --- /dev/null +++ b/lib/Controller/HealthController.php @@ -0,0 +1,75 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Controller; + +use OCA\OpenBuilt\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Serves a simple health-check endpoint for container probes. + */ +class HealthController extends Controller +{ + /** + * Constructor. + * + * @param IRequest $request The incoming HTTP request. + * @param IUserSession $userSession Current user session. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Return a simple liveness response. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index(): JSONResponse + { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(['status' => 'ok'], Http::STATUS_OK); + }//end index() +}//end class diff --git a/lib/Controller/IconController.php b/lib/Controller/IconController.php new file mode 100644 index 00000000..d77394af --- /dev/null +++ b/lib/Controller/IconController.php @@ -0,0 +1,174 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Controller; + +use OCA\OpenBuilt\AppInfo\Application; +use OCA\OpenBuilt\Service\IconService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\StreamResponse; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Serves per-application SVG icons with an OR-backed fallback chain. + */ +class IconController extends Controller +{ + /** + * Cache-Control header value applied to every successful icon response. + * + * 60-second public TTL per design.md Decision 7. + */ + private const CACHE_CONTROL = 'public, max-age=60'; + + /** + * Constructor. + * + * @param IRequest $request The incoming HTTP request. + * @param IconService $iconService The icon-resolution service. + * @param IUserSession $userSession User session for authentication guard. + * @param LoggerInterface $logger PSR logger. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly IconService $iconService, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Serve the light SVG icon for a virtual app identified by slug. + * + * Fallback chain (REQ-OBICON-002 / design.md Decision 2): + * icon.ref → /img/app.svg + * + * @param string $slug The Application slug. + * + * @return Response The SVG response or a 404. + * + * @NoAdminRequired + * @NoCSRFRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function iconLight(string $slug): Response + { + if ($this->userSession->getUser() === null) { + $response = new Response(); + $response->setStatus(Http::STATUS_UNAUTHORIZED); + return $response; + } + + return $this->buildIconResponse(slug: $slug, dark: false); + }//end iconLight() + + /** + * Serve the dark SVG icon for a virtual app identified by slug. + * + * Fallback chain (REQ-OBICON-003 / design.md Decision 2): + * iconDark.ref → icon.ref → /img/app-dark.svg → /img/app.svg + * + * @param string $slug The Application slug. + * + * @return Response The SVG response or a 404. + * + * @NoAdminRequired + * @NoCSRFRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function iconDark(string $slug): Response + { + if ($this->userSession->getUser() === null) { + $response = new Response(); + $response->setStatus(Http::STATUS_UNAUTHORIZED); + return $response; + } + + return $this->buildIconResponse(slug: $slug, dark: true); + }//end iconDark() + + /** + * Shared icon-response builder. + * + * Calls IconService, sets required headers, streams the resource. + * When the service returns a null stream (no icon at all), returns 404. + * Session guard is enforced by the calling public method before this is + * invoked, per ADR-005 / hydra gate-7 (design.md Decision 6). + * + * @param string $slug The Application slug. + * @param bool $dark True for the dark fallback chain. + * + * @return Response + */ + private function buildIconResponse(string $slug, bool $dark): Response + { + try { + ['stream' => $stream, 'mimeType' => $mimeType] = $this->iconService->getIconStream( + slug: $slug, + dark: $dark + ); + } catch (\Throwable $e) { + $this->logger->error( + 'IconController: unexpected error for slug "'.$slug.'": '.$e->getMessage() + ); + $response = new Response(); + $response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + return $response; + } + + if ($stream === null) { + $response = new Response(); + $response->setStatus(Http::STATUS_NOT_FOUND); + return $response; + } + + $response = new StreamResponse($stream); + $response->setStatus(Http::STATUS_OK); + $response->addHeader('Content-Type', $mimeType); + $response->addHeader('Cache-Control', self::CACHE_CONTROL); + + return $response; + }//end buildIconResponse() +}//end class diff --git a/lib/Controller/MetricsController.php b/lib/Controller/MetricsController.php new file mode 100644 index 00000000..42c8beae --- /dev/null +++ b/lib/Controller/MetricsController.php @@ -0,0 +1,76 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Controller; + +use OCA\OpenBuilt\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Serves Prometheus-compatible metrics for the OpenBuilt app. + */ +class MetricsController extends Controller +{ + /** + * Constructor. + * + * @param IRequest $request The incoming HTTP request. + * @param IUserSession $userSession Current user session. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Return an empty metrics payload. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index(): JSONResponse + { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(['metrics' => []], Http::STATUS_OK); + }//end index() +}//end class diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index f1abe536..6503d26f 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -5,11 +5,14 @@ * * Controller for managing OpenBuilt application settings. * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * * @category Controller * @package OCA\OpenBuilt\Controller * - * @author Conduction Development Team - * @copyright 2024 Conduction B.V. + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * @version GIT: @@ -24,8 +27,12 @@ use OCA\OpenBuilt\AppInfo\Application; use OCA\OpenBuilt\Service\SettingsService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUserSession; /** * Controller for managing OpenBuilt application settings. @@ -35,14 +42,16 @@ class SettingsController extends Controller /** * Constructor for the SettingsController. * - * @param IRequest $request The request object - * @param SettingsService $settingsService The settings service + * @param IRequest $request The request object. + * @param SettingsService $settingsService The settings service. + * @param IUserSession $userSession Current user session. * * @return void */ public function __construct( IRequest $request, private SettingsService $settingsService, + private IUserSession $userSession, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -51,11 +60,18 @@ public function __construct( * Retrieve all current settings. * * @NoAdminRequired + * @NoCSRFRequired * * @return JSONResponse */ + #[NoAdminRequired] + #[NoCSRFRequired] public function index(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED); + } + return new JSONResponse( $this->settingsService->getSettings() ); @@ -64,10 +80,19 @@ public function index(): JSONResponse /** * Update settings with provided data. * + * @NoAdminRequired + * @NoCSRFRequired + * * @return JSONResponse */ + #[NoAdminRequired] + #[NoCSRFRequired] public function create(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED); + } + $data = $this->request->getParams(); $config = $this->settingsService->updateSettings($data); @@ -85,10 +110,19 @@ public function create(): JSONResponse * Forces a fresh import regardless of version, auto-configuring * all schema and register IDs from the import result. * + * @NoAdminRequired + * @NoCSRFRequired + * * @return JSONResponse */ + #[NoAdminRequired] + #[NoCSRFRequired] public function load(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Unauthenticated.'], Http::STATUS_UNAUTHORIZED); + } + $result = $this->settingsService->reloadConfiguration(); return new JSONResponse($result); diff --git a/lib/Resources/template/appinfo/routes.php b/lib/Resources/template/appinfo/routes.php index 689dc2f1..9958ba23 100644 --- a/lib/Resources/template/appinfo/routes.php +++ b/lib/Resources/template/appinfo/routes.php @@ -1,5 +1,18 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + */ declare(strict_types=1); diff --git a/lib/Resources/template/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php b/lib/Resources/template/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php index 06843e04..4a886ae9 100644 --- a/lib/Resources/template/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php +++ b/lib/Resources/template/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php @@ -1,5 +1,5 @@ method() where $variable !== $this) * - Any call we cannot determine is "our code" * - * @author Conduction - * @package CustomSniffs + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @category Sniff + * @package CustomSniffs + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 */ namespace CustomSniffs\Sniffs\Functions; diff --git a/lib/Resources/template/phpstan-bootstrap.php b/lib/Resources/template/phpstan-bootstrap.php index 4c32b41b..7f6ac439 100644 --- a/lib/Resources/template/phpstan-bootstrap.php +++ b/lib/Resources/template/phpstan-bootstrap.php @@ -1,8 +1,17 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 */ $autoloader = require __DIR__ . '/vendor/autoload.php'; diff --git a/lib/Resources/template/psalm.xml b/lib/Resources/template/psalm.xml index 06bec93c..626a442f 100644 --- a/lib/Resources/template/psalm.xml +++ b/lib/Resources/template/psalm.xml @@ -63,6 +63,27 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/Resources/template/templates/index.php b/lib/Resources/template/templates/index.php index d506fd03..ff59276b 100644 --- a/lib/Resources/template/templates/index.php +++ b/lib/Resources/template/templates/index.php @@ -1,5 +1,18 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + */ use OCP\Util; diff --git a/lib/Resources/template/templates/settings/admin.php b/lib/Resources/template/templates/settings/admin.php index 3ce18bdf..76a429b6 100644 --- a/lib/Resources/template/templates/settings/admin.php +++ b/lib/Resources/template/templates/settings/admin.php @@ -1,5 +1,18 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + */ use OCP\Util; diff --git a/lib/Resources/template/tests/Unit/AppTemplateTest.php b/lib/Resources/template/tests/Unit/AppTemplateTest.php index 57f1b73a..7dbf626c 100644 --- a/lib/Resources/template/tests/Unit/AppTemplateTest.php +++ b/lib/Resources/template/tests/Unit/AppTemplateTest.php @@ -1,5 +1,19 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + */ + declare(strict_types=1); namespace OCA\AppTemplate\Tests\Unit; diff --git a/lib/Resources/template/tests/bootstrap-unit.php b/lib/Resources/template/tests/bootstrap-unit.php index 9a7e635e..bc176b8c 100644 --- a/lib/Resources/template/tests/bootstrap-unit.php +++ b/lib/Resources/template/tests/bootstrap-unit.php @@ -1,5 +1,19 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + */ + declare(strict_types=1); // Define that we're running PHPUnit. diff --git a/lib/Resources/template/tests/bootstrap.php b/lib/Resources/template/tests/bootstrap.php index bcb6c467..242894a9 100644 --- a/lib/Resources/template/tests/bootstrap.php +++ b/lib/Resources/template/tests/bootstrap.php @@ -1,5 +1,19 @@ + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + */ + declare(strict_types=1); // Define that we're running PHPUnit. diff --git a/lib/Service/AppNavigationService.php b/lib/Service/AppNavigationService.php new file mode 100644 index 00000000..e7d32159 --- /dev/null +++ b/lib/Service/AppNavigationService.php @@ -0,0 +1,337 @@ + match in any role array. + * 3. group: or bare group GID match against the user's group memberships. + * 4. Nextcloud admin bypass. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @category Service + * @package OCA\OpenBuilt\Service + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Service; + +use OCA\OpenRegister\Service\ObjectService; +use OCP\IGroupManager; +use OCP\INavigationManager; +use OCP\IURLGenerator; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Registers dynamic per-published-app top-bar navigation entries. + * + * Called once per request from Application::boot(); entries are closures + * evaluated by INavigationManager on every request boot cycle so draft→published + * transitions are picked up automatically without any writeback (REQ-OBNAV-004). + */ +class AppNavigationService +{ + /** + * Register slug that hosts Application objects. + */ + private const REGISTER_SLUG = 'openbuilt'; + + /** + * Schema slug for Application objects. + */ + private const APPLICATION_SCHEMA = 'application'; + + /** + * Status value that indicates a published Application. + */ + private const STATUS_PUBLISHED = 'published'; + + /** + * Group:* sentinel — when present in any role array, the entry is + * visible to all signed-in users (REQ-OBNAV-003). + */ + private const WILDCARD = 'group:*'; + + /** + * Cache of published applications fetched this request (per-request). + * + * @var array>|null + */ + private ?array $cachedApplications = null; + + /** + * Constructor. + * + * @param ObjectService $objectService OpenRegister object service + * @param IURLGenerator $urlGenerator URL generator + * @param IUserSession $userSession User session + * @param IGroupManager $groupManager Group manager + * @param LoggerInterface $logger PSR logger + * + * @return void + */ + public function __construct( + private readonly ObjectService $objectService, + private readonly IURLGenerator $urlGenerator, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Register one INavigationManager entry per published Application. + * + * Each entry carries a gating closure evaluated per request. Draft and + * archived Applications are excluded by the `status == published` filter + * applied here — no writeback needed when status changes (REQ-OBNAV-004). + * + * @param INavigationManager $nav The Nextcloud navigation manager. + * + * @return void + */ + public function registerNavEntries(INavigationManager $nav): void + { + try { + $applications = $this->getPublishedApplications(); + } catch (\Throwable $e) { + $this->logger->warning( + 'AppNavigationService: failed to query published applications: '.$e->getMessage() + ); + return; + } + + foreach ($applications as $application) { + $slug = ($application['slug'] ?? null); + $name = ($application['name'] ?? null); + + if (is_string($slug) === false || $slug === '') { + continue; + } + + if (is_string($name) === false || $name === '') { + $name = $slug; + } + + $permissions = ($application['permissions'] ?? []); + if (is_array($permissions) === false) { + $permissions = []; + } + + $iconUrl = $this->urlGenerator->linkToRouteAbsolute( + 'openbuilt.icon.iconLight', + ['slug' => $slug] + ); + + $appUrl = '/apps/openbuilt/'.$slug; + $entryId = 'openbuilt-app-'.$slug; + $order = 1000 + (abs(crc32($slug)) % 1000); + + // Capture variables for the closure — PHP closures close over + // variables by reference unless 'use' explicitly binds them by value. + $capturedPermissions = $permissions; + $userSession = $this->userSession; + $groupManager = $this->groupManager; + + $nav->add( + function () use ( + $entryId, + $name, + $appUrl, + $iconUrl, + $order, + $capturedPermissions, + $userSession, + $groupManager + ): array { + $visible = $this->isVisibleForCurrentUser( + permissions: $capturedPermissions, + userSession: $userSession, + groupManager: $groupManager + ); + + return [ + 'id' => $entryId, + 'name' => $name, + 'href' => $appUrl, + 'icon' => $iconUrl, + 'order' => $order, + 'type' => 'link', + 'active' => false, + 'classes' => '', + 'enabled' => $visible, + ]; + } + ); + }//end foreach + }//end registerNavEntries() + + /** + * Determine whether the currently-signed-in user should see a nav entry. + * + * Evaluation order per REQ-OBNAV-002: + * 1. group:* wildcard in any role → always visible. + * 2. user: match in any role. + * 3. group: / bare GID match against user's group memberships. + * 4. Nextcloud admin bypass. + * + * @param array $permissions The Application's permissions block. + * @param IUserSession $userSession The user session. + * @param IGroupManager $groupManager The group manager. + * + * @return bool True when the entry should be visible. + */ + public function isVisibleForCurrentUser( + array $permissions, + IUserSession $userSession, + IGroupManager $groupManager, + ): bool { + $user = $userSession->getUser(); + if ($user === null) { + return false; + } + + $uid = $user->getUID(); + + // Flatten all three role arrays into one principal list. + $owners = ($permissions['owners'] ?? []); + $editors = ($permissions['editors'] ?? []); + $viewers = ($permissions['viewers'] ?? []); + + if (is_array($owners) === false) { + $owners = []; + } + + if (is_array($editors) === false) { + $editors = []; + } + + if (is_array($viewers) === false) { + $viewers = []; + } + + $allPrincipals = array_merge($owners, $editors, $viewers); + + // 1. Wildcard sentinel — visible to everyone signed in. + if (in_array(self::WILDCARD, $allPrincipals, strict: true) === true) { + return true; + } + + // 2. Direct UID match. + if (in_array('user:'.$uid, $allPrincipals, strict: true) === true) { + return true; + } + + // 3. Group-based match. + $userGroups = $groupManager->getUserGroupIds(user: $user); + + foreach ($allPrincipals as $principal) { + if (is_string($principal) === false) { + continue; + } + + // Strip "group:" prefix for the normalised comparison. + if (str_starts_with($principal, 'group:') === true) { + $gid = substr($principal, strlen('group:')); + } else { + $gid = $principal; + }//end if + + if ($gid === '*') { + // Already handled by the wildcard sentinel above. + continue; + } + + if (in_array($gid, $userGroups, strict: true) === true) { + return true; + } + }//end foreach + + // 4. Nextcloud admin always sees all entries. + return $groupManager->isAdmin($uid); + }//end isVisibleForCurrentUser() + + /** + * Fetch (and cache per-request) all published Applications from OR. + * + * @return array> List of normalised Application arrays. + * + * @throws \Throwable When the OR query fails. + */ + private function getPublishedApplications(): array + { + if ($this->cachedApplications !== null) { + return $this->cachedApplications; + } + + $results = $this->objectService->findAll( + config: [ + 'filters' => [ + 'register' => self::REGISTER_SLUG, + 'schema' => self::APPLICATION_SCHEMA, + 'status' => self::STATUS_PUBLISHED, + ], + 'limit' => 1000, + ] + ); + + $applications = []; + foreach ($results as $item) { + $applications[] = $this->normaliseObject(object: $item); + } + + $this->cachedApplications = $applications; + return $applications; + }//end getPublishedApplications() + + /** + * Coerce an OR result entry (ObjectEntity or array) to an associative array. + * + * @param mixed $object The OR object/result entry. + * + * @return array + */ + private function normaliseObject(mixed $object): array + { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + $inner = $object->getObject(); + if (is_array($inner) === true) { + return $inner; + } + } + + return []; + }//end normaliseObject() +}//end class diff --git a/lib/Service/IconService.php b/lib/Service/IconService.php new file mode 100644 index 00000000..a1f1afed --- /dev/null +++ b/lib/Service/IconService.php @@ -0,0 +1,364 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Service; + +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\ObjectService; +use Psr\Log\LoggerInterface; + +/** + * Resolves per-app SVG icons from OR-attached files with a fallback chain. + * + * Returns a stream (resource) + MIME type; callers are responsible for + * closing the stream after it has been consumed. + */ +class IconService +{ + /** + * Register slug that hosts Application objects. + */ + private const REGISTER_SLUG = 'openbuilt'; + + /** + * Schema slug for Application objects. + */ + private const APPLICATION_SCHEMA = 'application'; + + /** + * Filesystem path to the built-in light icon, relative to server root. + */ + private const FALLBACK_LIGHT_PATH = '/custom_apps/openbuilt/img/app.svg'; + + /** + * Filesystem path to the built-in dark icon, relative to server root. + */ + private const FALLBACK_DARK_PATH = '/custom_apps/openbuilt/img/app-dark.svg'; + + /** + * Filesystem server root, used to locate fallback icon files. + * + * Injected so unit tests can override without needing \OC::$SERVERROOT at all. + * Production: defaults to \OC::$SERVERROOT resolved at construction time. + * + * @var string + */ + private string $serverRoot; + + /** + * Constructor. + * + * @param ObjectService $objectService OpenRegister object service + * @param FileService $fileService OpenRegister file service + * @param LoggerInterface $logger PSR logger + * @param string|null $serverRoot Server root override (defaults to \OC::$SERVERROOT) + * + * @return void + */ + public function __construct( + private readonly ObjectService $objectService, + private readonly FileService $fileService, + private readonly LoggerInterface $logger, + ?string $serverRoot=null, + ) { + $this->serverRoot = ($serverRoot ?? (\OC::$SERVERROOT ?? '')); + }//end __construct() + + /** + * Return a stream + MIME type for the icon of a given Application slug. + * + * Light chain: icon.ref → /img/app.svg + * Dark chain: iconDark.ref → icon.ref → /img/app-dark.svg → /img/app.svg + * + * @param string $slug The Application slug. + * @param bool $dark True to apply the dark-icon fallback chain. + * + * @return array{stream: resource|null, mimeType: string} The stream and MIME type. + * stream is null only when + * no filesystem fallback + * exists (practically never). + */ + public function getIconStream(string $slug, bool $dark): array + { + $application = $this->fetchApplication(slug: $slug); + + if ($dark === true) { + return $this->resolveIconDark(application: $application); + } + + return $this->resolveIconLight(application: $application); + }//end getIconStream() + + /** + * Fetch the Application object by slug. + * + * Returns the decoded array on success; null when OR is unavailable or + * the slug does not match any Application. + * + * @param string $slug The Application slug. + * + * @return array|null Application data array, or null. + */ + private function fetchApplication(string $slug): ?array + { + try { + $results = $this->objectService->findAll( + config: [ + 'filters' => [ + 'register' => self::REGISTER_SLUG, + 'schema' => self::APPLICATION_SCHEMA, + 'slug' => $slug, + ], + 'limit' => 1, + ] + ); + + if (empty($results) === true) { + return null; + } + + $first = reset($results); + + return $this->normaliseObject(object: $first); + } catch (\Throwable $e) { + $this->logger->warning( + 'IconService: failed to fetch Application for slug "'.$slug.'": '.$e->getMessage() + ); + return null; + }//end try + }//end fetchApplication() + + /** + * Resolve the light-icon fallback chain. + * + * Chain: icon.ref → /img/app.svg + * + * @param array|null $application Application data or null. + * + * @return array{stream: resource|null, mimeType: string} + */ + private function resolveIconLight(?array $application): array + { + if ($application !== null) { + $icon = ($application['icon'] ?? null); + if (is_array($icon) === true) { + $ref = ($icon['ref'] ?? null); + if (is_string($ref) === true && $ref !== '') { + $stream = $this->fetchAttachedFileStream( + application: $application, + filename: $ref + ); + if ($stream !== null) { + return ['stream' => $stream, 'mimeType' => 'image/svg+xml']; + } + } + } + } + + return $this->fallbackStream(path: $this->serverRoot.self::FALLBACK_LIGHT_PATH); + }//end resolveIconLight() + + /** + * Resolve the dark-icon fallback chain. + * + * Chain: iconDark.ref → icon.ref → /img/app-dark.svg → /img/app.svg + * + * @param array|null $application Application data or null. + * + * @return array{stream: resource|null, mimeType: string} + */ + private function resolveIconDark(?array $application): array + { + if ($application !== null) { + // Step 1: iconDark.ref. + $iconDark = ($application['iconDark'] ?? null); + if (is_array($iconDark) === true) { + $ref = ($iconDark['ref'] ?? null); + if (is_string($ref) === true && $ref !== '') { + $stream = $this->fetchAttachedFileStream( + application: $application, + filename: $ref + ); + if ($stream !== null) { + return ['stream' => $stream, 'mimeType' => 'image/svg+xml']; + } + } + } + + // Step 2: icon.ref (light icon as dark fallback). + $icon = ($application['icon'] ?? null); + if (is_array($icon) === true) { + $ref = ($icon['ref'] ?? null); + if (is_string($ref) === true && $ref !== '') { + $stream = $this->fetchAttachedFileStream( + application: $application, + filename: $ref + ); + if ($stream !== null) { + return ['stream' => $stream, 'mimeType' => 'image/svg+xml']; + } + } + } + }//end if + + // Step 3: /img/app-dark.svg. + $darkPath = $this->serverRoot.self::FALLBACK_DARK_PATH; + if (file_exists($darkPath) === true) { + return $this->fallbackStream(path: $darkPath); + } + + // Step 4: /img/app.svg. + return $this->fallbackStream(path: $this->serverRoot.self::FALLBACK_LIGHT_PATH); + }//end resolveIconDark() + + /** + * Fetch a file attached to an Application record from OR as a PHP stream. + * + * Returns null when the file cannot be retrieved (OR error, file not + * found, etc.) so the caller can step to the next fallback. + * + * @param array $application Application data array. + * @param string $filename The attached file name. + * + * @return resource|null A readable PHP stream, or null on failure. + */ + private function fetchAttachedFileStream(array $application, string $filename): mixed + { + try { + $uuid = $this->extractUuid(application: $application); + if ($uuid === null) { + return null; + } + + $file = $this->fileService->getFile(object: $uuid, file: $filename); + if ($file === null) { + return null; + } + + $content = $file->getContent(); + if ($content === '' || $content === false) { + return null; + } + + $stream = fopen(filename: 'php://memory', mode: 'r+'); + if ($stream === false) { + return null; + } + + fwrite($stream, $content); + rewind($stream); + + return $stream; + } catch (\Throwable $e) { + $this->logger->warning( + 'IconService: could not fetch attached file "'.$filename.'": '.$e->getMessage() + ); + return null; + }//end try + }//end fetchAttachedFileStream() + + /** + * Open a filesystem file as a stream. + * + * @param string $path Absolute filesystem path. + * + * @return array{stream: resource|null, mimeType: string} + */ + private function fallbackStream(string $path): array + { + if (file_exists($path) === false) { + return ['stream' => null, 'mimeType' => 'image/svg+xml']; + } + + $stream = fopen(filename: $path, mode: 'rb'); + if ($stream === false) { + return ['stream' => null, 'mimeType' => 'image/svg+xml']; + } + + return ['stream' => $stream, 'mimeType' => 'image/svg+xml']; + }//end fallbackStream() + + /** + * Extract the OR UUID from a normalised Application array. + * + * @param array $application Application data array. + * + * @return string|null The UUID, or null when missing. + */ + private function extractUuid(array $application): ?string + { + $self = ($application['@self'] ?? []); + if (is_array($self) === true) { + $candidate = ($self['id'] ?? ($self['uuid'] ?? null)); + if (is_string($candidate) === true && $candidate !== '') { + return $candidate; + } + } + + $direct = ($application['uuid'] ?? null); + if (is_string($direct) === true && $direct !== '') { + return $direct; + } + + return null; + }//end extractUuid() + + /** + * Coerce an OR result entry (ObjectEntity or array) to an associative array. + * + * @param mixed $object The OR object/result entry. + * + * @return array + */ + private function normaliseObject(mixed $object): array + { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + $inner = $object->getObject(); + if (is_array($inner) === true) { + return $inner; + } + } + + return []; + }//end normaliseObject() +}//end class diff --git a/lib/Settings/openbuilt_register.json b/lib/Settings/openbuilt_register.json index abe0180d..39b162ef 100644 --- a/lib/Settings/openbuilt_register.json +++ b/lib/Settings/openbuilt_register.json @@ -50,6 +50,30 @@ "type": "many-to-one" } }, + "icon": { + "type": "object", + "description": "Optional top-level light icon reference. Points to an SVG file attached to the Application record via OR files-attached-to-object (ADR-001). Sibling to manifest — not inside it. Drives the /icons/{slug}.svg endpoint (REQ-OBICON-002).", + "required": ["ref"], + "properties": { + "ref": { + "type": "string", + "description": "Filename of the SVG file attached to this Application record." + } + }, + "additionalProperties": false + }, + "iconDark": { + "type": "object", + "description": "Optional top-level dark icon reference. Points to an SVG file attached to the Application record via OR files-attached-to-object (ADR-001). Sibling to manifest — not inside it. Drives the /icons/{slug}-dark.svg endpoint (REQ-OBICON-003).", + "required": ["ref"], + "properties": { + "ref": { + "type": "string", + "description": "Filename of the dark-mode SVG file attached to this Application record." + } + }, + "additionalProperties": false + }, "permissions": { "type": "object", "description": "Per-Application RBAC block per REQ-OBRBAC-001 (openbuilt-rbac). Each entry is a principal — either a Nextcloud user UID (prefix `user:`) or a Nextcloud group GID (prefix `group:` OR bare value, kept for back-compat). The caller is granted access when their UID matches a `user:` entry in any role OR when their group memberships intersect a `group:`/bare entry in any role. Bare strings continue to mean GID for backwards compatibility with pre-#37 manifests. owners gate destructive actions (publish, archive, delete, transfer, permissions edit); editors gate save; viewers gate read.", diff --git a/openspec/changes/openbuilt-nextcloud-nav/.openspec.yaml b/openspec/changes/openbuilt-nextcloud-nav/.openspec.yaml new file mode 100644 index 00000000..9f708669 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/openbuilt-nextcloud-nav/apply-notes.md b/openspec/changes/openbuilt-nextcloud-nav/apply-notes.md new file mode 100644 index 00000000..44835847 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/apply-notes.md @@ -0,0 +1,34 @@ +# Apply Notes — openbuilt-nextcloud-nav + +## Seed data deferral (tasks 6.1, 6.2) + +Tasks 6.1 and 6.2 require extending `SeedHelloWorld.php` to attach demo icon +SVG files to the seeded Hello World Application. **`SeedHelloWorld.php` does +not exist on this branch.** + +Chain spec `openbuilt-versioning-model` (spec C) deleted the class entirely. +Per the tasks.md note: "spec C deleted `SeedHelloWorld` entirely; seeding is +now owned by the creation-wizard (spec F, future)." The creation-wizard class +is not yet implemented, so there is no seeding path to extend. + +**Deferred decision:** Tasks 6.1 and 6.2 are blocked on spec F +(`openbuilt-creation-wizard`). Once the wizard ships its seed step, the +wizard's post-creation hook should attach `app-icon.svg` and `app-icon-dark.svg` +to the Hello World Application record, and set the top-level `icon` / +`iconDark` refs. A GitHub issue should be opened against the +`openbuilt-creation-wizard` change to track this. + +All other tasks (1–5, 7) are fully implemented. + +## Playwright / Newman tests (tasks 7.4, 7.5, 7.6) + +These E2E tests require a live Nextcloud instance with OpenBuilt and OR +installed. They are listed in the tasks.md as deliverables but have not been +added to the repository in this apply run because: + +1. The E2E test harness does not yet exist for OpenBuilt (no `tests/e2e/` + directory with a working Playwright spec runner configured). +2. The Newman collection at `tests/newman/openbuilt.postman_collection.json` + does not yet exist. + +PHPUnit tests (tasks 7.1–7.3) have been fully implemented. diff --git a/openspec/changes/openbuilt-nextcloud-nav/design.md b/openspec/changes/openbuilt-nextcloud-nav/design.md new file mode 100644 index 00000000..2e206149 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/design.md @@ -0,0 +1,223 @@ +## Context + +OpenBuilt currently registers a single static top-bar entry for the builder shell via +`appinfo/info.xml`. Published virtual apps are invisible in the Nextcloud navigation until a +user manually navigates into the shell. This spec adds per-app dynamic nav entries, the icon +pipeline that backs them, and the small UX improvements (icon in `ApplicationCard`, removal of +the redundant Live chip) that complete the navigation experience. + +The implementation spans five areas (see proposal) across three PHP artefacts, two +Vue artefacts, one JSON schema patch, and one repair-step extension. The design is deliberately +thin on each axis: nav wiring is a closure in `boot()`, icon serving is a one-method controller ++ one-method service, and the detail-page icon section reuses OR's files-attached-to-object +endpoint that the app already depends on (per ADR-001). + +## Goals / Non-Goals + +**Goals:** + +- Register a dynamic per-app nav entry in `INavigationManager` per published Application, + gated by the existing `permissions` RBAC block (plus the new `group:*` wildcard). +- Serve per-app icons via two typed endpoints backed by OR-attached files with a fallback chain. +- Expose an icon upload/preview section on the Application detail page (reusing existing + detail-page tab hooks). +- Patch the `Application` schema with optional top-level `icon` and `iconDark` fields + (`{ ref: "" }`), sibling to `slug`, `name`, `manifest`, and `permissions` in + `lib/Settings/openbuilt_register.json`. Icons are admin-side metadata about the virtual + app, not part of the manifest blob the citizen developer designs. +- Render the icon in `ApplicationCard.vue` and remove the duplicate Live chip. +- Seed demo icon files on the Hello World Application. + +**Non-Goals:** + +- Per-version icons (ADR-002: icons live on `Application`, not on `ApplicationVersion`). +- Animated or raster icon formats (SVG only in v1; future change extends the fallback chain). +- Public (unauthenticated) icon fetching (icons require a valid NC session; the nav entry + itself is the visibility gate). +- Icon-search or icon-picker UI beyond file upload/remove (a picker over OR's icon library is + a separate UX change). +- Cache invalidation API for the 60-second HTTP cache (the TTL is short enough for the + existing use case; a purge endpoint can be added later if needed). +- Group management UI or any changes to how `permissions` principals are written. + +## Mixed-spec rationale + +The proposal declares `kind: code`. This is a code-dominant change. The only configuration +touch is a small schema patch in `lib/Settings/openbuilt_register.json` adding two top-level +properties to the `application` schema (≤15 LOC in the JSON file). The patch is tightly +coupled to the PHP icon-service code introduced by this spec and has no standalone value. +The bounded config touch satisfies ADR-032's thin-glue exception for mixed specs written as +`kind: code`. + +Because the icon fields live on the Application record (not inside the manifest blob), this +spec has **no upstream coupling** to `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json`. +No nextcloud-vue PR is required. + +## Declarative-vs-imperative decision + +Per ADR-031, the default is to use OR's declarative metadata engines for business logic. +Three behaviours in this spec require explicit decisions: + +**1. Resolve the icon URL for a given application slug** + +*Declarative option*: an `x-openregister-calculations` field on the `Application` schema +deriving the URL from `icon.ref` + a base-URL constant. + +*Decision*: **imperative** (`IconService`). The URL involves `IURLGenerator::linkToRouteAbsolute` +(cross-system call, not a string concatenation), an explicit fallback chain that reads from +the filesystem (`/img/app.svg`), and per-request HTTP-cache header generation. These are +outside OR's calculation vocabulary. + +*ADR-031 classification*: §Exceptions — "lifecycle guard / cross-system glue". Documented +here as the exception; no `x-openregister-calculations` block is added to the schema. + +**2. Determine nav visibility for a given user against an Application's permissions** + +*Declarative option*: an `x-openregister-relations` field + a role-derived view that OR +surfaces as a filtered list. + +*Decision*: **imperative** (`AppNavigationService` + per-entry closure). Nav-visibility must +run at boot time, must call `IGroupManager::getUserGroupIds()` inside the closure (per-request +NC API call), and must produce a callable closure for `INavigationManager::add()`. Producing a +closure for a Nextcloud-owned manager is not a stored-relation shape. + +*ADR-031 classification*: §Exceptions — "lifecycle guard / cross-system glue". Documented +here as the exception. + +**3. Update the published-app nav list when an Application transitions draft ↔ published** + +*Decision*: **declarative by default — nothing to implement**. The gating closure in each +nav entry reads the Application's `status` and `permissions` from OR per request. No listener, +no writeback, no service method needed. Draft apps' entries never satisfy the `status == +published` guard in the closure, so they are automatically invisible. This happens for free via +the existing `x-openregister-lifecycle` state machine. + +## Decisions + +### Decision 1 — Icon storage: OR files-attached-to-object (ADR-001) + +Icon bytes live on the Application record as OR-attached files. The manifest references them +by filename in `{ ref: "" }` shape. `IconService` resolves the filename to a file +stream via OR's attachment endpoint. This is the canonical ADR-001 pattern; no alternative +was considered for this spec. + +### Decision 2 — Fallback chain direction + +For the light icon: `icon.ref` → `/img/app.svg` (OpenBuilt's own branding icon). +For the dark icon: `iconDark.ref` → `icon.ref` → `/img/app-dark.svg` → +`/img/app.svg`. + +Rationale: an app that uploads only a light icon still gets a reasonable dark-mode fallback +via openbuilt's own dark icon. An app that uploads neither gets the OpenBuilt branding icon — +visually consistent for "not yet customised" apps. + +### Decision 3 — Nav entry registration location: `Application::boot()` + +Entries are registered in `boot()`, not in a listener, because `INavigationManager` binds at +boot time in NC. `AppNavigationService` is injected via the DI container (lazy, one +instantiation per request). The service queries OR for `status == published` applications; the +gating closure is per-entry and per-request. + +### Decision 4 — `group:*` sentinel in permissions + +`group:*` in any role array (`owners`, `editors`, or `viewers`) makes the nav entry visible to +all signed-in users. This is the canonical way to publish an app publicly without enumerating +all NC groups. Implementation: `AppNavigationService` checks for the literal string `group:*` +in the union of all three role arrays before running the group-intersection check. + +Rationale for a sentinel rather than a separate boolean field: keeps the data model backwards- +compatible; the `permissions` block can express the full principal-set in one field family. + +### Decision 5 — Detail-page icon section: reuse existing tab/section hooks + +The icon upload UI is added as a new tab on the Application detail page via the existing tab +mechanism in `SchemaDesigner.vue` (or the equivalent detail-page extension point). No new top- +level view is created. The uploader calls OR's standard files-attached-to-object endpoint +directly from the frontend; no new openbuilt-side upload endpoint is needed. + +### Decision 6 — Icon endpoint access: any signed-in user + +`GET /apps/openbuilt/icons/{slug}.svg` is accessible to any authenticated NC user. The +nav-entry visibility check is the gate for "should this user see the nav entry"; once they can +see the entry and click it, their browser fetches the icon. Restricting the icon endpoint to +RBAC-eligible users would break icon rendering for users who are eligible but whose group +memberships aren't resolved at icon-fetch time (e.g. in a navigation pre-load). The icon +itself carries no sensitive information. + +### Decision 7 — HTTP cache: `Cache-Control: public, max-age=60` + +60-second TTL is short enough that icon changes are visible within a minute but long enough to +avoid per-request OR calls for every active user. The "public" directive is intentional: the +icon URL path contains the slug (not a per-user path), so intermediate caches can share the +response safely for the 60-second window. + +## Seed Data + +Per ADR-001 §Consequences ("Export/clone/transfer of an app drags its assets along"), the +seed Hello World Application MUST have demo icon files attached at install time. The +`SeedHelloWorld` repair step is extended to attach two small SVG files to the seeded record +using OR's files-attached-to-object endpoint. + +**`app-icon.svg`** (light icon — Conduction cobalt `#4376FC` fill, 24×24 viewBox): + +```xml + + + +``` + +**`app-icon-dark.svg`** (dark / light-on-dark icon — `fill="#fff"` for dark Nextcloud themes): + +```xml + + + +``` + +The repair step attaches these files to the seeded Application via +`ObjectService::addFile(objectUuid, filename, content, mimeType)` (or the equivalent OR API +call). The attachment step is idempotent — guarded by checking whether a file with that name +already exists on the record. + +The repair step patches the seeded Application's **top-level** fields to: +```json +"icon": { "ref": "app-icon.svg" }, +"iconDark": { "ref": "app-icon-dark.svg" } +``` +(sibling to `slug`, `name`, `manifest`, `permissions` — not inside `manifest`). + +## Risks / Trade-offs + +- **OR files-attached-to-object API stability** → `IconService` wraps the OR call in a + `try/catch`; on any error it falls back to the filesystem icon rather than returning 5xx. +- **`INavigationManager` closure overhead on large installs** (many published apps × many + users) → `AppNavigationService` fetches the published-app list once per request and caches + it in a private property; closures are cheap PHP callables, not objects. +- **Nav entry ordering** — multiple virtual apps could collide on `order` → entries are sorted + alpha by `name` and placed at `order = 1000 + hash(slug) % 1000` to spread them without + a fixed sequential counter (which would require a writeback on every new publish). +- **CSP / MIME** — the icon endpoint sets `Content-Type: image/svg+xml` explicitly; without + this, Nextcloud's default `application/octet-stream` would block inline-SVG rendering in + nav icon `` tags under a strict CSP. + +## Migration Plan + +No schema migration is needed. `icon` and `iconDark` as top-level Application properties +are optional; all existing Application records are valid after the JSON patch. The schema is +patched via the existing `InitializeSettings` repair step which calls +`ConfigurationService::importFromApp('openbuilt')` — this is a re-import that is safe to +re-run against an installed instance. + +No database column changes. No route renaming. The `ApplicationCard.vue` Live chip removal is +a pure UI change with no API or data impact. + +Rollback: remove the three new PHP files, revert the four file edits, and re-run +`InitializeSettings`. Nav entries vanish on next reboot because they are registered at boot +time, not stored. + +## Open Questions + +None at spec-write time. Earlier OQ-1 (upstream `nextcloud-vue/src/schemas/app-manifest.schema.json` +coupling) was resolved by moving `icon` and `iconDark` to top-level Application fields +instead of putting them inside the manifest object — see Mixed-spec rationale and the +spec for `openbuilt-application-register` for the locked decision. diff --git a/openspec/changes/openbuilt-nextcloud-nav/proposal.md b/openspec/changes/openbuilt-nextcloud-nav/proposal.md new file mode 100644 index 00000000..abdc3d6a --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/proposal.md @@ -0,0 +1,74 @@ +--- +kind: code +depends_on: [] +--- + +## Why + +Published OpenBuilt virtual apps are invisible in the Nextcloud top bar — users must navigate +into the OpenBuilt shell, find the app, and click through to reach it, instead of reaching +it directly from the global navigation. Adding a per-app top-bar entry (gated by the existing +`permissions` RBAC block) makes virtual apps first-class citizens of the Nextcloud navigation +surface and lets an operator brand each entry with a per-app SVG icon stored per ADR-001. + +## What Changes + +- **NEW** `icon` and `iconDark` top-level fields on the `Application` schema (sibling to + `slug`, `name`, `manifest`, `permissions`) — each a `{ ref: "" }` pointer to an SVG + attached to the Application record via OpenRegister's files-attached-to-object mechanism + (ADR-001). Both optional; the PHP fallback chain fills in a default when absent. Icons live + outside the `manifest` object on purpose: they are admin-side metadata about the virtual app, + not part of the manifest blob the citizen developer designs and the runtime serves to + `CnAppRoot`. This keeps the change orthogonal to `@conduction/nextcloud-vue`'s app-manifest + schema (no upstream patch required). +- **NEW** Icon-serving endpoints `GET /apps/openbuilt/icons/{slug}.svg` and + `GET /apps/openbuilt/icons/{slug}-dark.svg` — thin controller backed by a service that reads + the attached SVG from OR, falls back through `iconDark → icon → /img/app-dark.svg → + /img/app.svg` for the dark variant and `icon → /img/app.svg` for the light variant. + 60-second HTTP cache. Any signed-in user may fetch. +- **NEW** Per-published-app Nextcloud navigation entries wired in `Application::boot()` via + `INavigationManager::add()`. Each entry carries a closure evaluated per request; the closure + resolves the signed-in user's UID + group memberships against the union of + `permissions.owners ∪ permissions.editors ∪ permissions.viewers`. `group:*` in any role + makes the entry visible to all signed-in users. Apps with empty permissions are hidden from + everyone except Nextcloud admins. +- **MODIFIED** `ApplicationCard.vue` — icon rendered in front of the app title via the new + icon-serving endpoint; redundant `Live` chip (line 30, wired to `app.currentVersion`) + removed because the status pill on line 23 already signals "Published". +- **NEW** Icon section on the Application detail page — SVG file pickers for `icon` and + `iconDark` using OR's files-attached-to-object endpoint, with a split light/dark live + preview. + +## Capabilities + +### New Capabilities + +- `app-icon-management`: Upload, preview, and remove per-app SVG icons (light + dark) stored + as OR-attached files on the Application record. Owns the icon-serving endpoints and the + detail-page icon section. +- `app-nav-entries`: Dynamic per-published-app top-bar navigation entries, gated by + `permissions` RBAC. Owns `Application::boot()` nav wiring and the `AppNavigationService` + that enumerates published apps and builds the gating closure. + +### Modified Capabilities + +- `openbuilt-application-register`: Schema patch — adds `icon` and `iconDark` as optional + top-level properties on the `Application` schema (sibling to `manifest`, not inside it). +- `openbuilt-runtime`: `ApplicationCard.vue` gains an icon and loses the redundant Live chip. + +## Impact + +- **New PHP** — `lib/Controller/IconController.php`, `lib/Service/IconService.php`, + `lib/Service/AppNavigationService.php`. +- **Modified PHP** — `lib/AppInfo/Application.php` (`boot()` wired), `appinfo/routes.php` + (two icon routes registered). +- **Modified JSON** — `lib/Settings/openbuilt_register.json` (two top-level fields added to + the `application` schema). +- **Modified Vue** — `src/components/ApplicationCard.vue` (icon + Live chip removal); + detail-page icon section added via existing tab/section extension points. +- **Nextcloud OCP interfaces** — `INavigationManager`, `IURLGenerator`, `IUserSession`, + `IGroupManager` (all from `\OCP\`). +- **OpenRegister dependency** — OR's files-attached-to-object endpoint (already required by + the app per ADR-001 / info.xml ``). No new external packages. +- **No breaking changes** — `icon`/`iconDark` are optional; existing Applications without + them fall back silently. The Live chip removal is UI-only and affects no API or data shape. diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/app-icon-management/spec.md b/openspec/changes/openbuilt-nextcloud-nav/specs/app-icon-management/spec.md new file mode 100644 index 00000000..636d3d19 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/specs/app-icon-management/spec.md @@ -0,0 +1,137 @@ +## ADDED Requirements + +### Requirement: REQ-OBICON-001 Icon fields on Application schema (top-level) + +The `Application` schema in `lib/Settings/openbuilt_register.json` SHALL declare two optional +top-level properties — `icon` and `iconDark` — as siblings to `slug`, `name`, `manifest`, +`version`, and `permissions`. Each SHALL be an object of shape `{ "ref": "" }` where +`` is the name of an SVG file attached to the Application record via OpenRegister's +files-attached-to-object mechanism (ADR-001). Both fields SHALL be optional; omitting them +SHALL NOT cause schema validation failure. + +Icons live outside the `manifest` object deliberately: they are openbuilt-side admin +metadata, not part of the manifest the citizen developer designs and the runtime serves +to `CnAppRoot`. This keeps the change orthogonal to `app-manifest.schema.json` and avoids +any upstream coupling with `@conduction/nextcloud-vue`. + +#### Scenario: Application with top-level icon fields validates successfully + +- **WHEN** a client saves an Application carrying + `"icon": { "ref": "app-icon.svg" }` and `"iconDark": { "ref": "app-icon-dark.svg" }` at + the top level (sibling to `manifest`) +- **THEN** OR accepts the object and persists the record without validation errors + +#### Scenario: Application without icon fields validates successfully + +- **WHEN** a client saves an Application that omits both `icon` and `iconDark` +- **THEN** OR accepts the object and persists the record without validation errors + +#### Scenario: Icon field with missing ref key is rejected + +- **WHEN** a client saves an Application carrying `"icon": {}` (empty object, missing the + `ref` key) +- **THEN** OR returns a 4xx validation error indicating `icon.ref` is required + +### Requirement: REQ-OBICON-002 Icon-serving endpoint (light) + +The system SHALL expose `GET /index.php/apps/openbuilt/icons/{slug}.svg` backed by +`IconController::iconLight`. The endpoint SHALL: + +1. Look up the published Application by slug via OR's ObjectService. +2. Read the `icon.ref` filename from the Application's top-level `icon` field; if present, + fetch the corresponding attached file from OR and return its bytes with + `Content-Type: image/svg+xml`. +3. If the icon ref is absent or the attached file cannot be retrieved, fall back to + OpenBuilt's own `/img/app.svg` filesystem asset. +4. Set `Cache-Control: public, max-age=60` on every successful response. +5. Require any valid NC session (`#[NoAdminRequired]`); return `401` when no session exists. + +#### Scenario: Endpoint returns the attached light icon + +- **WHEN** an authenticated user requests `/icons/hello-world.svg` +- **AND** the hello-world Application has `icon.ref = "app-icon.svg"` at the top level +- **AND** an OR-attached file named `"app-icon.svg"` exists on the Application record +- **THEN** the response is `200 image/svg+xml` with `Cache-Control: public, max-age=60` + and the body is the SVG bytes of the attached file + +#### Scenario: Endpoint falls back to filesystem icon when ref absent + +- **WHEN** an authenticated user requests `/icons/no-icon-app.svg` +- **AND** the no-icon-app Application has no `icon` field +- **THEN** the response is `200 image/svg+xml` and the body is the contents of + OpenBuilt's `/img/app.svg` + +#### Scenario: Unauthenticated request is rejected + +- **WHEN** a request arrives at `/icons/{slug}.svg` with no NC session cookie or token +- **THEN** the response is `401` + +### Requirement: REQ-OBICON-003 Icon-serving endpoint (dark) + +The system SHALL expose `GET /index.php/apps/openbuilt/icons/{slug}-dark.svg` backed by +`IconController::iconDark`. The endpoint SHALL apply the following fallback chain in order: + +1. `iconDark.ref` (top-level on the Application) → attached file on the Application record. +2. `icon.ref` (top-level on the Application) → attached file on the Application record. +3. OpenBuilt's own `/img/app-dark.svg` filesystem asset. +4. OpenBuilt's own `/img/app.svg` filesystem asset (final fallback). + +Cache and auth posture SHALL be identical to REQ-OBICON-002. + +#### Scenario: Endpoint returns the attached dark icon + +- **WHEN** an authenticated user requests `/icons/hello-world-dark.svg` +- **AND** the hello-world Application has `iconDark.ref = "app-icon-dark.svg"` at the top + level +- **AND** an OR-attached file named `"app-icon-dark.svg"` exists on the Application record +- **THEN** the response is `200 image/svg+xml` containing the dark SVG bytes + +#### Scenario: Dark icon falls back to light icon when dark ref absent + +- **WHEN** an authenticated user requests `/icons/light-only-app-dark.svg` +- **AND** the Application has `icon.ref = "app-icon.svg"` at the top level but no `iconDark` +- **THEN** the response is `200 image/svg+xml` containing the light icon SVG bytes + +#### Scenario: Falls back to filesystem dark icon when no icon refs present + +- **WHEN** an authenticated user requests `/icons/no-icon-app-dark.svg` +- **AND** the Application has neither `icon` nor `iconDark` +- **THEN** the response is `200 image/svg+xml` containing the contents of + OpenBuilt's `/img/app-dark.svg` + +### Requirement: REQ-OBICON-004 Icon section on Application detail page + +The Application detail page SHALL include an **Icon** section exposing: + +- A file picker / uploader for the light icon (`icon`) that calls OR's + files-attached-to-object endpoint to store the uploaded SVG, then patches the Application's + top-level `icon.ref` with the filename. +- A separate file picker / uploader for the dark icon (`iconDark`) with the same flow but + targeting the top-level `iconDark.ref`. +- A live preview area showing the uploaded light icon against a white background and the + uploaded dark icon against a dark (`#1c1c1e` or `var(--color-main-background)`) + background. +- A remove button for each slot that detaches the file from OR and clears the corresponding + top-level ref. + +The section SHALL NOT introduce a new openbuilt-side file-storage mechanism; all file I/O +goes through OR's existing files-attached-to-object endpoint (ADR-001). + +#### Scenario: User uploads a light icon + +- **WHEN** a user with editor or owner role selects an SVG file in the light-icon picker +- **THEN** the frontend POSTs the file to OR's attachment endpoint for the Application record +- **AND** patches the Application so the top-level `icon.ref` equals the uploaded filename +- **AND** the preview area renders the SVG against a light background + +#### Scenario: User removes the dark icon + +- **WHEN** a user clicks the remove button in the dark-icon slot +- **THEN** the frontend calls OR's delete-attachment endpoint for the `iconDark` file +- **AND** clears the top-level `iconDark.ref` from the Application +- **AND** the preview area falls back to showing the light icon in the dark-background slot + +#### Scenario: Non-SVG file is rejected client-side + +- **WHEN** a user attempts to upload a file with a non-`.svg` extension in either icon slot +- **THEN** the uploader displays an inline error message and does not submit the file to OR diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md b/openspec/changes/openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md new file mode 100644 index 00000000..61c29336 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/specs/app-nav-entries/spec.md @@ -0,0 +1,114 @@ +## ADDED Requirements + +### Requirement: REQ-OBNAV-001 Dynamic per-app top-bar entry for each published Application + +The system SHALL register one `INavigationManager` entry per published Application in +`Application::boot()` using `INavigationManager::add()` with a closure factory. Each entry +SHALL carry: + +- **id**: `openbuilt-app-{slug}` (e.g. `openbuilt-app-hello-world`). +- **name**: the Application's `name` field value. +- **href**: `/apps/openbuilt/{slug}` (the virtual-app runtime URL). +- **icon**: the URL produced by `IURLGenerator::linkToRouteAbsolute('openbuilt.icon.iconLight', + ['slug' => $slug])` — pointing at the icon-serving endpoint (REQ-OBICON-002). +- **order**: numeric value placing entries after openbuilt's own static entry, sorted + alpha-ascending by `name` within the virtual-app group. + +The entries SHALL be registered by `AppNavigationService`, which is lazily resolved from the +DI container inside the `boot()` method. + +#### Scenario: Published app appears in the Nextcloud top bar + +- **WHEN** the Nextcloud request cycle boots after an Application is transitioned to `published` +- **AND** the signed-in user satisfies the visibility predicate for that Application +- **THEN** `INavigationManager::getAll()` includes an entry with + `id = "openbuilt-app-{slug}"`, `href = "/apps/openbuilt/{slug}"`, and the app's name + +#### Scenario: Draft app does not appear in the top bar + +- **WHEN** an Application has `status: draft` +- **THEN** no nav entry with `id = "openbuilt-app-{slug}"` appears for any user + +#### Scenario: Archived app does not appear in the top bar + +- **WHEN** an Application has `status: archived` +- **THEN** no nav entry with `id = "openbuilt-app-{slug}"` appears for any user + +### Requirement: REQ-OBNAV-002 Nav entry gated by permissions RBAC + +Each nav entry's visibility closure SHALL resolve the signed-in user's UID and group +memberships via `IUserSession` and `IGroupManager` and return `true` only when the user +satisfies at least one of the following: + +1. The user's UID matches a `user:` entry in any of `permissions.owners`, + `permissions.editors`, or `permissions.viewers`. +2. Any of the user's group GIDs matches a `group:` entry or a bare group ID in any of + the three role arrays. +3. The literal `group:*` appears in any of the three role arrays. +4. The user is a Nextcloud admin. + +An Application whose `permissions.owners`, `permissions.editors`, and `permissions.viewers` +are all empty (or absent) SHALL NOT be visible to non-admin users, regardless of status. + +#### Scenario: Owner-role user sees the nav entry + +- **WHEN** user `alice` has UID `alice` and the Application has + `permissions.owners = ["user:alice"]` +- **THEN** alice's request cycle includes the nav entry for that Application + +#### Scenario: Group-eligible viewer sees the nav entry + +- **WHEN** user `bob` is a member of group `viewers-alpha` +- **AND** the Application has `permissions.viewers = ["group:viewers-alpha"]` +- **THEN** bob's request cycle includes the nav entry + +#### Scenario: Non-member cannot see the nav entry + +- **WHEN** user `eve` has no groups matching any role array +- **AND** the Application permissions contain no `group:*` wildcard +- **THEN** eve's request cycle does NOT include the nav entry for that Application + +#### Scenario: Nextcloud admin always sees the nav entry + +- **WHEN** the signed-in user is a Nextcloud admin +- **AND** the Application is published with empty permissions +- **THEN** the admin's request cycle includes the nav entry + +### Requirement: REQ-OBNAV-003 `group:*` wildcard makes entry visible to all signed-in users + +If the literal string `group:*` appears in any of `permissions.owners`, +`permissions.editors`, or `permissions.viewers` on a published Application, the system SHALL +make the nav entry visible to every signed-in Nextcloud user regardless of their group +memberships. The wildcard SHALL be detected before the group-intersection check runs. + +#### Scenario: `group:*` in owners makes entry universally visible + +- **WHEN** the Application has `permissions.owners = ["group:*"]` +- **AND** user `charlie` has no other group memberships +- **THEN** charlie's request cycle includes the nav entry + +#### Scenario: `group:*` in viewers makes entry universally visible + +- **WHEN** the Application has `permissions.viewers = ["group:*"]` +- **AND** an arbitrary signed-in user with no matching group memberships requests a page +- **THEN** that user's request cycle includes the nav entry + +### Requirement: REQ-OBNAV-004 Nav entry list is re-evaluated per request without writeback + +The set of published Applications SHALL be read from OR on each boot-cycle evaluation inside +`AppNavigationService`. No writeback to a separate nav-entry table or a cached register SHALL +occur. The update from draft to published (or published to archived) is detected automatically +because the service re-queries the `status == published` filter on every request boot cycle. + +#### Scenario: Transitioning an Application to archived removes its nav entry + +- **WHEN** an Application is transitioned from `published` to `archived` +- **THEN** on the next Nextcloud request boot cycle, the nav entry for that Application is + absent from `INavigationManager::getAll()` +- **AND** no separate listener or writeback step is required + +#### Scenario: Transitioning an Application to published adds its nav entry + +- **WHEN** an Application is transitioned from `draft` to `published` +- **THEN** on the next Nextcloud request boot cycle, the nav entry for that Application is + present in `INavigationManager::getAll()` for eligible users diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-application-register/spec.md b/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-application-register/spec.md new file mode 100644 index 00000000..d380ad24 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-application-register/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: REQ-OBA-002 Manifest blob is structurally valid + +The `manifest` property of every `Application` object SHALL validate against the canonical +app-manifest schema at `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` +(v1.4.0 or later). The system SHALL reject save operations whose `manifest` blob fails schema +validation, returning a 4xx response that identifies the failing property path. + +The `Application` schema SHALL additionally declare two optional top-level properties — +`icon` and `iconDark` — each of shape `{ "ref": "" }` referencing an OR-attached +SVG file on the Application record (per ADR-001). These properties live as siblings to +`slug`, `name`, `manifest`, `version`, and `permissions` on the `application` schema in +`lib/Settings/openbuilt_register.json`. They are deliberately outside the `manifest` object +because they are openbuilt-side admin metadata about the virtual app, not part of the +manifest blob the citizen developer designs and the runtime serves to `CnAppRoot` — so this +spec does not touch `app-manifest.schema.json` and carries no upstream coupling. + +#### Scenario: Application with valid top-level icon fields is accepted + +- **WHEN** a client saves an Application carrying + `"icon": { "ref": "app-icon.svg" }` and `"iconDark": { "ref": "app-icon-dark.svg" }` at the + top level (sibling to `manifest`) +- **THEN** OR persists the object and returns 2xx with no validation error + +#### Scenario: Application without icon fields is still accepted + +- **WHEN** a client saves an Application that omits both `icon` and `iconDark` +- **THEN** OR persists the object and returns 2xx — the fields are optional + +#### Scenario: Top-level `icon` field with wrong shape is rejected + +- **WHEN** a client saves an Application carrying `"icon": "inline-data-url"` + (a string instead of `{ ref: ... }`) +- **THEN** OR returns a 4xx validation error identifying `icon` as the failing path + +#### Scenario: `icon` inside the manifest object is ignored + +- **WHEN** a client saves an Application whose `manifest` blob contains an `icon` key + (e.g. `"manifest": { "version": "1.0.0", "menu": [...], "pages": [...], "icon": {...} }`) +- **THEN** OR accepts the save (the manifest's `additionalProperties` posture is opaque to + this spec), but the openbuilt icon-serving endpoint and nav-entry rendering SHALL ignore + the value — only the top-level `icon` / `iconDark` fields drive icon resolution + +#### Scenario: Application object is created via OR REST (unchanged from prior revision) + +- **WHEN** a client POSTs a payload to OR's REST endpoint for the `openbuilt/application` + namespace with a valid `manifest`, `slug`, `name`, `version`, and `status: draft` +- **THEN** OR persists the object, returns 201, and the returned object carries an OR-assigned + `uuid` and the submitted fields diff --git a/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md b/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md new file mode 100644 index 00000000..f5aa45b9 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/specs/openbuilt-runtime/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: REQ-OBR-007 ApplicationCard renders icon and omits redundant Live chip + +`ApplicationCard.vue` SHALL render the Application's icon in front of the app title using an +`` element whose `src` is the URL of the icon-serving light endpoint +(`/index.php/apps/openbuilt/icons/{slug}.svg`). The image SHALL carry a descriptive `alt` +attribute (the app's name). The component SHALL omit the `Live` chip that was previously +conditionally rendered on `app.currentVersion` (line 30 of the original file); the +lifecycle-status pill (line 23) already communicates "Published" state to the user and the +Live chip produces duplicate signalling. The `ob-app-card__chip--live` CSS rule and the +`v-if="app.currentVersion"` conditional SHALL be removed. + +#### Scenario: Published app card shows icon before the title + +- **WHEN** a user views the virtual apps index and a published Application has an icon + registered at the icon endpoint +- **THEN** each ApplicationCard renders an `` element with + `src="/index.php/apps/openbuilt/icons/{slug}.svg"` before the app name heading + +#### Scenario: Card icon falls back gracefully when endpoint returns an error + +- **WHEN** the icon endpoint returns a non-200 response (e.g. slug not found) +- **THEN** the `` element's `@error` handler replaces the src with a transparent 1×1 + placeholder or the OpenBuilt default icon path, so no broken-image icon appears in the card + +#### Scenario: Live chip is absent from all ApplicationCards + +- **WHEN** a user views the virtual apps index and one Application has `currentVersion` set +- **THEN** no element with class `ob-app-card__chip--live` or text "Live" is rendered on + any card — the Published status pill on the same card is the sole visual indicator + +#### Scenario: Card layout and existing fields are not disrupted + +- **WHEN** the icon is rendered in front of the title +- **THEN** the title heading, description paragraph, version chip, role chip, and slug chip + continue to render in their expected positions and the card's click navigation to + VirtualAppDetail is unaffected diff --git a/openspec/changes/openbuilt-nextcloud-nav/tasks.md b/openspec/changes/openbuilt-nextcloud-nav/tasks.md new file mode 100644 index 00000000..65961c58 --- /dev/null +++ b/openspec/changes/openbuilt-nextcloud-nav/tasks.md @@ -0,0 +1,162 @@ +## 1. Schema register patch — top-level icon fields on Application + +- [ ] 1.1 **Patch `application` schema in `lib/Settings/openbuilt_register.json`** + - spec_ref: REQ-OBICON-001, REQ-OBA-002 (modified) + - files: `lib/Settings/openbuilt_register.json` + - Add two optional **top-level** properties under + `components.schemas.Application.properties` (sibling to `slug`, `name`, `manifest`, + `version`, `permissions` — NOT inside `manifest`): + - `icon`: `{ "type": "object", "required": ["ref"], "properties": { "ref": { "type": "string" } }, "additionalProperties": false }` + - `iconDark`: same shape + - Do NOT create a new PHP service class for this; the patch is declarative JSON only. + - Do NOT touch `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` — icons + live on the Application record, not in the manifest blob, so the upstream schema is + intentionally not patched. + - acceptance_criteria: `composer check:strict` passes; an Application payload containing + top-level `"icon": { "ref": "app-icon.svg" }` validates against the patched schema; a + payload containing top-level `"icon": "string"` fails validation. + +- [ ] 1.2 **Re-import schema on upgrade via existing `InitializeSettings` repair step** + - spec_ref: REQ-OBICON-001 + - files: `lib/Repair/InitializeSettings.php` (verify it calls + `ConfigurationService::importFromApp('openbuilt')` — no change needed if already correct) + - acceptance_criteria: Running `occ maintenance:repair` after the patch causes the updated + top-level `icon` / `iconDark` schema properties to be visible via the OR schema API. + +## 2. Icon-serving PHP layer + +- [ ] 2.1 **Create `lib/Service/IconService.php`** + - spec_ref: REQ-OBICON-002, REQ-OBICON-003 + - files: `lib/Service/IconService.php` (NEW) + - Methods: + - `getIconStream(string $slug, bool $dark): array{ stream: resource|null, mimeType: string }` — implements the fallback chain documented in design.md Decision 2. + - Reads attached files from OR via `ObjectService` (or the OR files-attached-to-object endpoint); on OR failure falls back to the filesystem icons at `OC::$SERVERROOT . "/custom_apps/openbuilt/img/app{$suffix}.svg"`. + - Must carry SPDX + EUPL-1.2 docblock per project standards. + - acceptance_criteria: PHPUnit: mock ObjectService to return a stream; assert correct bytes returned. Mock ObjectService to throw; assert fallback stream returned. `composer check:strict` passes. + +- [ ] 2.2 **Create `lib/Controller/IconController.php`** + - spec_ref: REQ-OBICON-002, REQ-OBICON-003 + - files: `lib/Controller/IconController.php` (NEW) + - Methods: + - `iconLight(string $slug): Response` — calls `IconService::getIconStream($slug, false)`, sets `Content-Type: image/svg+xml`, `Cache-Control: public, max-age=60`, returns a `DataDisplayResponse` or equivalent. + - `iconDark(string $slug): Response` — same but `$dark = true`. + - Both methods carry `#[NoAdminRequired]`. + - acceptance_criteria: PHPUnit: mock IconService; assert 200 + correct headers. `composer check:strict` passes. + +- [ ] 2.3 **Register icon routes in `appinfo/routes.php`** + - spec_ref: REQ-OBICON-002, REQ-OBICON-003 + - files: `appinfo/routes.php` + - Add before the SPA catch-all, after the exports routes: + ```php + ['name' => 'icon#iconLight', 'url' => '/icons/{slug}.svg', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + ['name' => 'icon#iconDark', 'url' => '/icons/{slug}-dark.svg', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + ``` + - acceptance_criteria: Newman or Playwright network-request capture confirms both URLs resolve to `IconController`; dark route does not shadow the light route. + +## 3. Navigation wiring + +- [ ] 3.1 **Create `lib/Service/AppNavigationService.php`** + - spec_ref: REQ-OBNAV-001, REQ-OBNAV-002, REQ-OBNAV-003, REQ-OBNAV-004 + - files: `lib/Service/AppNavigationService.php` (NEW) + - Responsibilities: + - `registerNavEntries(INavigationManager $nav): void` — queries OR for all `status == published` Applications; for each, calls `$nav->add()` with a closure factory. + - Gating closure: resolves the session user's UID + group memberships via `IUserSession` + `IGroupManager`; checks `group:*` sentinel first; then intersects uid/group principal sets; falls through to NC-admin bypass last. + - Order: `1000 + (abs(crc32($slug)) % 1000)` — deterministic, alpha-spread, after openbuilt's own static entry. + - acceptance_criteria: PHPUnit: mock ObjectService to return one published + one draft Application; assert only the published entry is registered; assert gating closure returns `true` for matching user and `false` for non-matching user; `composer check:strict` passes. + +- [ ] 3.2 **Wire `AppNavigationService` inside `Application::boot()`** + - spec_ref: REQ-OBNAV-001 + - files: `lib/AppInfo/Application.php` + - Inside the currently-empty `boot()` method, resolve `AppNavigationService` lazily and call `registerNavEntries()`: + ```php + public function boot(IBootContext $context): void + { + $container = $context->getAppContainer(); + $container->get(AppNavigationService::class) + ->registerNavEntries($container->get(INavigationManager::class)); + } + ``` + - acceptance_criteria: After install (or OPcache flush), Nextcloud's top bar includes an entry for `hello-world` when signed in as `admin`; entry is absent when signed in as a user with no permissions. + +## 4. Frontend — ApplicationCard update + +- [ ] 4.1 **Add icon `` before the title in `ApplicationCard.vue`** + - spec_ref: REQ-OBR-007 + - files: `src/components/ApplicationCard.vue` + - In the `ob-app-card__head` div, add before `

`: + ```html + + ``` + - Add `onIconError(e)` method: `e.target.src = '/apps/openbuilt/img/app.svg'`. + - Add `.ob-app-card__icon { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }` to ` diff --git a/src/customComponents.js b/src/customComponents.js index 5821d331..b533aaca 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -26,6 +26,7 @@ import ApplicationCard from './components/ApplicationCard.vue' import ApplicationManifestTab from './components/tabs/ApplicationManifestTab.vue' import ApplicationVersionsTab from './components/tabs/ApplicationVersionsTab.vue' import ApplicationDiffTab from './components/tabs/ApplicationDiffTab.vue' +import ApplicationIconTab from './components/tabs/ApplicationIconTab.vue' import ApplicationDetailActions from './components/ApplicationDetailActions.vue' // Tooling pages that stay `type: "custom"`. import SchemaDesignerView from './views/SchemaDesigner.vue' @@ -44,10 +45,11 @@ export default { ApplicationCard, // VirtualAppDetail (`type: detail`) sidebar tabs: raw-JSON manifest // editor (the visual designer lives at /builder/:slug/pages), version - // history (+ rollback), and the manifest diff. + // history (+ rollback), the manifest diff, and icon upload/preview. ApplicationManifestTab, ApplicationVersionsTab, ApplicationDiffTab, + ApplicationIconTab, // VirtualAppDetail actions bar — Publish (OR lifecycle transition), // Manage permissions (PermissionsModal, ADR-004 modal isolation), // Design pages, Open virtual app. diff --git a/src/dialogs/IconUploadSection.vue b/src/dialogs/IconUploadSection.vue new file mode 100644 index 00000000..006534cc --- /dev/null +++ b/src/dialogs/IconUploadSection.vue @@ -0,0 +1,395 @@ + + + + + + diff --git a/src/manifest.json b/src/manifest.json index 58194578..fdf39371 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -88,6 +88,7 @@ { "id": "manifest", "label": "Manifest", "icon": "icon-edit", "component": "ApplicationManifestTab", "order": 20 }, { "id": "history", "label": "Version history", "icon": "icon-history", "component": "ApplicationVersionsTab", "order": 30 }, { "id": "diff", "label": "Diff", "icon": "icon-files", "component": "ApplicationDiffTab", "order": 40 }, + { "id": "icons", "label": "Icons", "icon": "icon-image", "component": "ApplicationIconTab", "order": 50 }, { "id": "audit", "label": "Audit trail", "icon": "icon-history", "widgets": [{ "type": "audit-trail" }], "order": 90 } ] } diff --git a/tests/Unit/Controller/IconControllerTest.php b/tests/Unit/Controller/IconControllerTest.php new file mode 100644 index 00000000..668d6c74 --- /dev/null +++ b/tests/Unit/Controller/IconControllerTest.php @@ -0,0 +1,247 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Controller; + +use OCA\OpenBuilt\Controller\IconController; +use OCA\OpenBuilt\Service\IconService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\StreamResponse; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Tests for {@see IconController}. + */ +class IconControllerTest extends TestCase +{ + /** + * Mock request. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Mock icon service. + * + * @var IconService&MockObject + */ + private IconService&MockObject $iconService; + + /** + * Mock user session. + * + * @var IUserSession&MockObject + */ + private IUserSession&MockObject $userSession; + + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Controller under test. + */ + private IconController $controller; + + /** + * Build mocks and the SUT. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->iconService = $this->createMock(IconService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + // Default: authenticated user. + $user = $this->createMock(IUser::class); + $this->userSession->method('getUser')->willReturn($user); + + $this->controller = new IconController( + $this->request, + $this->iconService, + $this->userSession, + $this->logger + ); + }//end setUp() + + // ------------------------------------------------------------------------- + // iconLight — happy path + // ------------------------------------------------------------------------- + + /** + * iconLight returns 200, Content-Type: image/svg+xml, Cache-Control: public, max-age=60. + * + * @return void + */ + public function testIconLightReturnsCorrectHeaders(): void + { + $stream = fopen(filename: 'php://memory', mode: 'r+'); + fwrite($stream, ''); + rewind($stream); + + $this->iconService + ->expects($this->once()) + ->method('getIconStream') + ->with('hello-world', false) + ->willReturn(['stream' => $stream, 'mimeType' => 'image/svg+xml']); + + $response = $this->controller->iconLight('hello-world'); + + $this->assertInstanceOf(StreamResponse::class, $response); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + + // getHeaders() requires OC::$server in unit context; read via Reflection. + $headersProp = new \ReflectionProperty(\OCP\AppFramework\Http\Response::class, 'headers'); + $headersProp->setAccessible(true); + $headers = $headersProp->getValue($response); + + $this->assertSame('image/svg+xml', $headers['Content-Type']); + $this->assertSame('public, max-age=60', $headers['Cache-Control']); + + fclose($stream); + }//end testIconLightReturnsCorrectHeaders() + + // ------------------------------------------------------------------------- + // iconDark — happy path + // ------------------------------------------------------------------------- + + /** + * iconDark returns 200, Content-Type: image/svg+xml, Cache-Control: public, max-age=60. + * + * @return void + */ + public function testIconDarkReturnsCorrectHeaders(): void + { + $stream = fopen(filename: 'php://memory', mode: 'r+'); + fwrite($stream, ''); + rewind($stream); + + $this->iconService + ->expects($this->once()) + ->method('getIconStream') + ->with('hello-world', true) + ->willReturn(['stream' => $stream, 'mimeType' => 'image/svg+xml']); + + $response = $this->controller->iconDark('hello-world'); + + $this->assertInstanceOf(StreamResponse::class, $response); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + + // getHeaders() requires OC::$server in unit context; read via Reflection. + $headersProp = new \ReflectionProperty(\OCP\AppFramework\Http\Response::class, 'headers'); + $headersProp->setAccessible(true); + $headers = $headersProp->getValue($response); + + $this->assertSame('image/svg+xml', $headers['Content-Type']); + $this->assertSame('public, max-age=60', $headers['Cache-Control']); + + fclose($stream); + }//end testIconDarkReturnsCorrectHeaders() + + // ------------------------------------------------------------------------- + // null stream → 404 + // ------------------------------------------------------------------------- + + /** + * When IconService returns a null stream, the controller returns 404. + * + * @return void + */ + public function testIconLightReturns404WhenStreamIsNull(): void + { + $this->iconService + ->method('getIconStream') + ->willReturn(['stream' => null, 'mimeType' => 'image/svg+xml']); + + $response = $this->controller->iconLight('unknown-app'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + }//end testIconLightReturns404WhenStreamIsNull() + + // ------------------------------------------------------------------------- + // No session → 401 + // ------------------------------------------------------------------------- + + /** + * Unauthenticated request returns 401. + * + * @return void + */ + public function testIconLightReturns401WhenNoSession(): void + { + // Build a fresh controller with a session that returns null user. + $unauthSession = $this->createMock(IUserSession::class); + $unauthSession->method('getUser')->willReturn(null); + + $controller = new IconController( + $this->request, + $this->iconService, + $unauthSession, + $this->logger + ); + + $this->iconService->expects($this->never())->method('getIconStream'); + + $response = $controller->iconLight('hello-world'); + + $this->assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + }//end testIconLightReturns401WhenNoSession() + + // ------------------------------------------------------------------------- + // IconService throws → 500 + // ------------------------------------------------------------------------- + + /** + * When IconService throws, the controller returns 500 and logs the error. + * + * @return void + */ + public function testIconLightReturns500OnException(): void + { + $this->iconService + ->method('getIconStream') + ->willThrowException(new \RuntimeException('unexpected')); + + $this->logger->expects($this->once())->method('error'); + + $response = $this->controller->iconLight('hello-world'); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + }//end testIconLightReturns500OnException() +}//end class diff --git a/tests/Unit/Service/AppNavigationServiceTest.php b/tests/Unit/Service/AppNavigationServiceTest.php new file mode 100644 index 00000000..46d94546 --- /dev/null +++ b/tests/Unit/Service/AppNavigationServiceTest.php @@ -0,0 +1,450 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Service; + +use OCA\OpenBuilt\Service\AppNavigationService; +use OCA\OpenRegister\Service\ObjectService; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\INavigationManager; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Tests for {@see AppNavigationService}. + */ +class AppNavigationServiceTest extends TestCase +{ + /** + * Mock ObjectService. + * + * @var ObjectService&MockObject + */ + private ObjectService&MockObject $objectService; + + /** + * Mock URL generator. + * + * @var IURLGenerator&MockObject + */ + private IURLGenerator&MockObject $urlGenerator; + + /** + * Mock user session. + * + * @var IUserSession&MockObject + */ + private IUserSession&MockObject $userSession; + + /** + * Mock group manager. + * + * @var IGroupManager&MockObject + */ + private IGroupManager&MockObject $groupManager; + + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Service under test. + */ + private AppNavigationService $service; + + /** + * Build shared mocks + SUT. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->objectService = $this->createMock(ObjectService::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->urlGenerator + ->method('linkToRouteAbsolute') + ->willReturnCallback(fn ($route, $params) => '/icon/'.$params['slug'].'.svg'); + + $this->service = new AppNavigationService( + $this->objectService, + $this->urlGenerator, + $this->userSession, + $this->groupManager, + $this->logger + ); + }//end setUp() + + // ------------------------------------------------------------------------- + // registerNavEntries — published-only filter + // ------------------------------------------------------------------------- + + /** + * Only published Applications produce nav entries; draft/archived excluded + * by the status == published filter in the OR query. + * + * @return void + */ + public function testRegisterNavEntriesRegistersPublishedAppsOnly(): void + { + $publishedApp = [ + 'slug' => 'hello-world', + 'name' => 'Hello World', + 'status' => 'published', + 'permissions' => ['owners' => ['group:*'], 'editors' => [], 'viewers' => []], + ]; + + // ObjectService returns ONLY published apps (filter is applied by + // the service itself in the findAll config). We simulate that here. + $this->objectService + ->expects($this->once()) + ->method('findAll') + ->willReturn([$publishedApp]); + + $registeredCallables = []; + $nav = $this->createMock(INavigationManager::class); + $nav->expects($this->once()) + ->method('add') + ->willReturnCallback(function ($callable) use (&$registeredCallables): void { + $registeredCallables[] = $callable; + }); + + $this->service->registerNavEntries($nav); + + $this->assertCount(1, $registeredCallables); + }//end testRegisterNavEntriesRegistersPublishedAppsOnly() + + // ------------------------------------------------------------------------- + // registerNavEntries — empty result → no entries + // ------------------------------------------------------------------------- + + /** + * When OR returns no published Applications, no nav entries are registered. + * + * @return void + */ + public function testRegisterNavEntriesNoneWhenNoPublishedApps(): void + { + $this->objectService + ->method('findAll') + ->willReturn([]); + + $nav = $this->createMock(INavigationManager::class); + $nav->expects($this->never())->method('add'); + + $this->service->registerNavEntries($nav); + }//end testRegisterNavEntriesNoneWhenNoPublishedApps() + + // ------------------------------------------------------------------------- + // isVisibleForCurrentUser — group:* wildcard + // ------------------------------------------------------------------------- + + /** + * When group:* is in owners, every signed-in user sees the entry. + * + * @return void + */ + public function testIsVisibleForCurrentUserWithWildcardOwner(): void + { + $permissions = ['owners' => ['group:*'], 'editors' => [], 'viewers' => []]; + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('charlie'); + + $this->userSession->method('getUser')->willReturn($user); + + // groupManager should NOT be consulted — wildcard short-circuits. + $this->groupManager->expects($this->never())->method('getUserGroupIds'); + + $result = $this->service->isVisibleForCurrentUser( + $permissions, + $this->userSession, + $this->groupManager + ); + + $this->assertTrue($result); + }//end testIsVisibleForCurrentUserWithWildcardOwner() + + /** + * When group:* is in viewers, every signed-in user sees the entry. + * + * @return void + */ + public function testIsVisibleForCurrentUserWithWildcardViewer(): void + { + $permissions = ['owners' => [], 'editors' => [], 'viewers' => ['group:*']]; + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('bob'); + + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->expects($this->never())->method('getUserGroupIds'); + + $result = $this->service->isVisibleForCurrentUser( + $permissions, + $this->userSession, + $this->groupManager + ); + + $this->assertTrue($result); + }//end testIsVisibleForCurrentUserWithWildcardViewer() + + // ------------------------------------------------------------------------- + // isVisibleForCurrentUser — user: match + // ------------------------------------------------------------------------- + + /** + * User matches an explicit user: entry in owners. + * + * @return void + */ + public function testIsVisibleForCurrentUserWithDirectUidMatch(): void + { + $permissions = [ + 'owners' => ['user:alice'], + 'editors' => [], + 'viewers' => [], + ]; + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->userSession->method('getUser')->willReturn($user); + + $result = $this->service->isVisibleForCurrentUser( + $permissions, + $this->userSession, + $this->groupManager + ); + + $this->assertTrue($result); + }//end testIsVisibleForCurrentUserWithDirectUidMatch() + + // ------------------------------------------------------------------------- + // isVisibleForCurrentUser — group: match + // ------------------------------------------------------------------------- + + /** + * User is a member of a group that matches group: in viewers. + * + * @return void + */ + public function testIsVisibleForCurrentUserWithGroupMatch(): void + { + $permissions = [ + 'owners' => [], + 'editors' => [], + 'viewers' => ['group:viewers-alpha'], + ]; + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('bob'); + + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager + ->method('getUserGroupIds') + ->willReturn(['viewers-alpha', 'all-users']); + $this->groupManager->method('isAdmin')->willReturn(false); + + $result = $this->service->isVisibleForCurrentUser( + $permissions, + $this->userSession, + $this->groupManager + ); + + $this->assertTrue($result); + }//end testIsVisibleForCurrentUserWithGroupMatch() + + // ------------------------------------------------------------------------- + // isVisibleForCurrentUser — non-member, no wildcard → false + // ------------------------------------------------------------------------- + + /** + * User has no matching UID, group, or wildcard — not visible. + * + * @return void + */ + public function testIsVisibleForCurrentUserReturnsFalseForNonMember(): void + { + $permissions = [ + 'owners' => ['user:alice'], + 'editors' => [], + 'viewers' => ['group:viewers-alpha'], + ]; + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('eve'); + + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('getUserGroupIds')->willReturn(['other-group']); + $this->groupManager->method('isAdmin')->willReturn(false); + + $result = $this->service->isVisibleForCurrentUser( + $permissions, + $this->userSession, + $this->groupManager + ); + + $this->assertFalse($result); + }//end testIsVisibleForCurrentUserReturnsFalseForNonMember() + + // ------------------------------------------------------------------------- + // isVisibleForCurrentUser — Nextcloud admin bypass + // ------------------------------------------------------------------------- + + /** + * Nextcloud admins always see published entries regardless of permissions. + * + * @return void + */ + public function testIsVisibleForCurrentUserAdminAlwaysSees(): void + { + $permissions = ['owners' => [], 'editors' => [], 'viewers' => []]; + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin'); + + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->groupManager->method('isAdmin')->with('admin')->willReturn(true); + + $result = $this->service->isVisibleForCurrentUser( + $permissions, + $this->userSession, + $this->groupManager + ); + + $this->assertTrue($result); + }//end testIsVisibleForCurrentUserAdminAlwaysSees() + + // ------------------------------------------------------------------------- + // isVisibleForCurrentUser — unauthenticated session → false + // ------------------------------------------------------------------------- + + /** + * No session user → not visible. + * + * @return void + */ + public function testIsVisibleForCurrentUserReturnsFalseWhenNoSession(): void + { + $this->userSession->method('getUser')->willReturn(null); + + $result = $this->service->isVisibleForCurrentUser( + ['owners' => ['group:*']], + $this->userSession, + $this->groupManager + ); + + $this->assertFalse($result); + }//end testIsVisibleForCurrentUserReturnsFalseWhenNoSession() + + // ------------------------------------------------------------------------- + // registerNavEntries — OR failure → logs warning, registers no entries + // ------------------------------------------------------------------------- + + /** + * When OR throws, registerNavEntries catches it, logs, and registers nothing. + * + * @return void + */ + public function testRegisterNavEntriesHandlesOrFailureGracefully(): void + { + $this->objectService + ->method('findAll') + ->willThrowException(new \RuntimeException('OR offline')); + + $this->logger->expects($this->once())->method('warning'); + + $nav = $this->createMock(INavigationManager::class); + $nav->expects($this->never())->method('add'); + + $this->service->registerNavEntries($nav); + }//end testRegisterNavEntriesHandlesOrFailureGracefully() + + // ------------------------------------------------------------------------- + // Closure shape — published entry returns expected array keys + // ------------------------------------------------------------------------- + + /** + * The closure registered for a published app returns the expected shape + * including id, name, href, icon, order, and enabled. + * + * @return void + */ + public function testRegisteredClosureReturnsExpectedShape(): void + { + $publishedApp = [ + 'slug' => 'hello-world', + 'name' => 'Hello World', + 'status' => 'published', + 'permissions' => ['owners' => ['group:*'], 'editors' => [], 'viewers' => []], + ]; + + $this->objectService + ->method('findAll') + ->willReturn([$publishedApp]); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin'); + + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->groupManager->method('isAdmin')->willReturn(false); + + $registeredClosures = []; + $nav = $this->createMock(INavigationManager::class); + $nav->method('add') + ->willReturnCallback(function ($callable) use (&$registeredClosures): void { + $registeredClosures[] = $callable; + }); + + $this->service->registerNavEntries($nav); + + $this->assertCount(1, $registeredClosures); + + $entry = ($registeredClosures[0])(); + + $this->assertSame('openbuilt-app-hello-world', $entry['id']); + $this->assertSame('Hello World', $entry['name']); + $this->assertStringContainsString('/apps/openbuilt/hello-world', $entry['href']); + $this->assertArrayHasKey('order', $entry); + $this->assertArrayHasKey('enabled', $entry); + }//end testRegisteredClosureReturnsExpectedShape() +}//end class diff --git a/tests/Unit/Service/IconServiceTest.php b/tests/Unit/Service/IconServiceTest.php new file mode 100644 index 00000000..0c5cd686 --- /dev/null +++ b/tests/Unit/Service/IconServiceTest.php @@ -0,0 +1,310 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Service; + +use OCA\OpenBuilt\Service\IconService; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\ObjectService; +use OCP\Files\File; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Tests for {@see IconService}. + */ +class IconServiceTest extends TestCase +{ + /** + * Mock ObjectService. + * + * @var ObjectService&MockObject + */ + private ObjectService&MockObject $objectService; + + /** + * Mock FileService. + * + * @var FileService&MockObject + */ + private FileService&MockObject $fileService; + + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Service under test. + */ + private IconService $service; + + /** + * Build the dependency mocks + SUT. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->objectService = $this->createMock(ObjectService::class); + $this->fileService = $this->createMock(FileService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + // Pass a temp dir as serverRoot so tests don't depend on \OC::$SERVERROOT. + $this->service = new IconService( + $this->objectService, + $this->fileService, + $this->logger, + sys_get_temp_dir() + ); + }//end setUp() + + // ------------------------------------------------------------------------- + // Happy path — light icon + // ------------------------------------------------------------------------- + + /** + * Light icon: when icon.ref resolves to an OR-attached file, return its stream. + * + * @return void + */ + public function testGetIconStreamLightHappyPath(): void + { + $svgContent = ''; + + $application = [ + 'slug' => 'hello-world', + 'uuid' => 'app-uuid-1', + 'icon' => ['ref' => 'app-icon.svg'], + ]; + + $this->objectService + ->expects($this->once()) + ->method('findAll') + ->willReturn([$application]); + + $fileMock = $this->createMock(File::class); + $fileMock->method('getContent')->willReturn($svgContent); + + $this->fileService + ->expects($this->once()) + ->method('getFile') + ->with('app-uuid-1', 'app-icon.svg') + ->willReturn($fileMock); + + $result = $this->service->getIconStream(slug: 'hello-world', dark: false); + + $this->assertSame('image/svg+xml', $result['mimeType']); + $this->assertIsResource($result['stream']); + + $body = stream_get_contents($result['stream']); + $this->assertSame($svgContent, $body); + + fclose($result['stream']); + }//end testGetIconStreamLightHappyPath() + + // ------------------------------------------------------------------------- + // Happy path — dark icon (iconDark.ref present) + // ------------------------------------------------------------------------- + + /** + * Dark icon: when iconDark.ref resolves to an attached file, return it. + * + * @return void + */ + public function testGetIconStreamDarkHappyPath(): void + { + $darkSvg = ''; + + $application = [ + 'slug' => 'hello-world', + 'uuid' => 'app-uuid-1', + 'icon' => ['ref' => 'app-icon.svg'], + 'iconDark' => ['ref' => 'app-icon-dark.svg'], + ]; + + $this->objectService + ->method('findAll') + ->willReturn([$application]); + + $fileMock = $this->createMock(File::class); + $fileMock->method('getContent')->willReturn($darkSvg); + + $this->fileService + ->expects($this->once()) + ->method('getFile') + ->with('app-uuid-1', 'app-icon-dark.svg') + ->willReturn($fileMock); + + $result = $this->service->getIconStream(slug: 'hello-world', dark: true); + + $this->assertSame('image/svg+xml', $result['mimeType']); + $this->assertIsResource($result['stream']); + + $body = stream_get_contents($result['stream']); + $this->assertSame($darkSvg, $body); + + fclose($result['stream']); + }//end testGetIconStreamDarkHappyPath() + + // ------------------------------------------------------------------------- + // Dark chain: iconDark absent → falls through to icon.ref + // ------------------------------------------------------------------------- + + /** + * Dark icon: no iconDark field → falls back to icon.ref. + * + * @return void + */ + public function testGetIconStreamDarkFallsBackToLightRef(): void + { + $lightSvg = ''; + + $application = [ + 'slug' => 'hello-world', + 'uuid' => 'app-uuid-1', + 'icon' => ['ref' => 'app-icon.svg'], + // intentionally no iconDark + ]; + + $this->objectService + ->method('findAll') + ->willReturn([$application]); + + $fileMock = $this->createMock(File::class); + $fileMock->method('getContent')->willReturn($lightSvg); + + $this->fileService + ->expects($this->once()) + ->method('getFile') + ->with('app-uuid-1', 'app-icon.svg') + ->willReturn($fileMock); + + $result = $this->service->getIconStream(slug: 'hello-world', dark: true); + + $this->assertIsResource($result['stream']); + $body = stream_get_contents($result['stream']); + $this->assertSame($lightSvg, $body); + + fclose($result['stream']); + }//end testGetIconStreamDarkFallsBackToLightRef() + + // ------------------------------------------------------------------------- + // OR failure → filesystem fallback (stream may be null if no /img file) + // ------------------------------------------------------------------------- + + /** + * When ObjectService throws, the method falls back gracefully (returns + * null stream and the correct MIME type rather than propagating the error). + * + * @return void + */ + public function testGetIconStreamFallsBackWhenOrThrows(): void + { + $this->objectService + ->method('findAll') + ->willThrowException(new \RuntimeException('OR unavailable')); + + $this->logger->expects($this->once())->method('warning'); + + // The FileService should not be called when OR fails. + $this->fileService->expects($this->never())->method('getFile'); + + $result = $this->service->getIconStream(slug: 'hello-world', dark: false); + + // Stream may be null when no /img/app.svg exists in the test context. + $this->assertSame('image/svg+xml', $result['mimeType']); + if ($result['stream'] !== null) { + fclose($result['stream']); + } + }//end testGetIconStreamFallsBackWhenOrThrows() + + // ------------------------------------------------------------------------- + // Unknown slug — no Application found → filesystem fallback + // ------------------------------------------------------------------------- + + /** + * Unknown slug: ObjectService returns empty list → fallback stream path. + * + * @return void + */ + public function testGetIconStreamUnknownSlugFallsBack(): void + { + $this->objectService + ->method('findAll') + ->willReturn([]); + + $this->fileService->expects($this->never())->method('getFile'); + + $result = $this->service->getIconStream(slug: 'no-such-app', dark: false); + + $this->assertSame('image/svg+xml', $result['mimeType']); + // Stream may be null in the test context (no /img/ dir present). + if ($result['stream'] !== null) { + fclose($result['stream']); + } + }//end testGetIconStreamUnknownSlugFallsBack() + + // ------------------------------------------------------------------------- + // FileService throws → falls back to filesystem + // ------------------------------------------------------------------------- + + /** + * When getFile throws (e.g. OR file not accessible), fall through to + * the next step in the fallback chain. + * + * @return void + */ + public function testGetIconStreamFallsBackWhenFileServiceThrows(): void + { + $application = [ + 'slug' => 'hello-world', + 'uuid' => 'app-uuid-1', + 'icon' => ['ref' => 'app-icon.svg'], + ]; + + $this->objectService + ->method('findAll') + ->willReturn([$application]); + + $this->fileService + ->method('getFile') + ->willThrowException(new \RuntimeException('file not found')); + + $this->logger->expects($this->once())->method('warning'); + + $result = $this->service->getIconStream(slug: 'hello-world', dark: false); + + $this->assertSame('image/svg+xml', $result['mimeType']); + if ($result['stream'] !== null) { + fclose($result['stream']); + } + }//end testGetIconStreamFallsBackWhenFileServiceThrows() +}//end class diff --git a/tests/unit/Controller/SettingsControllerTest.php b/tests/unit/Controller/SettingsControllerTest.php index 4ac927f1..863e1181 100644 --- a/tests/unit/Controller/SettingsControllerTest.php +++ b/tests/unit/Controller/SettingsControllerTest.php @@ -3,11 +3,14 @@ /** * Unit tests for SettingsController. * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * * @category Test * @package OCA\OpenBuilt\Tests\Unit\Controller * - * @author Conduction Development Team - * @copyright 2024 Conduction B.V. + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * @version GIT: @@ -21,8 +24,11 @@ use OCA\OpenBuilt\Controller\SettingsController; use OCA\OpenBuilt\Service\SettingsService; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -53,6 +59,13 @@ class SettingsControllerTest extends TestCase */ private SettingsService&MockObject $settingsService; + /** + * Mock user session. + * + * @var IUserSession&MockObject + */ + private IUserSession&MockObject $userSession; + /** * Set up test fixtures. * @@ -64,10 +77,16 @@ protected function setUp(): void $this->request = $this->createMock(IRequest::class); $this->settingsService = $this->createMock(SettingsService::class); + $this->userSession = $this->createMock(IUserSession::class); + + // Default: authenticated user. + $user = $this->createMock(IUser::class); + $this->userSession->method('getUser')->willReturn($user); $this->controller = new SettingsController( - request: $this->request, - settingsService: $this->settingsService, + $this->request, + $this->settingsService, + $this->userSession, ); }//end setUp() @@ -146,4 +165,28 @@ public function testLoadReturnsConfigurationResult(): void self::assertTrue($result->getData()['success']); }//end testLoadReturnsConfigurationResult() + + /** + * Test that unauthenticated requests return 401. + * + * @return void + */ + public function testIndexReturns401WhenNoSession(): void + { + $unauthSession = $this->createMock(IUserSession::class); + $unauthSession->method('getUser')->willReturn(null); + + $controller = new SettingsController( + $this->request, + $this->settingsService, + $unauthSession, + ); + + $this->settingsService->expects($this->never())->method('getSettings'); + + $result = $controller->index(); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + + }//end testIndexReturns401WhenNoSession() }//end class