From 46d095ff21abf0d35db7a5e8c85ecde55e3198c9 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 28 Nov 2023 21:08:46 +0300 Subject: [PATCH] initial draft Signed-off-by: Alexander Piskun --- appinfo/routes.php | 20 +- lib/AppInfo/Application.php | 10 + lib/Controller/MenuEntryController.php | 318 ++++++++++++++++++ lib/Db/UI/InitialState.php | 65 ++++ lib/Db/UI/InitialStateMapper.php | 37 ++ lib/Db/UI/MenuEntry.php | 81 +++++ lib/Db/UI/MenuEntryMapper.php | 63 ++++ lib/Db/UI/Script.php | 65 ++++ lib/Db/UI/ScriptMapper.php | 37 ++ lib/Db/UI/Style.php | 57 ++++ lib/Db/UI/StyleMapper.php | 37 ++ lib/Middleware/ExAppUiMiddleware.php | 43 +++ lib/Migration/Version1003Date202311061844.php | 142 ++++++++ lib/ProxyResponse.php | 43 +++ lib/Service/ExAppInitialStateService.php | 41 +++ lib/Service/ExAppMenuEntryService.php | 97 ++++++ lib/Service/ExAppScriptsService.php | 44 +++ lib/Service/ExAppStylesService.php | 48 +++ src/router/index.js | 10 + 19 files changed, 1257 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/MenuEntryController.php create mode 100644 lib/Db/UI/InitialState.php create mode 100644 lib/Db/UI/InitialStateMapper.php create mode 100644 lib/Db/UI/MenuEntry.php create mode 100644 lib/Db/UI/MenuEntryMapper.php create mode 100644 lib/Db/UI/Script.php create mode 100644 lib/Db/UI/ScriptMapper.php create mode 100644 lib/Db/UI/Style.php create mode 100644 lib/Db/UI/StyleMapper.php create mode 100644 lib/Middleware/ExAppUiMiddleware.php create mode 100644 lib/Migration/Version1003Date202311061844.php create mode 100644 lib/ProxyResponse.php create mode 100644 lib/Service/ExAppInitialStateService.php create mode 100644 lib/Service/ExAppMenuEntryService.php create mode 100644 lib/Service/ExAppScriptsService.php create mode 100644 lib/Service/ExAppStylesService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index c5725229..6fcd9c19 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -7,8 +7,26 @@ // AppAPI admin settings ['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'], + // Menu Entries + ['name' => 'MenuEntry#viewExAppPage', 'url' => '/embedded/{appId}/{name}', 'verb' => 'GET' , 'root' => '/embedded'], + ['name' => 'MenuEntry#ExAppIcon', 'url' => '/embedded/{appId}/{name}/icon', 'verb' => 'GET' , 'root' => '/embedded'], + + // Proxy +// ['name' => 'MenuEntry#ExAppProxySubLinksGet', +// 'url' => '/proxying/{appId}/{other}', 'verb' => 'GET' , 'root' => '/proxying', +// 'requirements' => array('other' => '.+'), 'defaults' => array('other' => '')], +// ['name' => 'MenuEntry#ExAppProxySubLinksPost', +// 'url' => '/proxying/{appId}/{other}', 'verb' => 'POST' , 'root' => '/proxying', +// 'requirements' => array('other' => '.+'), 'defaults' => array('other' => '')], +// ['name' => 'MenuEntry#ExAppProxySubLinksPut', +// 'url' => '/proxying/{appId}/{other}', 'verb' => 'PUT' , 'root' => '/proxying', +// 'requirements' => array('other' => '.+'), 'defaults' => array('other' => '')], + ['name' => 'MenuEntry#ExAppProxySubLinksGet', + 'url' => '/proxy/css/{appId}/{other}', 'verb' => 'GET' , 'root' => '', + 'requirements' => array('other' => '.+'), 'defaults' => array('other' => '')], + // ExApps actions - ['name' => 'ExAppsPage#viewApps', 'url' => '/apps', 'verb' => 'GET' , 'root' => ''], + ['name' => 'ExAppsPage#viewApps', 'url' => '/apps', 'verb' => 'GET' , 'root' => '/apps'], ['name' => 'ExAppsPage#listCategories', 'url' => '/apps/categories', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#listApps', 'url' => '/apps/list', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#enableApp', 'url' => '/apps/enable/{appId}', 'verb' => 'GET' , 'root' => ''], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 732772f6..a571dcb1 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -10,11 +10,13 @@ use OCA\AppAPI\Listener\SabrePluginAuthInitListener; use OCA\AppAPI\Listener\UserDeletedListener; use OCA\AppAPI\Middleware\AppAPIAuthMiddleware; +use OCA\AppAPI\Middleware\ExAppUiMiddleware; use OCA\AppAPI\Notifications\ExAppAdminNotifier; use OCA\AppAPI\Notifications\ExAppNotifier; use OCA\AppAPI\Profiler\AppAPIDataCollector; use OCA\AppAPI\PublicCapabilities; +use OCA\AppAPI\Service\ExAppMenuEntryService; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCP\AppFramework\App; @@ -53,6 +55,7 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerCapability(PublicCapabilities::class); $context->registerMiddleware(AppAPIAuthMiddleware::class); + $context->registerMiddleware(ExAppUiMiddleware::class); $context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerNotifierService(ExAppNotifier::class); @@ -67,6 +70,7 @@ public function boot(IBootContext $context): void { $profiler->add(new AppAPIDataCollector()); } $context->injectFn($this->registerExAppsManagementNavigation(...)); + $context->injectFn($this->registerExAppsMenuEntries(...)); } catch (NotFoundExceptionInterface|ContainerExceptionInterface|Throwable) { } } @@ -111,4 +115,10 @@ private function registerExAppsManagementNavigation(IUserSession $userSession): }); } } + + private function registerExAppsMenuEntries(): void { + $container = $this->getContainer(); + $menuEntryService = $container->get(ExAppMenuEntryService::class); + $menuEntryService->registerMenuEntries($container); + } } diff --git a/lib/Controller/MenuEntryController.php b/lib/Controller/MenuEntryController.php new file mode 100644 index 00000000..1c47581d --- /dev/null +++ b/lib/Controller/MenuEntryController.php @@ -0,0 +1,318 @@ +service->getExApp($appId); + if ($exApp === null) { + return new NotFoundResponse(); + } + if (!$exApp->getEnabled()) { + return new NotFoundResponse(); + } + $menuEntry = $this->menuEntryService->getExAppMenuEntry($appId, $name); + if ($menuEntry === null) { + return new NotFoundResponse(); + } + $initialStates = $this->initialStateService->getExAppInitialStates($appId, 'top_menu'); + foreach ($initialStates as $key => $value) { + $this->initialState->provideInitialState($key, $value); + } + $this->scriptsService->applyExAppScripts($appId, 'top_menu'); + $this->stylesService->applyExAppStyles($appId, 'top_menu'); + + $response = new TemplateResponse(Application::APP_ID, 'main'); + return $response; + } + + /** + * @NoCSRFRequired + * @NoAdminRequired + * @throws DOMException + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function ExAppIframeProxy(string $appId, string $name): Response { + $exApp = $this->service->getExApp($appId); + if ($exApp === null) { + return new NotFoundResponse(); + } + if (!$exApp->getEnabled()) { + return new NotFoundResponse(); + } + $menuEntry = $this->menuEntryService->getExAppMenuEntry($appId, $name); + if ($menuEntry === null) { + return new NotFoundResponse(); + } + $response = $this->service->aeRequestToExApp( + $exApp, $menuEntry->getRoute(), $this->userId, 'GET', request: $this->request + ); + if (is_array($response)) { + $error_response = new Response(); + return $error_response->setStatus(500); + } + $reHeaders = []; + foreach ($response->getHeaders() as $k => $values) { + $reHeaders[$k] = count($values) === 1 ? $values[0] : $values; + } + $reHeaders['content-security-policy'] = 'frame-ancestors *;'; + + $dom = new DOMDocument(); + @$dom->loadHTML($response->getBody(), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + $base = $dom->createElement('base'); + $base->setAttribute( + 'href', + $this->url->getAbsoluteURL('index.php/apps/app_api/proxying/'. $exApp->getAppid() . '/') + ); + $base->setAttribute('target', '_parent'); + $head = $dom->getElementsByTagName('head')->item(0); + if ($head) { + if ($head->hasChildNodes()) { + $head->insertBefore($base, $head->firstChild); + } else { + $head->appendChild($base); + } + } + return new DataDisplayResponse($dom->saveHTML(), $response->getStatusCode(), $reHeaders); + } + + private function createProxyResponse(string $path, IResponse $response, $cache = true): ProxyResponse { + $content = $response->getBody(); + $isHTML = pathinfo($path, PATHINFO_EXTENSION) === 'html'; + if ($isHTML) { + $nonce = $this->nonceManager->getNonce(); + $content = str_replace( + 'getHeader('content-type'); + if (empty($mime)) { + $mime = $this->mimeTypeHelper->detectPath($path); + if (pathinfo($path, PATHINFO_EXTENSION) === 'wasm') { + $mime = 'application/wasm'; + } + } + + $proxyResponse = new ProxyResponse( + data: $content, + length: strlen($content), + mimeType: $mime, + ); + + $headersToCopy = ['Content-Disposition', 'Last-Modified', 'Etag']; + foreach ($headersToCopy as $element) { + $headerValue = $response->getHeader($element); + if (empty($headerValue)) { + $proxyResponse->addHeader($element, $headerValue); + } + } + + if ($cache && !$isHTML) { + $proxyResponse->cacheFor(3600); + } + + $csp = new ContentSecurityPolicy(); + $csp->addAllowedScriptDomain($this->request->getServerHost()); + $csp->addAllowedScriptDomain('\'unsafe-eval\''); + $csp->addAllowedScriptDomain('\'unsafe-inline\''); + $csp->addAllowedFrameDomain($this->request->getServerHost()); + $proxyResponse->setContentSecurityPolicy($csp); + return $proxyResponse; + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function ExAppProxyCss(string $appId, string $other): Response { + $exApp = $this->service->getExApp($appId); + if ($exApp === null) { + return new NotFoundResponse(); + } + if (!$exApp->getEnabled()) { + return new NotFoundResponse(); + } + + $response = $this->service->aeRequestToExApp( + $exApp, '/' . $other, $this->userId, 'GET', request: $this->request + ); + if (is_array($response)) { + $error_response = new Response(); + return $error_response->setStatus(500); + } + return $this->createProxyResponse($other, $response); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function ExAppProxySubLinksGet(string $appId, string $other): Response { + $exApp = $this->service->getExApp($appId); + if ($exApp === null) { + return new NotFoundResponse(); + } + if (!$exApp->getEnabled()) { + return new NotFoundResponse(); + } + + $response = $this->service->aeRequestToExApp( + $exApp, '/' . $other, $this->userId, 'GET', request: $this->request + ); + if (is_array($response)) { + $error_response = new Response(); + return $error_response->setStatus(500); + } + return $this->createProxyResponse($other, $response); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function ExAppIframeProxySubLinksPost(string $appId, string $other): Response { + $exApp = $this->service->getExApp($appId); + if ($exApp === null) { + return new NotFoundResponse(); + } + if (!$exApp->getEnabled()) { + return new NotFoundResponse(); + } + + $response = $this->service->aeRequestToExApp( + $exApp, '/' . $other, $this->userId, + params: $this->request->getParams(), + request: $this->request + ); + if (is_array($response)) { + $error_response = new Response(); + return $error_response->setStatus(500); + } + return $this->createProxyResponse($other, $response); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function ExAppIframeProxySubLinksPut(string $appId, string $other): Response { + $exApp = $this->service->getExApp($appId); + if ($exApp === null) { + return new NotFoundResponse(); + } + if (!$exApp->getEnabled()) { + return new NotFoundResponse(); + } + + $response = $this->service->aeRequestToExApp( + $exApp, '/' . $other, $this->userId, 'PUT', + $this->request->getParams(), + request: $this->request + ); + if (is_array($response)) { + $error_response = new Response(); + return $error_response->setStatus(500); + } + return $this->createProxyResponse($other, $response); + } + + /** + * @NoCSRFRequired + * @NoAdminRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function ExAppIconProxy(string $appId, string $name): Response { + $exApp = $this->service->getExApp($appId); + if ($exApp === null) { + return new NotFoundResponse(); + } + if (!$exApp->getEnabled()) { + return new NotFoundResponse(); + } + $icon = $this->menuEntryService->loadFileActionIcon($appId, $name, $exApp, $this->request, $this->userId); + if ($icon !== null && isset($icon['body'], $icon['headers'])) { + $response = new DataDisplayResponse( + $icon['body'], + HttpAlias::STATUS_OK, + ['Content-Type' => $icon['headers']['Content-Type'][0] ?? 'image/svg+xml'] + ); + $response->cacheFor(ExAppMenuEntryService::ICON_CACHE_TTL, false, true); + return $response; + } + return new DataDisplayResponse('', 400); + } + + /** + * @NoCSRFRequired + * @NoAdminRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[AppAPIAuth] + public function registerExAppMenuEntry(string $name, string $displayName, string $route, string $iconUrl = '', int $adminRequired = 0): DataResponse { + return new DataResponse(); + } + + /** + * @NoCSRFRequired + * @NoAdminRequired + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[AppAPIAuth] + public function unregisterExAppMenuEntry(string $name): DataResponse { + return new DataResponse(); + } +} diff --git a/lib/Db/UI/InitialState.php b/lib/Db/UI/InitialState.php new file mode 100644 index 00000000..e88609b9 --- /dev/null +++ b/lib/Db/UI/InitialState.php @@ -0,0 +1,65 @@ +addType('appid', 'string'); + $this->addType('type', 'string'); + $this->addType('key', 'string'); + $this->addType('value', 'json'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppId($params['appid']); + } + if (isset($params['type'])) { + $this->setType($params['type']); + } + if (isset($params['key'])) { + $this->setKey($params['key']); + } + if (isset($params['value'])) { + $this->setValue($params['value']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppId(), + 'type' => $this->getType(), + 'key' => $this->getKey(), + 'value' => $this->getValue(), + ]; + } +} diff --git a/lib/Db/UI/InitialStateMapper.php b/lib/Db/UI/InitialStateMapper.php new file mode 100644 index 00000000..9a90dcad --- /dev/null +++ b/lib/Db/UI/InitialStateMapper.php @@ -0,0 +1,37 @@ + + */ +class InitialStateMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_apps_ui_state'); + } + + /** + * @param string $appId + * @param string $type + * + * @throws Exception + * @return array + */ + public function findByAppIdType(string $appId, string $type): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('key', 'value') + ->from($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_STR)) + )->executeQuery();; + return $result->fetchAll(); + } +} diff --git a/lib/Db/UI/MenuEntry.php b/lib/Db/UI/MenuEntry.php new file mode 100644 index 00000000..5808120e --- /dev/null +++ b/lib/Db/UI/MenuEntry.php @@ -0,0 +1,81 @@ +addType('appid', 'string'); + $this->addType('name', 'string'); + $this->addType('display_name', 'string'); + $this->addType('route', 'string'); + $this->addType('icon_url', 'string'); + $this->addType('admin_required', 'integer'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['name'])) { + $this->setName($params['name']); + } + if (isset($params['display_name'])) { + $this->setDisplayName($params['display_name']); + } + if (isset($params['route'])) { + $this->setRoute($params['route']); + } + if (isset($params['icon_url'])) { + $this->setIconUrl($params['icon_url']); + } + if (isset($params['admin_required'])) { + $this->setAdminRequired($params['admin_required']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'name' => $this->getName(), + 'display_name' => $this->getDisplayName(), + 'route' => $this->getRoute(), + 'icon_url' => $this->getIconUrl(), + 'admin_required' => $this->getAdminRequired(), + ]; + } +} diff --git a/lib/Db/UI/MenuEntryMapper.php b/lib/Db/UI/MenuEntryMapper.php new file mode 100644 index 00000000..a87453d4 --- /dev/null +++ b/lib/Db/UI/MenuEntryMapper.php @@ -0,0 +1,63 @@ + + */ +class MenuEntryMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'menu_entries_ex'); + } + + /** + * @throws Exception + */ + public function findAllEnabled(): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select( + 'menu_entries.appid', + 'menu_entries.name', + 'menu_entries.display_name', + 'menu_entries.route', + 'menu_entries.icon_url', + 'menu_entries.admin_required', + ) + ->from($this->tableName, 'menu_entries') + ->innerJoin('menu_entries', 'ex_apps', 'exa', 'exa.appid = menu_entries.appid') + ->where( + $qb->expr()->eq('exa.enabled', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)) + ) + ->executeQuery(); + return $result->fetchAll(); + } + + /** + * @param string $appId + * @param string $name + * + * @throws DoesNotExistException if not found + * @throws Exception + * @throws MultipleObjectsReturnedException if more than one result + * @return MenuEntry + */ + public function findByAppidName(string $appId, string $name): MenuEntry { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR)) + ); + return $this->findEntity($qb); + } +} diff --git a/lib/Db/UI/Script.php b/lib/Db/UI/Script.php new file mode 100644 index 00000000..778667c9 --- /dev/null +++ b/lib/Db/UI/Script.php @@ -0,0 +1,65 @@ +addType('appid', 'string'); + $this->addType('type', 'string'); + $this->addType('path', 'string'); + $this->addType('afterAppId', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppId($params['appid']); + } + if (isset($params['type'])) { + $this->setType($params['type']); + } + if (isset($params['path'])) { + $this->setPath($params['path']); + } + if (isset($params['afterAppId'])) { + $this->setAfterAppId($params['afterAppId']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppId(), + 'type' => $this->getType(), + 'path' => $this->getPath(), + 'afterAppId' => $this->getAfterAppId(), + ]; + } +} diff --git a/lib/Db/UI/ScriptMapper.php b/lib/Db/UI/ScriptMapper.php new file mode 100644 index 00000000..aceb4270 --- /dev/null +++ b/lib/Db/UI/ScriptMapper.php @@ -0,0 +1,37 @@ + + */ +class ScriptMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_apps_ui_scripts'); + } + + /** + * @param string $appId + * @param string $type + * + * @throws Exception + * @return array + */ + public function findByAppIdType(string $appId, string $type): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('path', 'after_app_id') + ->from($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_STR)) + )->executeQuery();; + return $result->fetchAll(); + } +} diff --git a/lib/Db/UI/Style.php b/lib/Db/UI/Style.php new file mode 100644 index 00000000..f23ca3a9 --- /dev/null +++ b/lib/Db/UI/Style.php @@ -0,0 +1,57 @@ +addType('appid', 'string'); + $this->addType('type', 'string'); + $this->addType('path', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppId($params['appid']); + } + if (isset($params['type'])) { + $this->setType($params['type']); + } + if (isset($params['path'])) { + $this->setPath($params['path']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppId(), + 'type' => $this->getType(), + 'path' => $this->getPath(), + ]; + } +} diff --git a/lib/Db/UI/StyleMapper.php b/lib/Db/UI/StyleMapper.php new file mode 100644 index 00000000..b8b0b0c8 --- /dev/null +++ b/lib/Db/UI/StyleMapper.php @@ -0,0 +1,37 @@ + + */ +class StyleMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_apps_ui_styles'); + } + + /** + * @param string $appId + * @param string $type + * + * @throws Exception + * @return array + */ + public function findByAppIdType(string $appId, string $type): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('path') + ->from($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_STR)) + )->executeQuery();; + return $result->fetchAll(); + } +} diff --git a/lib/Middleware/ExAppUiMiddleware.php b/lib/Middleware/ExAppUiMiddleware.php new file mode 100644 index 00000000..c323340b --- /dev/null +++ b/lib/Middleware/ExAppUiMiddleware.php @@ -0,0 +1,43 @@ +hasTable('menu_entries_ex')) { + $table = $schema->createTable('menu_entries_ex'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('display_name', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('route', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('icon_url', Types::STRING, [ + 'notnull' => true, + 'default' => '', + ]); + $table->addColumn('admin_required', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['appid', 'name'], 'menu_entries_ex__idx'); + } + + if (!$schema->hasTable('ex_apps_ui_state')) { + $table = $schema->createTable('ex_apps_ui_state'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + ]); + $table->addColumn('key', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('value', Types::JSON, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['appid', 'type', 'key'], 'ui_state__idx'); + } + + if (!$schema->hasTable('ex_apps_ui_scripts')) { + $table = $schema->createTable('ex_apps_ui_scripts'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + ]); + $table->addColumn('path', Types::STRING, [ + 'notnull' => true, + 'length' => 2000, + ]); + $table->addColumn('after_app_id', Types::STRING, [ + 'notnull' => false, + 'length' => 32, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['appid', 'type', 'path'], 'ui_script__idx'); + } + + if (!$schema->hasTable('ex_apps_ui_styles')) { + $table = $schema->createTable('ex_apps_ui_styles'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + ]); + $table->addColumn('path', Types::STRING, [ + 'notnull' => true, + 'length' => 2000, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['appid', 'type', 'path'], 'ui_style__idx'); + } + + return $schema; + } +} diff --git a/lib/ProxyResponse.php b/lib/ProxyResponse.php new file mode 100644 index 00000000..da2fa958 --- /dev/null +++ b/lib/ProxyResponse.php @@ -0,0 +1,43 @@ +> */ +class ProxyResponse extends Response implements ICallbackResponse { + private mixed $data; + + public function __construct(int $status = HttpAlias::STATUS_OK, + array $headers = [], mixed $data = null, int $length = 0, + string $mimeType = '', int $lastModified = 0) { + parent::__construct(); + $this->data = $data; + $this->setStatus($status); + $this->setHeaders(array_merge($this->getHeaders(), $headers)); + $this->addHeader('Content-Length', (string)$length); + if (!empty($mimeType)) { + $this->addHeader('Content-Type', $mimeType); + } + if ($lastModified !== 0) { + $lastModifiedDate = new \DateTime(); + $lastModifiedDate->setTimestamp($lastModified); + $this->setLastModified($lastModifiedDate); + } + } + + public function callback(IOutput $output): void { + if ($output->getHttpResponseCode() !== HttpAlias::STATUS_NOT_MODIFIED) { + if (is_resource($this->data)) { + fpassthru($this->data); + } else { + print $this->data; + } + } + } +} diff --git a/lib/Service/ExAppInitialStateService.php b/lib/Service/ExAppInitialStateService.php new file mode 100644 index 00000000..f6291447 --- /dev/null +++ b/lib/Service/ExAppInitialStateService.php @@ -0,0 +1,41 @@ +mapper->findByAppIdType($appId, $type); + $results = []; + foreach ($initialStates as $value) { + $results[$value['key']] = $value['value']; + } + return $results; + } catch (Exception $e) { + $this->logger->error($e->getMessage()); + return null; + } + } +} diff --git a/lib/Service/ExAppMenuEntryService.php b/lib/Service/ExAppMenuEntryService.php new file mode 100644 index 00000000..d5064a45 --- /dev/null +++ b/lib/Service/ExAppMenuEntryService.php @@ -0,0 +1,97 @@ +mapper->findAllEnabled(); + /** @var MenuEntry $menuEntry */ + foreach ($enabledEntries as $menuEntry) { + $userSession = $container->get(IUserSession::class); + /** @var IGroupManager $groupManager */ + $groupManager = $container->get(IGroupManager::class); + /** @var IUser $user */ + $user = $userSession->getUser(); + if ($menuEntry['admin_required'] === 1 && !$groupManager->isInGroup($user->getUID(), 'admin')) { + continue; // Skip this entry if user is not admin and entry requires admin privileges + } + $container->get(INavigationManager::class)->add(function () use ($container, $menuEntry) { + $urlGenerator = $container->get(IURLGenerator::class); + return [ + 'id' => $menuEntry['appid'] . $menuEntry['route'], + 'href' => $urlGenerator->linkToRoute('app_api.MenuEntry.viewExAppPage', ['appId' => $menuEntry['appid'], 'name' => $menuEntry['name']]), + 'icon' => $menuEntry['icon_url'] === '' ? $urlGenerator->imagePath('app_api', 'app.svg') : $urlGenerator->linkToRoute('app_api.MenuEntry.ExAppIconProxy', ['appId' => $menuEntry['appid'], 'name' => $menuEntry['name']]), + 'name' => $menuEntry['display_name'], + ]; + }); + } + } + + public function registerExAppMenuEntry(string $appId, array $params) { + // TODO: Register new MenuEntry from ExApp + } + + public function unregisterExAppMenuEntry(string $appId, string $route) { + // TODO: Unregister ExApp MenuEntry by route + } + + public function getExAppMenuEntry(string $appId, string $name): ?MenuEntry { + try { + // TODO: Add caching + return $this->mapper->findByAppidName($appId, $name); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->error($e->getMessage()); + return null; + } + } + + public function loadFileActionIcon(string $appId, string $name, ExApp $exApp, IRequest $request, string $userId): ?array { + // $menuEntry = $this->getExAppMenuEntry($appId, $name); + // if ($menuEntry === null) { + // return null; + // } + // $iconUrl = $menuEntry->getIconUrl(); + // if (!isset($iconUrl) || $iconUrl === '') { + // return null; + // } + // try { + // $iconResponse = $this->service->requestToExApp($request, $userId, $exApp, $iconUrl, 'GET'); + // if ($iconResponse->getStatusCode() === Http::STATUS_OK) { + // return [ + // 'body' => $iconResponse->getBody(), + // 'headers' => $iconResponse->getHeaders(), + // ]; + // } + // } catch (\Exception $e) { + // $this->logger->error(sprintf('Failed to load ExApp %s MenuEntry icon %s. Error: %s', $appId, $name, $e->getMessage()), ['exception' => $e]); + // return null; + // } + return null; + } +} diff --git a/lib/Service/ExAppScriptsService.php b/lib/Service/ExAppScriptsService.php new file mode 100644 index 00000000..fd56844a --- /dev/null +++ b/lib/Service/ExAppScriptsService.php @@ -0,0 +1,44 @@ +mapper->findByAppIdType($appId, $type); + foreach ($scripts as $value) { + if (is_null($value['after_app_id'])) { + Util::addScript(Application::APP_ID, $value['path']); + } + else { + Util::addScript(Application::APP_ID, $value['path'], $value['after_app_id']); + } + } + } +} diff --git a/lib/Service/ExAppStylesService.php b/lib/Service/ExAppStylesService.php new file mode 100644 index 00000000..bac56dc2 --- /dev/null +++ b/lib/Service/ExAppStylesService.php @@ -0,0 +1,48 @@ +mapper->findByAppIdType($appId, $type); + foreach ($styles as $value) { + if (str_starts_with($value['path'], '/')) { + // in the future we should allow offload of styles to the NC instance if they start with '/' + $path = 'proxy/css/'. $appId . $value['path']; + } + else { + $path = 'proxy/css/'. $appId . '/' . $value['path']; + } + Util::addStyle(Application::APP_ID, $path); + } + } +} diff --git a/src/router/index.js b/src/router/index.js index 09d4b609..afedd395 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -21,6 +21,16 @@ const router = new VueRouter({ base: generateUrl('/apps/app_api', ''), linkActiveClass: 'active', routes: [ + { + path: '/embedded/:appid/:name', + component: ExAppView, + name: 'embedded', + meta: { + title: async () => { + return t('app_api', 'Embedded ExApp') + }, + }, + }, { path: '/apps', component: Apps,