From 0a92f6948e3cd3bb6480d9bc313ff05ee60c7720 Mon Sep 17 00:00:00 2001 From: meatybot Date: Thu, 29 Oct 2026 16:01:19 +0000 Subject: [PATCH 1/3] feat(ui): let ExApps register reference providers and Smart Picker entries Assisted-by: omp:claude-opus-5-5 Signed-off-by: meatybot --- CHANGELOG.md | 4 + appinfo/info.xml | 2 +- appinfo/routes.php | 5 + js/app_api-reference.js | 2 + js/app_api-reference.js.license | 8 + js/app_api-reference.js.map | 1 + js/app_api-reference.js.map.license | 1 + lib/AppInfo/Application.php | 21 + lib/Controller/OCSUiController.php | 84 ++++ lib/Db/UI/ReferenceProvider.php | 130 ++++++ lib/Db/UI/ReferenceProviderMapper.php | 86 ++++ lib/Listener/RenderReferenceListener.php | 81 ++++ .../Version036000Date20261024120000.php | 81 ++++ lib/ResponseDefinitions.php | 12 + lib/Service/ExAppService.php | 4 + lib/Service/UI/ReferenceProviderService.php | 323 ++++++++++++++ openapi-full.json | 409 ++++++++++++++++++ openapi.json | 409 ++++++++++++++++++ src/reference.js | 9 + src/referenceLoader.js | 66 +++ src/referenceLoader.spec.js | 64 +++ tests/php/Controller/OCSUiControllerTest.php | 32 ++ .../Service/ExAppServiceGetAppInfoTest.php | 2 + .../Service/ReferenceProviderServiceTest.php | 195 +++++++++ webpack.js | 1 + 25 files changed, 2031 insertions(+), 1 deletion(-) create mode 100644 js/app_api-reference.js create mode 100644 js/app_api-reference.js.license create mode 100644 js/app_api-reference.js.map create mode 120000 js/app_api-reference.js.map.license create mode 100644 lib/Db/UI/ReferenceProvider.php create mode 100644 lib/Db/UI/ReferenceProviderMapper.php create mode 100644 lib/Listener/RenderReferenceListener.php create mode 100644 lib/Migration/Version036000Date20261024120000.php create mode 100644 lib/Service/UI/ReferenceProviderService.php create mode 100644 src/reference.js create mode 100644 src/referenceLoader.js create mode 100644 src/referenceLoader.spec.js create mode 100644 tests/php/Service/ReferenceProviderServiceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bf5734d..2853b77d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added + +- ExApps can register reference providers with `POST /ocs/v2.php/apps/app_api/api/v1/ui/reference-provider`. A provider adds an entry to the Smart Picker, optionally with a picker script from the ExApp, and resolves the links that match its patterns into rich previews through `POST /reference/resolve` on the ExApp. The script path and a relative icon path must stay inside the ExApp: no `..` segments, schemes, backslashes or percent-encoding. #1099 + ### Changed - `POST /ocs/v2.php/apps/app_api/api/v1/notification` now answers `404` when the `notifications` app is not enabled, instead of `200` for a notification that nobody delivers. diff --git a/appinfo/info.xml b/appinfo/info.xml index 0937b840f..0eb02d5e3 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -37,7 +37,7 @@ Two options are available: See the [admin documentation](https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/DeployConfigurations.html) for setup instructions. ]]> - 36.0.0-dev.0 + 36.0.0-dev.1 agpl Andrey Borysenko Alexander Piskun diff --git a/appinfo/routes.php b/appinfo/routes.php index 2d4b7144a..ca48572ee 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -125,6 +125,11 @@ ['name' => 'OCSUi#unregisterExAppMenuEntry', 'url' => '/api/v1/ui/top-menu', 'verb' => 'DELETE'], ['name' => 'OCSUi#getExAppMenuEntry', 'url' => '/api/v1/ui/top-menu', 'verb' => 'GET'], + // Reference providers (Smart Picker entries and link previews) + ['name' => 'OCSUi#registerExAppReferenceProvider', 'url' => '/api/v1/ui/reference-provider', 'verb' => 'POST'], + ['name' => 'OCSUi#unregisterExAppReferenceProvider', 'url' => '/api/v1/ui/reference-provider', 'verb' => 'DELETE'], + ['name' => 'OCSUi#getExAppReferenceProvider', 'url' => '/api/v1/ui/reference-provider', 'verb' => 'GET'], + // Common UI ['name' => 'OCSUi#setExAppInitialState', 'url' => '/api/v1/ui/initial-state', 'verb' => 'POST'], ['name' => 'OCSUi#deleteExAppInitialState', 'url' => '/api/v1/ui/initial-state', 'verb' => 'DELETE'], diff --git a/js/app_api-reference.js b/js/app_api-reference.js new file mode 100644 index 000000000..0c2af6edb --- /dev/null +++ b/js/app_api-reference.js @@ -0,0 +1,2 @@ +(()=>{"use strict";function t(t,e,i){try{const n=i.location.origin,r=new URL(t,i.location.href),a=new URL(e.replace(/\/*$/,"/"),i.location.href);return r.origin===n&&a.origin===n&&r.pathname.startsWith(a.pathname)}catch{return!1}}!function(e,i,n){i.OCA??={},i.OCA.AppAPI??={},i.OCA.AppAPI.referencePickers??={};const r=i.OCA.AppAPI.referencePickers,a=new Set;for(const o of e){if(r[o.provider_id]={appId:o.appid,baseUrl:o.base_url},a.has(o.script_url))continue;if(a.add(o.script_url),!t(o.script_url,o.base_url,i))continue;const e=n.createElement("script");e.src=o.script_url,e.async=!1,e.dataset.appId=o.appid,e.dataset.providerId=o.provider_id,e.dataset.baseUrl=o.base_url,n.head.appendChild(e)}}(function(t,e,i){const n=`#initial-state-${t}-${e}`;if(window._nc_initial_state?.has(n))return window._nc_initial_state.get(n);window._nc_initial_state||(window._nc_initial_state=new Map);const r=document.querySelector(n);if(null===r){if(void 0!==i)return i;throw new Error(`Could not find initial state ${e} of ${t}`)}try{const t=JSON.parse(atob(r.value));return window._nc_initial_state.set(n,t),t}catch(n){if(console.error("[@nextcloud/initial-state] Could not parse initial state",{key:e,app:t,error:n}),void 0!==i)return i;throw new Error(`Could not parse initial state ${e} of ${t}`,{cause:n})}}("app_api","ex_reference_pickers",[]),window,document)})(); +//# sourceMappingURL=app_api-reference.js.map \ No newline at end of file diff --git a/js/app_api-reference.js.license b/js/app_api-reference.js.license new file mode 100644 index 000000000..2cb81a02a --- /dev/null +++ b/js/app_api-reference.js.license @@ -0,0 +1,8 @@ +SPDX-License-Identifier: ( AGPL-3.0-or-later) +SPDX-FileCopyrightText: Alexander Piskun + + +This file is generated from multiple sources. Included packages: +- app_api + - version: 36.0.0-dev.0 + - license: ( AGPL-3.0-or-later) diff --git a/js/app_api-reference.js.map b/js/app_api-reference.js.map new file mode 100644 index 000000000..5200f0dee --- /dev/null +++ b/js/app_api-reference.js.map @@ -0,0 +1 @@ +{"version":3,"file":"app_api-reference.js","mappings":"mBAwDO,SAASA,EAAcC,EAAWC,EAASC,GACjD,IACC,MAAMC,EAASD,EAAIE,SAASD,OACtBE,EAAS,IAAIC,IAAIN,EAAWE,EAAIE,SAASG,MACzCC,EAAO,IAAIF,IAAIL,EAAQQ,QAAQ,OAAQ,KAAMP,EAAIE,SAASG,MAChE,OAAOF,EAAOF,SAAWA,GAAUK,EAAKL,SAAWA,GAAUE,EAAOK,SAASC,WAAWH,EAAKE,SAC9F,CAAE,MACD,OAAO,CACR,CACD,EA/CO,SAA0BE,EAASV,EAAKW,GAC9CX,EAAIY,MAAQ,CAAC,EACbZ,EAAIY,IAAIC,SAAW,CAAC,EACpBb,EAAIY,IAAIC,OAAOC,mBAAqB,CAAC,EACrC,MAAMC,EAAWf,EAAIY,IAAIC,OAAOC,iBAE1BE,EAAS,IAAIC,IACnB,IAAK,MAAMC,KAAUR,EAAS,CAE7B,GADAK,EAASG,EAAOC,aAAe,CAAEC,MAAOF,EAAOG,MAAOtB,QAASmB,EAAOI,UAClEN,EAAOO,IAAIL,EAAOM,YACrB,SAMD,GAJAR,EAAOS,IAAIP,EAAOM,aAIb3B,EAAcqB,EAAOM,WAAYN,EAAOI,SAAUtB,GACtD,SAGD,MAAMG,EAASQ,EAAIe,cAAc,UACjCvB,EAAOwB,IAAMT,EAAOM,WACpBrB,EAAOyB,OAAQ,EACfzB,EAAO0B,QAAQT,MAAQF,EAAOG,MAC9BlB,EAAO0B,QAAQC,WAAaZ,EAAOC,YACnChB,EAAO0B,QAAQ9B,QAAUmB,EAAOI,SAChCX,EAAIoB,KAAKC,YAAY7B,EACtB,CACD,CCtCA8B,CCEO,SAAAC,EAAAC,EAAAC,GACP,MAAAC,EAAA,kBAAuCH,KAAOC,IAC9C,GAAAG,OAAAC,mBAAAhB,IAAAc,GACA,OAAAC,OAAAC,kBAAAC,IAAAH,GAEAC,OAAAC,oBACAD,OAAAC,kBAAA,IAAAE,KAEA,MAAAC,EAAAC,SAAAC,cAAAP,GACA,UAAAK,EAAA,CACA,QAAAG,IAAAT,EACA,OAAAA,EAEA,UAAAU,MAAA,gCAAwDX,QAAUD,IAClE,CACA,IACA,MAAAa,EAAAC,KAAAC,MAAAC,KAAAR,EAAAS,QAEA,OADAb,OAAAC,kBAAAa,IAAAf,EAAAU,GACAA,CACA,CACA,MAAAM,GAEA,GADAC,QAAAD,MAAA,4DAAoFlB,MAAAD,MAAAmB,eACpFR,IAAAT,EACA,OAAAA,EAEA,UAAAU,MAAA,iCAAyDX,QAAUD,IAAI,CAAKqB,MAAAF,GAC5E,CACA,CD7BiBG,CAAU,UAAW,uBAAwB,IAAKlB,OAAQK","sources":["webpack:///app_api/src/referenceLoader.js","webpack:///app_api/src/reference.js","webpack:///app_api/node_modules/@nextcloud/initial-state/dist/index.js"],"sourcesContent":["/**\n * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\n/**\n * Load the picker scripts of ExApp reference providers.\n *\n * Each ExApp script registers its custom picker element with\n * `window._registerCustomPickerElement(providerId, ...)`. Before the script runs, the base URL of its\n * ExApp is published in `window.OCA.AppAPI.referencePickers[providerId].baseUrl`, and the script tag\n * carries `data-base-url` and `data-provider-id` (readable via `document.currentScript.dataset`).\n * The base URL is `/exapps/` with HaRP, otherwise the AppAPI PHP proxy.\n *\n * @param {Array<{appid: string, provider_id: string, base_url: string, script_url: string}>} pickers from initial state\n * @param {Window} win window to publish the base URLs on\n * @param {Document} doc document to add the script tags to\n */\nexport function loadExAppPickers(pickers, win, doc) {\n\twin.OCA ??= {}\n\twin.OCA.AppAPI ??= {}\n\twin.OCA.AppAPI.referencePickers ??= {}\n\tconst registry = win.OCA.AppAPI.referencePickers\n\n\tconst loaded = new Set()\n\tfor (const picker of pickers) {\n\t\tregistry[picker.provider_id] = { appId: picker.appid, baseUrl: picker.base_url }\n\t\tif (loaded.has(picker.script_url)) {\n\t\t\tcontinue\n\t\t}\n\t\tloaded.add(picker.script_url)\n\n\t\t// AppAPI validates script paths on registration. Rows stored before that, or a bad base URL, must\n\t\t// still not load a script from another ExApp or another origin.\n\t\tif (!isInsideExApp(picker.script_url, picker.base_url, win)) {\n\t\t\tcontinue\n\t\t}\n\n\t\tconst script = doc.createElement('script')\n\t\tscript.src = picker.script_url\n\t\tscript.async = false\n\t\tscript.dataset.appId = picker.appid\n\t\tscript.dataset.providerId = picker.provider_id\n\t\tscript.dataset.baseUrl = picker.base_url\n\t\tdoc.head.appendChild(script)\n\t}\n}\n\n/**\n * Whether `scriptUrl` resolves (as the browser will, after `..` and encoding) to a path under `baseUrl`\n * on the page's own origin.\n *\n * @param {string} scriptUrl the script URL\n * @param {string} baseUrl the ExApp base URL\n * @param {Window} win window giving the page origin\n */\nexport function isInsideExApp(scriptUrl, baseUrl, win) {\n\ttry {\n\t\tconst origin = win.location.origin\n\t\tconst script = new URL(scriptUrl, win.location.href)\n\t\tconst base = new URL(baseUrl.replace(/\\/*$/, '/'), win.location.href)\n\t\treturn script.origin === origin && base.origin === origin && script.pathname.startsWith(base.pathname)\n\t} catch {\n\t\treturn false\n\t}\n}\n","/**\n * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { loadState } from '@nextcloud/initial-state'\nimport { loadExAppPickers } from './referenceLoader.js'\n\nloadExAppPickers(loadState('app_api', 'ex_reference_pickers', []), window, document)\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: GPL-3.0-or-later\n */\n/**\n * @param app app ID, e.g. \"mail\"\n * @param key name of the property\n * @param fallback optional parameter to use as default value\n * @throws if the key can't be found\n */\nexport function loadState(app, key, fallback) {\n const selector = `#initial-state-${app}-${key}`;\n if (window._nc_initial_state?.has(selector)) {\n return window._nc_initial_state.get(selector);\n }\n else if (!window._nc_initial_state) {\n window._nc_initial_state = new Map();\n }\n const elem = document.querySelector(selector);\n if (elem === null) {\n if (fallback !== undefined) {\n return fallback;\n }\n throw new Error(`Could not find initial state ${key} of ${app}`);\n }\n try {\n const parsedValue = JSON.parse(atob(elem.value));\n window._nc_initial_state.set(selector, parsedValue);\n return parsedValue;\n }\n catch (error) {\n console.error('[@nextcloud/initial-state] Could not parse initial state', { key, app, error });\n if (fallback !== undefined) {\n return fallback;\n }\n throw new Error(`Could not parse initial state ${key} of ${app}`, { cause: error });\n }\n}\n"],"names":["isInsideExApp","scriptUrl","baseUrl","win","origin","location","script","URL","href","base","replace","pathname","startsWith","pickers","doc","OCA","AppAPI","referencePickers","registry","loaded","Set","picker","provider_id","appId","appid","base_url","has","script_url","add","createElement","src","async","dataset","providerId","head","appendChild","loadExAppPickers","app","key","fallback","selector","window","_nc_initial_state","get","Map","elem","document","querySelector","undefined","Error","parsedValue","JSON","parse","atob","value","set","error","console","cause","loadState"],"sourceRoot":""} \ No newline at end of file diff --git a/js/app_api-reference.js.map.license b/js/app_api-reference.js.map.license new file mode 120000 index 000000000..08ca2ca0d --- /dev/null +++ b/js/app_api-reference.js.map.license @@ -0,0 +1 @@ +app_api-reference.js.license \ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4e8238ad1..f88a2192a 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -17,6 +17,7 @@ use OCA\AppAPI\Listener\GetTaskProcessingProvidersListener; use OCA\AppAPI\Listener\LoadFilesPluginListener; use OCA\AppAPI\Listener\LoadMenuEntriesListener; +use OCA\AppAPI\Listener\RenderReferenceListener; use OCA\AppAPI\Listener\SabrePluginAuthInitListener; use OCA\AppAPI\Middleware\AppAPIAuthMiddleware; use OCA\AppAPI\Middleware\ExAppUIL10NMiddleware; @@ -24,6 +25,7 @@ use OCA\AppAPI\Middleware\MaintenanceModeMiddleware; use OCA\AppAPI\Notifications\ExAppNotifier; use OCA\AppAPI\PublicCapabilities; +use OCA\AppAPI\Service\UI\ReferenceProviderService; use OCA\AppAPI\SetupChecks\DaemonCheck; use OCA\AppAPI\SetupChecks\ExAppsErrorSetupCheck; use OCA\AppAPI\SetupChecks\ExAppsWarningSetupCheck; @@ -35,12 +37,15 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\Navigation\Events\LoadAdditionalEntriesEvent; use OCP\Settings\Events\DeclarativeSettingsGetValueEvent; use OCP\Settings\Events\DeclarativeSettingsRegisterFormEvent; use OCP\Settings\Events\DeclarativeSettingsSetValueEvent; use OCP\TaskProcessing\Events\GetTaskProcessingProvidersEvent; +use Psr\Container\ContainerExceptionInterface; +use Psr\Log\LoggerInterface; class Application extends App implements IBootstrap { public const APP_ID = 'app_api'; @@ -78,11 +83,27 @@ public function register(IRegistrationContext $context): void { $context->registerSetupCheck(HarpVersionCheck::class); $context->registerSetupCheck(ExAppsErrorSetupCheck::class); $context->registerSetupCheck(ExAppsWarningSetupCheck::class); + + $context->registerEventListener(RenderReferenceEvent::class, RenderReferenceListener::class); + $this->registerExAppReferenceProviders($context); } public function boot(IBootContext $context): void { } + /** + * The server has no event to collect reference providers, so they are registered here, + * one synthetic class per ExApp provider (read from the distributed cache when available). + */ + private function registerExAppReferenceProviders(IRegistrationContext $context): void { + $container = $this->getContainer(); + try { + $container->get(ReferenceProviderService::class)->registerExAppReferenceProviders($context); + } catch (ContainerExceptionInterface $e) { + $container->get(LoggerInterface::class)->debug('Could not register ExApp reference providers', ['exception' => $e]); + } + } + public function registerDavAuth(): void { $container = $this->getContainer(); diff --git a/lib/Controller/OCSUiController.php b/lib/Controller/OCSUiController.php index 0120a4145..e8fcf6694 100644 --- a/lib/Controller/OCSUiController.php +++ b/lib/Controller/OCSUiController.php @@ -14,6 +14,7 @@ use OCA\AppAPI\ResponseDefinitions; use OCA\AppAPI\Service\UI\FilesActionsMenuService; use OCA\AppAPI\Service\UI\InitialStateService; +use OCA\AppAPI\Service\UI\ReferenceProviderService; use OCA\AppAPI\Service\UI\ScriptsService; use OCA\AppAPI\Service\UI\StylesService; use OCA\AppAPI\Service\UI\TopMenuService; @@ -32,6 +33,7 @@ * @psalm-import-type AppAPIInitialState from ResponseDefinitions * @psalm-import-type AppAPIScript from ResponseDefinitions * @psalm-import-type AppAPIStyle from ResponseDefinitions + * @psalm-import-type AppAPIReferenceProvider from ResponseDefinitions */ class OCSUiController extends OCSController { protected $request; @@ -43,6 +45,7 @@ public function __construct( private readonly InitialStateService $initialStateService, private readonly ScriptsService $scriptsService, private readonly StylesService $stylesService, + private readonly ReferenceProviderService $referenceProviderService, ) { parent::__construct(Application::APP_ID, $request); @@ -230,6 +233,87 @@ public function getExAppMenuEntry(string $name): DataResponse { return new DataResponse($result->jsonSerialize(), Http::STATUS_OK); } + /** + * Register a reference provider for the calling ExApp + * + * The provider appears in the Smart Picker and resolves matching links into rich previews. + * A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp. + * + * @param string $providerId Unique ID of the provider; the picker script registers its custom element under this ID + * @param string $title Title in the Smart Picker + * @param string $iconUrl Icon path inside the ExApp, or an absolute URL + * @param int $order Position in the Smart Picker; lower comes first + * @param list $searchProvidersIds Unified search providers that the picker can use for this provider + * @param list $matchPatterns PCRE patterns of links that this provider resolves + * @param string $scriptPath Path of the picker script inside the ExApp; empty for no custom picker element + * + * @return DataResponse, array{}> + * @throws OCSBadRequestException Invalid input, or the provider could not be registered + * + * 200: Reference provider registered + */ + #[AppAPIAuth] + #[PublicPage] + #[NoCSRFRequired] + public function registerExAppReferenceProvider( + string $providerId, string $title, string $iconUrl = '', int $order = 0, + array $searchProvidersIds = [], array $matchPatterns = [], string $scriptPath = ''): DataResponse { + try { + $result = $this->referenceProviderService->registerReferenceProvider( + $this->request->getHeader('ex-app-id'), $providerId, $title, $iconUrl, $order, + $searchProvidersIds, $matchPatterns, $scriptPath); + } catch (\InvalidArgumentException $e) { + throw new OCSBadRequestException($e->getMessage()); + } + if (!$result) { + throw new OCSBadRequestException('Reference provider could not be registered'); + } + return new DataResponse(); + } + + /** + * Unregister a reference provider of the calling ExApp + * + * @param string $providerId ID of the provider to remove + * + * @return DataResponse, array{}> + * @throws OCSNotFoundException Reference provider not found + * + * 200: Reference provider unregistered + */ + #[AppAPIAuth] + #[PublicPage] + #[NoCSRFRequired] + public function unregisterExAppReferenceProvider(string $providerId): DataResponse { + if (!$this->referenceProviderService->unregisterReferenceProvider( + $this->request->getHeader('ex-app-id'), $providerId)) { + throw new OCSNotFoundException('No such reference provider'); + } + return new DataResponse(); + } + + /** + * Get a reference provider of the calling ExApp + * + * @param string $providerId ID of the provider + * + * @return DataResponse + * @throws OCSNotFoundException Reference provider not found + * + * 200: Reference provider returned + */ + #[AppAPIAuth] + #[PublicPage] + #[NoCSRFRequired] + public function getExAppReferenceProvider(string $providerId): DataResponse { + $result = $this->referenceProviderService->getReferenceProvider( + $this->request->getHeader('ex-app-id'), $providerId); + if (!$result) { + throw new OCSNotFoundException('No such reference provider'); + } + return new DataResponse($result->jsonSerialize(), Http::STATUS_OK); + } + /** * Set an initial state for a page of the calling ExApp * diff --git a/lib/Db/UI/ReferenceProvider.php b/lib/Db/UI/ReferenceProvider.php new file mode 100644 index 000000000..5bbc8eb15 --- /dev/null +++ b/lib/Db/UI/ReferenceProvider.php @@ -0,0 +1,130 @@ +addType('appid', 'string'); + $this->addType('provider_id', 'string'); + $this->addType('title', 'string'); + $this->addType('icon_url', 'string'); + $this->addType('order', 'integer'); + $this->addType('search_providers_ids', 'string'); + $this->addType('match_patterns', 'string'); + $this->addType('script_path', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['provider_id'])) { + $this->setProviderId($params['provider_id']); + } + if (isset($params['title'])) { + $this->setTitle($params['title']); + } + if (isset($params['icon_url'])) { + $this->setIconUrl($params['icon_url']); + } + if (isset($params['order'])) { + $this->setOrder((int)$params['order']); + } + if (isset($params['search_providers_ids'])) { + $this->setSearchProvidersIds($params['search_providers_ids']); + } + if (isset($params['match_patterns'])) { + $this->setMatchPatterns($params['match_patterns']); + } + if (isset($params['script_path'])) { + $this->setScriptPath($params['script_path']); + } + } + + /** + * @return list + */ + public function getSearchProvidersIdsList(): array { + return self::decodeList($this->getSearchProvidersIds()); + } + + /** + * @return list + */ + public function getMatchPatternsList(): array { + return self::decodeList($this->getMatchPatterns()); + } + + /** + * @return list + */ + private static function decodeList(?string $json): array { + if ($json === null || $json === '') { + return []; + } + $list = json_decode($json, true); + if (!is_array($list)) { + return []; + } + return array_values(array_filter($list, 'is_string')); + } + + /** + * @return AppAPIReferenceProvider + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'provider_id' => $this->getProviderId(), + 'title' => $this->getTitle(), + 'icon_url' => $this->getIconUrl() ?? '', + 'order' => $this->getOrder() ?? 0, + 'search_providers_ids' => $this->getSearchProvidersIdsList(), + 'match_patterns' => $this->getMatchPatternsList(), + 'script_path' => $this->getScriptPath() ?? '', + ]; + } +} diff --git a/lib/Db/UI/ReferenceProviderMapper.php b/lib/Db/UI/ReferenceProviderMapper.php new file mode 100644 index 000000000..6f628ed93 --- /dev/null +++ b/lib/Db/UI/ReferenceProviderMapper.php @@ -0,0 +1,86 @@ + + */ +class ReferenceProviderMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_ui_reference_providers'); + } + + /** + * Rows of reference providers whose ExApp is enabled. + * + * @throws Exception + */ + public function findAllEnabled(): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('exr.*') + ->from($this->tableName, 'exr') + ->innerJoin('exr', 'ex_apps', 'exa', $qb->expr()->eq('exa.appid', 'exr.appid')) + ->where( + $qb->expr()->eq('exa.enabled', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)) + ) + ->orderBy('exr.id') + ->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + + /** + * @throws Exception + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + */ + public function findByProviderId(string $providerId): ReferenceProvider { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where( + $qb->expr()->eq('provider_id', $qb->createNamedParameter($providerId, IQueryBuilder::PARAM_STR)) + ); + return $this->findEntity($qb); + } + + /** + * @throws Exception + */ + public function removeByAppIdProviderId(string $appId, string $providerId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('provider_id', $qb->createNamedParameter($providerId, IQueryBuilder::PARAM_STR)) + ); + return $qb->executeStatement(); + } + + /** + * @throws Exception + */ + public function removeAllByAppId(string $appId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)) + ); + return $qb->executeStatement(); + } +} diff --git a/lib/Listener/RenderReferenceListener.php b/lib/Listener/RenderReferenceListener.php new file mode 100644 index 000000000..626bb94d2 --- /dev/null +++ b/lib/Listener/RenderReferenceListener.php @@ -0,0 +1,81 @@ + + */ +class RenderReferenceListener implements IEventListener { + public function __construct( + private readonly ReferenceProviderService $referenceProviderService, + private readonly ExAppService $exAppService, + private readonly IInitialState $initialState, + private readonly IURLGenerator $urlGenerator, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof RenderReferenceEvent) { + return; + } + + $pickers = []; + foreach ($this->referenceProviderService->getEnabledReferenceProviders() as $provider) { + $scriptPath = $provider->getScriptPath() ?? ''; + if ($scriptPath === '') { + continue; + } + $exApp = $this->exAppService->getExApp($provider->getAppid()); + if ($exApp === null) { + continue; + } + $baseUrl = $this->getExAppBaseUrl($provider->getAppid(), HarpService::isHarp($exApp->getDeployConfig())); + $pickers[] = [ + 'appid' => $provider->getAppid(), + 'provider_id' => $provider->getProviderId(), + 'base_url' => $baseUrl, + 'script_url' => $baseUrl . '/' . $scriptPath, + ]; + } + + if (empty($pickers)) { + return; + } + $this->initialState->provideInitialState('ex_reference_pickers', $pickers); + Util::addScript(Application::APP_ID, Application::APP_ID . '-reference'); + } + + /** + * Base URL the browser uses to reach the ExApp: HaRP serves `/exapps/` directly, + * otherwise the request goes through the AppAPI PHP proxy. + */ + private function getExAppBaseUrl(string $appId, bool $isHarp): string { + if ($isHarp) { + return rtrim($this->urlGenerator->getWebroot(), '/') . '/exapps/' . $appId; + } + return rtrim($this->urlGenerator->linkToRoute('app_api.ExAppProxy.ExAppGet', ['appId' => $appId, 'other' => '']), '/'); + } +} diff --git a/lib/Migration/Version036000Date20261024120000.php b/lib/Migration/Version036000Date20261024120000.php new file mode 100644 index 000000000..e741a2150 --- /dev/null +++ b/lib/Migration/Version036000Date20261024120000.php @@ -0,0 +1,81 @@ +hasTable('ex_ui_reference_providers')) { + return null; + } + + $table = $schema->createTable('ex_ui_reference_providers'); + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + // Provider ID as seen by the server and the Smart Picker; the ExApp's picker script + // registers its custom element under this same ID. + $table->addColumn('provider_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('title', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + $table->addColumn('icon_url', Types::STRING, [ + 'notnull' => false, + 'default' => '', + ]); + $table->addColumn('order', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + ]); + // JSON list of unified search provider IDs. + $table->addColumn('search_providers_ids', Types::TEXT, [ + 'notnull' => false, + ]); + // JSON list of PCRE patterns; a link that matches one of them is resolved by the ExApp. + $table->addColumn('match_patterns', Types::TEXT, [ + 'notnull' => false, + ]); + // Path of the picker script inside the ExApp; empty when the provider has no custom picker element. + $table->addColumn('script_path', Types::STRING, [ + 'notnull' => false, + 'default' => '', + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['provider_id'], 'ex_ui_ref_prov__provider_id'); + $table->addIndex(['appid'], 'ex_ui_ref_prov__appid'); + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 997a09473..ffa4c1559 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -80,6 +80,18 @@ * admin_required: int, * } * + * @psalm-type AppAPIReferenceProvider = array{ + * id: int, + * appid: string, + * provider_id: string, + * title: string, + * icon_url: string, + * order: int, + * search_providers_ids: list, + * match_patterns: list, + * script_path: string, + * } + * * @psalm-type AppAPIInitialState = array{ * id: int, * appid: string, diff --git a/lib/Service/ExAppService.php b/lib/Service/ExAppService.php index d39fab8c4..329d03ec4 100644 --- a/lib/Service/ExAppService.php +++ b/lib/Service/ExAppService.php @@ -18,6 +18,7 @@ use OCA\AppAPI\Service\ProvidersAI\TaskProcessingService; use OCA\AppAPI\Service\UI\FilesActionsMenuService; use OCA\AppAPI\Service\UI\InitialStateService; +use OCA\AppAPI\Service\UI\ReferenceProviderService; use OCA\AppAPI\Service\UI\ScriptsService; use OCA\AppAPI\Service\UI\SettingsService; use OCA\AppAPI\Service\UI\StylesService; @@ -58,6 +59,7 @@ public function __construct( private readonly ExAppDeployOptionsService $deployOptionsService, private readonly ExAppSetupCheckService $setupCheckService, private readonly IConfig $config, + private readonly ReferenceProviderService $referenceProviderService, ) { if ($cacheFactory->isAvailable()) { $distributedCacheClass = ltrim($config->getSystemValueString('memcache.distributed', ''), '\\'); @@ -115,6 +117,7 @@ public function unregisterExApp(string $appId): bool { $this->talkBotsService->unregisterExAppTalkBots($exApp); // TODO: Think about internal Events for clean and flexible unregister ExApp callbacks $this->filesActionsMenuService->unregisterExAppFileActions($appId); $this->topMenuService->unregisterExAppMenuEntries($appId); + $this->referenceProviderService->unregisterExAppReferenceProviders($appId); $this->initialStateService->deleteExAppInitialStates($appId); $this->scriptsService->deleteExAppScripts($appId); $this->stylesService->deleteExAppStyles($appId); @@ -245,6 +248,7 @@ public function getLatestExAppInfoFromAppstore(string $appId, string &$extracted private function resetCaches(): void { $this->topMenuService->resetCacheEnabled(); + $this->referenceProviderService->resetCacheEnabled(); $this->filesActionsMenuService->resetCacheEnabled(); $this->settingsService->resetCacheEnabled(); $this->occService->resetCacheEnabled(); diff --git a/lib/Service/UI/ReferenceProviderService.php b/lib/Service/UI/ReferenceProviderService.php new file mode 100644 index 000000000..8cd2406cb --- /dev/null +++ b/lib/Service/UI/ReferenceProviderService.php @@ -0,0 +1,323 @@ +isAvailable()) { + $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_ui_reference_providers'); + } + } + + /** + * @param list $searchProvidersIds + * @param list $matchPatterns + * + * @throws \InvalidArgumentException on invalid input or a provider ID owned by another ExApp + */ + public function registerReferenceProvider( + string $appId, string $providerId, string $title, string $iconUrl, int $order, + array $searchProvidersIds, array $matchPatterns, string $scriptPath, + ): ?ReferenceProvider { + if (!preg_match('/^[a-zA-Z0-9_.-]{1,64}$/', $providerId)) { + throw new \InvalidArgumentException('providerId must be 1-64 characters of [a-zA-Z0-9_.-]'); + } + if (trim($title) === '' || mb_strlen($title) > 128) { + throw new \InvalidArgumentException('title must be 1-128 characters'); + } + foreach ($matchPatterns as $pattern) { + if (!is_string($pattern) || @preg_match($pattern, '') === false) { + throw new \InvalidArgumentException(sprintf('Invalid match pattern: %s', (string)$pattern)); + } + } + foreach ($searchProvidersIds as $searchProviderId) { + if (!is_string($searchProviderId) || $searchProviderId === '') { + throw new \InvalidArgumentException('searchProvidersIds must be a list of non-empty strings'); + } + } + $scriptPath = ltrim($scriptPath, '/'); + if ($scriptPath !== '' && !self::isSafeRelativePath($scriptPath)) { + throw new \InvalidArgumentException('scriptPath must be a relative path inside the ExApp, without ".." segments or a scheme'); + } + if ($iconUrl !== '' && !preg_match('#^https?://#i', $iconUrl) && !self::isSafeRelativePath(ltrim($iconUrl, '/'))) { + throw new \InvalidArgumentException('iconUrl must be an http(s) URL or a relative path inside the ExApp, without ".." segments'); + } + + $existing = $this->findByProviderId($providerId); + if ($existing !== null && $existing->getAppid() !== $appId) { + throw new \InvalidArgumentException(sprintf('Provider ID "%s" belongs to another app', $providerId)); + } + + try { + $entity = new ReferenceProvider([ + 'appid' => $appId, + 'provider_id' => $providerId, + 'title' => $title, + 'icon_url' => $iconUrl, + 'order' => $order, + 'search_providers_ids' => json_encode($searchProvidersIds, JSON_THROW_ON_ERROR), + 'match_patterns' => json_encode($matchPatterns, JSON_THROW_ON_ERROR), + 'script_path' => $scriptPath, + ]); + if ($existing !== null) { + $entity->setId($existing->getId()); + } + $entity = $this->mapper->insertOrUpdate($entity); + $this->resetCacheEnabled(); + return $entity; + } catch (Exception|\JsonException $e) { + $this->logger->error( + sprintf('Failed to register ExApp %s reference provider %s. Error: %s', $appId, $providerId, $e->getMessage()), + ['exception' => $e], + ); + return null; + } + } + + public function unregisterReferenceProvider(string $appId, string $providerId): bool { + try { + $deleted = $this->mapper->removeByAppIdProviderId($appId, $providerId); + } catch (Exception $e) { + $this->logger->error(sprintf('Failed to unregister ExApp %s reference provider %s', $appId, $providerId), ['exception' => $e]); + return false; + } + $this->resetCacheEnabled(); + return $deleted > 0; + } + + public function unregisterExAppReferenceProviders(string $appId): int { + try { + $result = $this->mapper->removeAllByAppId($appId); + } catch (Exception) { + $result = -1; + } + $this->resetCacheEnabled(); + return $result; + } + + public function getReferenceProvider(string $appId, string $providerId): ?ReferenceProvider { + $provider = $this->findByProviderId($providerId); + return ($provider !== null && $provider->getAppid() === $appId) ? $provider : null; + } + + /** + * A path that stays inside the ExApp once appended to its base URL: browsers resolve `..` + * (plain or percent-encoded) and `\` like `/`, so `../other_app/x.js` would load another ExApp's + * script and `\host` could leave the origin. Allowed: `js/picker.js`, `js/fonts/a.woff2`. + */ + public static function isSafeRelativePath(string $path): bool { + if ($path === '' || strlen($path) > 255) { + return false; + } + if (!preg_match('#^[A-Za-z0-9._~/-]+$#', $path)) { + return false; // no scheme (":"), query, fragment, backslash or percent-encoding + } + foreach (explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + return false; + } + } + return true; + } + + private function findByProviderId(string $providerId): ?ReferenceProvider { + try { + return $this->mapper->findByProviderId($providerId); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception) { + return null; + } + } + + /** + * Reference providers of enabled ExApps. + * + * @return ReferenceProvider[] + */ + public function getEnabledReferenceProviders(): array { + try { + $records = $this->cache?->get(self::CACHE_KEY); + if ($records === null) { + $records = $this->mapper->findAllEnabled(); + $this->cache?->set(self::CACHE_KEY, $records); + } + return array_map(static fn (array $record) => new ReferenceProvider($record), $records); + } catch (Exception) { + return []; + } + } + + public function resetCacheEnabled(): void { + $this->cache?->remove(self::CACHE_KEY); + } + + /** + * Register one server reference provider for each enabled ExApp provider. + * + * The server takes a class name, so each provider gets a synthetic one in the AppAPI namespace; + * the server container sends `OCA\AppAPI\...` lookups to this app's container, where the + * anonymous object is registered. + */ + public function registerExAppReferenceProviders(IRegistrationContext $context): void { + foreach ($this->getEnabledReferenceProviders() as $row) { + /** @var class-string $className */ + $className = 'OCA\\AppAPI\\ExAppReferenceProvider\\' . $row->getAppid() . '\\' . $row->getProviderId(); + $provider = $this->getAnonymousReferenceProvider($row); + $context->registerService($className, static fn () => $provider); + $context->registerReferenceProvider($className); + } + } + + /** + * @psalm-suppress MissingDependency + */ + public function getAnonymousReferenceProvider(ReferenceProvider $row): ADiscoverableReferenceProvider { + return new class($row, $this->logger) extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider { + public function __construct( + private readonly ReferenceProvider $row, + private readonly LoggerInterface $logger, + ) { + } + + public function getId(): string { + return $this->row->getProviderId(); + } + + public function getTitle(): string { + return $this->row->getTitle(); + } + + public function getOrder(): int { + return $this->row->getOrder() ?? 0; + } + + public function getIconUrl(): string { + $icon = $this->row->getIconUrl() ?? ''; + $urlGenerator = Server::get(IURLGenerator::class); + if ($icon === '') { + return $urlGenerator->getAbsoluteURL($urlGenerator->imagePath(Application::APP_ID, 'app.svg')); + } + if (preg_match('#^https?://#i', $icon)) { + return $icon; + } + return $urlGenerator->linkToRouteAbsolute( + 'app_api.ExAppProxy.ExAppGet', ['appId' => $this->row->getAppid(), 'other' => ltrim($icon, '/')] + ); + } + + public function getSupportedSearchProviderIds(): array { + return $this->row->getSearchProvidersIdsList(); + } + + public function matchReference(string $referenceText): bool { + foreach ($this->row->getMatchPatternsList() as $pattern) { + if (@preg_match($pattern, $referenceText) === 1) { + return true; + } + } + return false; + } + + public function resolveReference(string $referenceText): ?IReference { + if (!$this->matchReference($referenceText)) { + return null; + } + $exApp = Server::get(ExAppService::class)->getExApp($this->row->getAppid()); + if ($exApp === null || !$exApp->getEnabled()) { + return null; + } + $userId = Server::get(IUserSession::class)->getUser()?->getUID(); + try { + $response = Server::get(AppAPIService::class)->requestToExApp( + $exApp, ReferenceProviderService::RESOLVE_ROUTE, $userId, 'POST', + ['provider_id' => $this->getId(), 'reference' => $referenceText], + ); + } catch (Throwable $e) { + $this->logger->warning('ExApp reference resolve failed', ['exception' => $e]); + return null; + } + if (!$response instanceof IResponse || $response->getStatusCode() !== 200) { + return null; + } + $data = json_decode((string)$response->getBody(), true); + if (!is_array($data)) { + return null; + } + return self::buildReference($referenceText, $data); + } + + private static function buildReference(string $referenceText, array $data): Reference { + $reference = new Reference($referenceText); + if (isset($data['title']) && is_string($data['title'])) { + $reference->setTitle($data['title']); + } + if (isset($data['description']) && is_string($data['description'])) { + $reference->setDescription($data['description']); + } + if (isset($data['image_url']) && is_string($data['image_url'])) { + $reference->setImageUrl($data['image_url']); + } + $reference->setUrl(isset($data['url']) && is_string($data['url']) ? $data['url'] : $referenceText); + if (isset($data['rich_object_type']) && is_string($data['rich_object_type']) && $data['rich_object_type'] !== '') { + $richObject = isset($data['rich_object']) && is_array($data['rich_object']) ? $data['rich_object'] : []; + $reference->setRichObject($data['rich_object_type'], $richObject); + } + return $reference; + } + + public function getCachePrefix(string $referenceId): string { + return $referenceId; + } + + public function getCacheKey(string $referenceId): ?string { + // The ExApp answers per user, so the result is cached per user. + return Server::get(IUserSession::class)->getUser()?->getUID() ?? ''; + } + }; + } +} diff --git a/openapi-full.json b/openapi-full.json index e18b1cfd2..c40c21dc3 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -577,6 +577,57 @@ } } }, + "ReferenceProvider": { + "type": "object", + "required": [ + "id", + "appid", + "provider_id", + "title", + "icon_url", + "order", + "search_providers_ids", + "match_patterns", + "script_path" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "appid": { + "type": "string" + }, + "provider_id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "search_providers_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "match_patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "script_path": { + "type": "string" + } + } + }, "Script": { "type": "object", "required": [ @@ -7377,6 +7428,364 @@ } } }, + "/ocs/v2.php/apps/app_api/api/v1/ui/reference-provider": { + "post": { + "operationId": "ocs_ui-register-ex-app-reference-provider", + "summary": "Register a reference provider for the calling ExApp", + "description": "The provider appears in the Smart Picker and resolves matching links into rich previews. A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp.", + "tags": [ + "ocs_ui" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "providerId", + "title" + ], + "properties": { + "providerId": { + "type": "string", + "description": "Unique ID of the provider; the picker script registers its custom element under this ID" + }, + "title": { + "type": "string", + "description": "Title in the Smart Picker" + }, + "iconUrl": { + "type": "string", + "default": "", + "description": "Icon path inside the ExApp, or an absolute URL" + }, + "order": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Position in the Smart Picker; lower comes first" + }, + "searchProvidersIds": { + "type": "array", + "default": [], + "description": "Unified search providers that the picker can use for this provider", + "items": { + "type": "string" + } + }, + "matchPatterns": { + "type": "array", + "default": [], + "description": "PCRE patterns of links that this provider resolves", + "items": { + "type": "string" + } + }, + "scriptPath": { + "type": "string", + "default": "", + "description": "Path of the picker script inside the ExApp; empty for no custom picker element" + } + } + } + } + } + }, + "parameters": [ + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Reference provider registered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "400": { + "description": "Invalid input, or the provider could not be registered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "ocs_ui-unregister-ex-app-reference-provider", + "summary": "Unregister a reference provider of the calling ExApp", + "tags": [ + "ocs_ui" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "providerId", + "in": "query", + "description": "ID of the provider to remove", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Reference provider unregistered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Reference provider not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "get": { + "operationId": "ocs_ui-get-ex-app-reference-provider", + "summary": "Get a reference provider of the calling ExApp", + "tags": [ + "ocs_ui" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "providerId", + "in": "query", + "description": "ID of the provider", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Reference provider returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ReferenceProvider" + } + } + } + } + } + } + } + }, + "404": { + "description": "Reference provider not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/app_api/api/v1/ui/initial-state": { "post": { "operationId": "ocs_ui-set-ex-app-initial-state", diff --git a/openapi.json b/openapi.json index 9f281206b..78b7ad0fd 100644 --- a/openapi.json +++ b/openapi.json @@ -347,6 +347,57 @@ } } }, + "ReferenceProvider": { + "type": "object", + "required": [ + "id", + "appid", + "provider_id", + "title", + "icon_url", + "order", + "search_providers_ids", + "match_patterns", + "script_path" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "appid": { + "type": "string" + }, + "provider_id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "search_providers_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "match_patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "script_path": { + "type": "string" + } + } + }, "Script": { "type": "object", "required": [ @@ -3528,6 +3579,364 @@ } } }, + "/ocs/v2.php/apps/app_api/api/v1/ui/reference-provider": { + "post": { + "operationId": "ocs_ui-register-ex-app-reference-provider", + "summary": "Register a reference provider for the calling ExApp", + "description": "The provider appears in the Smart Picker and resolves matching links into rich previews. A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp.", + "tags": [ + "ocs_ui" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "providerId", + "title" + ], + "properties": { + "providerId": { + "type": "string", + "description": "Unique ID of the provider; the picker script registers its custom element under this ID" + }, + "title": { + "type": "string", + "description": "Title in the Smart Picker" + }, + "iconUrl": { + "type": "string", + "default": "", + "description": "Icon path inside the ExApp, or an absolute URL" + }, + "order": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Position in the Smart Picker; lower comes first" + }, + "searchProvidersIds": { + "type": "array", + "default": [], + "description": "Unified search providers that the picker can use for this provider", + "items": { + "type": "string" + } + }, + "matchPatterns": { + "type": "array", + "default": [], + "description": "PCRE patterns of links that this provider resolves", + "items": { + "type": "string" + } + }, + "scriptPath": { + "type": "string", + "default": "", + "description": "Path of the picker script inside the ExApp; empty for no custom picker element" + } + } + } + } + } + }, + "parameters": [ + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Reference provider registered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "400": { + "description": "Invalid input, or the provider could not be registered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "ocs_ui-unregister-ex-app-reference-provider", + "summary": "Unregister a reference provider of the calling ExApp", + "tags": [ + "ocs_ui" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "providerId", + "in": "query", + "description": "ID of the provider to remove", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Reference provider unregistered", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Reference provider not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "get": { + "operationId": "ocs_ui-get-ex-app-reference-provider", + "summary": "Get a reference provider of the calling ExApp", + "tags": [ + "ocs_ui" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "providerId", + "in": "query", + "description": "ID of the provider", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ex-app-id", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Reference provider returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ReferenceProvider" + } + } + } + } + } + } + } + }, + "404": { + "description": "Reference provider not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/app_api/api/v1/ui/initial-state": { "post": { "operationId": "ocs_ui-set-ex-app-initial-state", diff --git a/src/reference.js b/src/reference.js new file mode 100644 index 000000000..c103ae14f --- /dev/null +++ b/src/reference.js @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { loadState } from '@nextcloud/initial-state' +import { loadExAppPickers } from './referenceLoader.js' + +loadExAppPickers(loadState('app_api', 'ex_reference_pickers', []), window, document) diff --git a/src/referenceLoader.js b/src/referenceLoader.js new file mode 100644 index 000000000..e33d547aa --- /dev/null +++ b/src/referenceLoader.js @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Load the picker scripts of ExApp reference providers. + * + * Each ExApp script registers its custom picker element with + * `window._registerCustomPickerElement(providerId, ...)`. Before the script runs, the base URL of its + * ExApp is published in `window.OCA.AppAPI.referencePickers[providerId].baseUrl`, and the script tag + * carries `data-base-url` and `data-provider-id` (readable via `document.currentScript.dataset`). + * The base URL is `/exapps/` with HaRP, otherwise the AppAPI PHP proxy. + * + * @param {Array<{appid: string, provider_id: string, base_url: string, script_url: string}>} pickers from initial state + * @param {Window} win window to publish the base URLs on + * @param {Document} doc document to add the script tags to + */ +export function loadExAppPickers(pickers, win, doc) { + win.OCA ??= {} + win.OCA.AppAPI ??= {} + win.OCA.AppAPI.referencePickers ??= {} + const registry = win.OCA.AppAPI.referencePickers + + const loaded = new Set() + for (const picker of pickers) { + registry[picker.provider_id] = { appId: picker.appid, baseUrl: picker.base_url } + if (loaded.has(picker.script_url)) { + continue + } + loaded.add(picker.script_url) + + // AppAPI validates script paths on registration. Rows stored before that, or a bad base URL, must + // still not load a script from another ExApp or another origin. + if (!isInsideExApp(picker.script_url, picker.base_url, win)) { + continue + } + + const script = doc.createElement('script') + script.src = picker.script_url + script.async = false + script.dataset.appId = picker.appid + script.dataset.providerId = picker.provider_id + script.dataset.baseUrl = picker.base_url + doc.head.appendChild(script) + } +} + +/** + * Whether `scriptUrl` resolves (as the browser will, after `..` and encoding) to a path under `baseUrl` + * on the page's own origin. + * + * @param {string} scriptUrl the script URL + * @param {string} baseUrl the ExApp base URL + * @param {Window} win window giving the page origin + */ +export function isInsideExApp(scriptUrl, baseUrl, win) { + try { + const origin = win.location.origin + const script = new URL(scriptUrl, win.location.href) + const base = new URL(baseUrl.replace(/\/*$/, '/'), win.location.href) + return script.origin === origin && base.origin === origin && script.pathname.startsWith(base.pathname) + } catch { + return false + } +} diff --git a/src/referenceLoader.spec.js b/src/referenceLoader.spec.js new file mode 100644 index 000000000..bee004f04 --- /dev/null +++ b/src/referenceLoader.spec.js @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it } from 'vitest' +import { loadExAppPickers } from './referenceLoader.js' + +describe('loadExAppPickers', () => { + let win + + beforeEach(() => { + document.head.innerHTML = '' + win = { location: { origin: 'http://cloud.test', href: 'http://cloud.test/index.php/apps/text/' } } + }) + + const picker = (appid, providerId, scriptUrl) => ({ + appid, + provider_id: providerId, + base_url: '/exapps/' + appid, + script_url: scriptUrl, + }) + + it('publishes base URLs before the scripts are added', () => { + loadExAppPickers([picker('app1', 'p1', '/exapps/app1/js/a.js')], win, document) + + expect(win.OCA.AppAPI.referencePickers.p1).toEqual({ appId: 'app1', baseUrl: '/exapps/app1' }) + const script = document.head.querySelector('script') + expect(script.getAttribute('src')).toBe('/exapps/app1/js/a.js') + expect(script.dataset.providerId).toBe('p1') + expect(script.dataset.baseUrl).toBe('/exapps/app1') + expect(script.async).toBe(false) + }) + + it('adds a script shared by two providers only once, but registers both', () => { + loadExAppPickers([ + picker('app1', 'p1', '/exapps/app1/js/a.js'), + picker('app1', 'p2', '/exapps/app1/js/a.js'), + ], win, document) + + expect(document.head.querySelectorAll('script')).toHaveLength(1) + expect(Object.keys(win.OCA.AppAPI.referencePickers)).toEqual(['p1', 'p2']) + }) + + it('keeps existing OCA namespaces', () => { + win.OCA = { Files: { x: 1 } } + loadExAppPickers([], win, document) + expect(win.OCA.Files).toEqual({ x: 1 }) + expect(win.OCA.AppAPI.referencePickers).toEqual({}) + }) + + it.each([ + ['parent segment', '/exapps/app1/../app2/js/x.js'], + ['encoded parent segment', '/exapps/app1/js/%2e%2e/%2e%2e/app2/x.js'], + ['prefix of another app id', '/exapps/app10/js/x.js'], + ['protocol-relative', '//evil.test/x.js'], + ['other origin', 'https://evil.test/exapps/app1/x.js'], + ['javascript URL', 'javascript:alert(1)'], + ])('does not add a script that resolves outside the ExApp: %s', (_, scriptUrl) => { + loadExAppPickers([picker('app1', 'p1', scriptUrl)], win, document) + + expect(document.head.querySelectorAll('script')).toHaveLength(0) + }) +}) diff --git a/tests/php/Controller/OCSUiControllerTest.php b/tests/php/Controller/OCSUiControllerTest.php index bb156cf5a..6b0f7fd2c 100644 --- a/tests/php/Controller/OCSUiControllerTest.php +++ b/tests/php/Controller/OCSUiControllerTest.php @@ -12,6 +12,7 @@ use OCA\AppAPI\Controller\OCSUiController; use OCA\AppAPI\Service\UI\FilesActionsMenuService; use OCA\AppAPI\Service\UI\InitialStateService; +use OCA\AppAPI\Service\UI\ReferenceProviderService; use OCA\AppAPI\Service\UI\ScriptsService; use OCA\AppAPI\Service\UI\StylesService; use OCA\AppAPI\Service\UI\TopMenuService; @@ -66,6 +67,7 @@ protected function setUp(): void { $this->initialStateService, $this->scriptsService, $this->stylesService, + Server::get(ReferenceProviderService::class), ); $this->cleanupTestRows(); @@ -85,6 +87,7 @@ private function cleanupTestRows(): void { foreach (['ui_action_v1', 'ui_action_v2'] as $name) { $this->filesActionsService->unregisterFileActionMenu(self::TEST_APP_ID, $name); } + Server::get(ReferenceProviderService::class)->unregisterReferenceProvider(self::TEST_APP_ID, 'phpunit_ocs_ref'); foreach ([ ['top_menu', 'page_a', 'state_a'], ['top_menu', 'page_a', 'state_b'], @@ -163,6 +166,35 @@ public function testTopMenuRegisterIsIdempotentReplace(): void { self::assertSame(1, $data['admin_required']); } + public function testReferenceProviderRegisterGetUnregister(): void { + $this->controller->registerExAppReferenceProvider( + providerId: 'phpunit_ocs_ref', title: 'Ref', iconUrl: '/img/icon.svg', order: 3, + searchProvidersIds: ['s1'], matchPatterns: ['#^https://x\.test/#'], scriptPath: '/js/picker.js', + ); + + $data = self::asArray($this->controller->getExAppReferenceProvider('phpunit_ocs_ref')->getData()); + self::assertSame(self::TEST_APP_ID, $data['appid']); + self::assertSame('Ref', $data['title']); + self::assertSame(3, $data['order']); + self::assertSame(['s1'], $data['search_providers_ids']); + self::assertSame(['#^https://x\.test/#'], $data['match_patterns']); + self::assertSame('js/picker.js', $data['script_path'], 'leading slash should be stripped'); + + $this->controller->unregisterExAppReferenceProvider('phpunit_ocs_ref'); + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppReferenceProvider('phpunit_ocs_ref'); + } + + public function testReferenceProviderInvalidPatternIsBadRequest(): void { + $this->expectException(OCSBadRequestException::class); + $this->controller->registerExAppReferenceProvider(providerId: 'phpunit_ocs_ref', title: 'Ref', matchPatterns: ['/(']); + } + + public function testReferenceProviderUnregisterMissingThrowsNotFound(): void { + $this->expectException(OCSNotFoundException::class); + $this->controller->unregisterExAppReferenceProvider('does_not_exist'); + } + public function testFileActionsV1RegisterGetUnregister(): void { // Pass leading-slash on action_handler — FilesActionsMenuService::registerFileActionMenu strips it via ltrim. $response = $this->controller->registerFileActionMenu( diff --git a/tests/php/Service/ExAppServiceGetAppInfoTest.php b/tests/php/Service/ExAppServiceGetAppInfoTest.php index 8e052df8c..8958587f2 100644 --- a/tests/php/Service/ExAppServiceGetAppInfoTest.php +++ b/tests/php/Service/ExAppServiceGetAppInfoTest.php @@ -21,6 +21,7 @@ use OCA\AppAPI\Service\UI\FilesActionsMenuService; use OCA\AppAPI\Service\UI\InitialStateService; use OCA\AppAPI\Service\UI\ScriptsService; +use OCA\AppAPI\Service\UI\ReferenceProviderService; use OCA\AppAPI\Service\UI\SettingsService; use OCA\AppAPI\Service\UI\StylesService; use OCA\AppAPI\Service\UI\TopMenuService; @@ -65,6 +66,7 @@ protected function setUp(): void { $this->createMock(ExAppDeployOptionsService::class), $this->createMock(ExAppSetupCheckService::class), $this->createMock(IConfig::class), + $this->createMock(ReferenceProviderService::class), ); } diff --git a/tests/php/Service/ReferenceProviderServiceTest.php b/tests/php/Service/ReferenceProviderServiceTest.php new file mode 100644 index 000000000..4ed7c5381 --- /dev/null +++ b/tests/php/Service/ReferenceProviderServiceTest.php @@ -0,0 +1,195 @@ +service = Server::get(ReferenceProviderService::class); + $this->insertFakeExApp(self::APP_A, 23101); + $this->insertFakeExApp(self::APP_B, 23102); + $this->insertFakeExApp(self::APP_DISABLED, 23103, enabled: 0); + } + + protected function tearDown(): void { + foreach ([self::APP_A, self::APP_B, self::APP_DISABLED] as $appId) { + $this->service->unregisterExAppReferenceProviders($appId); + $this->deleteFakeExApp($appId); + } + parent::tearDown(); + } + + private function register(string $appId, string $providerId, array $patterns = [], array $search = []): void { + self::assertNotNull($this->service->registerReferenceProvider( + $appId, $providerId, 'Title ' . $providerId, '', 10, $search, $patterns, 'js/picker.js')); + } + + /** @return string[] */ + private function enabledIds(): array { + return array_map(static fn ($p) => $p->getProviderId(), $this->service->getEnabledReferenceProviders()); + } + + public function testOnlyProvidersOfEnabledExAppsAreListed(): void { + $this->register(self::APP_A, 'phpunit_rp_a'); + $this->register(self::APP_DISABLED, 'phpunit_rp_off'); + + $ids = $this->enabledIds(); + self::assertContains('phpunit_rp_a', $ids); + self::assertNotContains('phpunit_rp_off', $ids); + } + + public function testListingReflectsUnregisterDespiteCache(): void { + $this->register(self::APP_A, 'phpunit_rp_a'); + self::assertContains('phpunit_rp_a', $this->enabledIds()); + + self::assertTrue($this->service->unregisterReferenceProvider(self::APP_A, 'phpunit_rp_a')); + self::assertNotContains('phpunit_rp_a', $this->enabledIds()); + } + + public function testProviderIdOfAnotherAppIsRejected(): void { + $this->register(self::APP_A, 'phpunit_rp_shared'); + + $this->expectException(\InvalidArgumentException::class); + $this->service->registerReferenceProvider(self::APP_B, 'phpunit_rp_shared', 'Hijack', '', 0, [], [], ''); + } + + public function testOtherAppCannotReadOrDeleteProvider(): void { + $this->register(self::APP_A, 'phpunit_rp_a'); + + self::assertNull($this->service->getReferenceProvider(self::APP_B, 'phpunit_rp_a')); + self::assertFalse($this->service->unregisterReferenceProvider(self::APP_B, 'phpunit_rp_a')); + self::assertNotNull($this->service->getReferenceProvider(self::APP_A, 'phpunit_rp_a')); + } + + public function testInvalidPatternIsRejected(): void { + $this->expectException(\InvalidArgumentException::class); + $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Bad', '', 0, [], ['/unclosed('], ''); + } + + public function testInvalidProviderIdIsRejected(): void { + $this->expectException(\InvalidArgumentException::class); + $this->service->registerReferenceProvider(self::APP_A, 'has space', 'Bad', '', 0, [], [], ''); + } + + /** + * Each of these, appended to /exapps//, makes the browser load a file outside the ExApp: + * another ExApp's script (`..`, encoded or not) or another origin (`\host`, schemes). + */ + public static function escapingPaths(): array { + return [ + 'parent' => ['../other_app/js/x.js'], + 'nested parent' => ['js/../../other_app/x.js'], + 'encoded parent' => ['js/%2e%2e/x.js'], + 'encoded slash' => ['js%2f..%2fx.js'], + 'backslash host' => ['\\evil.example/x.js'], + 'scheme' => ['https://evil.example/x.js'], + 'javascript' => ['javascript:alert(1)'], + 'query' => ['js/x.js?x=1'], + 'empty segment' => ['js//x.js'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('escapingPaths')] + public function testScriptPathEscapingTheExAppIsRejected(string $path): void { + $this->expectException(\InvalidArgumentException::class); + $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Bad', '', 0, [], [], $path); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('escapingPaths')] + public function testRelativeIconUrlEscapingTheExAppIsRejected(string $path): void { + if (str_starts_with($path, 'https://')) { + self::assertNotNull($this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Ok', $path, 0, [], [], '')); + return; // absolute http(s) icons are allowed + } + $this->expectException(\InvalidArgumentException::class); + $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Bad', $path, 0, [], [], ''); + } + + public function testScriptPathInsideTheExAppIsAccepted(): void { + $row = $this->service->registerReferenceProvider( + self::APP_A, 'phpunit_rp_a', 'Ok', '/img/app.svg', 0, [], [], '/js/caption_obvious-reference.js'); + self::assertSame('js/caption_obvious-reference.js', $row->getScriptPath()); + } + + public function testReRegisterReplacesFields(): void { + $this->register(self::APP_A, 'phpunit_rp_a', ['#^https://a\.test/#']); + $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_a', 'Renamed', '', 5, [], ['#^https://b\.test/#'], ''); + + $row = $this->service->getReferenceProvider(self::APP_A, 'phpunit_rp_a'); + self::assertSame('Renamed', $row->getTitle()); + self::assertSame(['#^https://b\.test/#'], $row->getMatchPatternsList()); + self::assertSame('', $row->getScriptPath()); + } + + public function testAnonymousProviderMatchesOnlyItsPatterns(): void { + $this->register(self::APP_A, 'phpunit_rp_a', ['#^https://a\.test/item/\d+$#', '#^https://alt\.test/#'], ['phpunit-search']); + $provider = $this->service->getAnonymousReferenceProvider( + $this->service->getReferenceProvider(self::APP_A, 'phpunit_rp_a')); + + self::assertSame('phpunit_rp_a', $provider->getId()); + self::assertSame('Title phpunit_rp_a', $provider->getTitle()); + self::assertSame(10, $provider->getOrder()); + self::assertInstanceOf(ISearchableReferenceProvider::class, $provider); + self::assertSame(['phpunit-search'], $provider->getSupportedSearchProviderIds()); + + self::assertTrue($provider->matchReference('https://a.test/item/42')); + self::assertTrue($provider->matchReference('https://alt.test/anything')); + self::assertFalse($provider->matchReference('https://a.test/item/42/extra')); + self::assertFalse($provider->matchReference('https://other.test/item/42')); + } + + public function testProviderWithoutPatternsMatchesNothingAndResolvesNothing(): void { + $this->register(self::APP_A, 'phpunit_rp_a'); + $provider = $this->service->getAnonymousReferenceProvider( + $this->service->getReferenceProvider(self::APP_A, 'phpunit_rp_a')); + + self::assertFalse($provider->matchReference('https://a.test/item/1')); + // No request to the (non-existent) ExApp for a link that does not match. + self::assertNull($provider->resolveReference('https://a.test/item/1')); + } + + public function testCachePrefixSeparatesLinks(): void { + $this->register(self::APP_A, 'phpunit_rp_a', ['#^https://a\.test/#']); + $provider = $this->service->getAnonymousReferenceProvider( + $this->service->getReferenceProvider(self::APP_A, 'phpunit_rp_a')); + + self::assertNotSame( + $provider->getCachePrefix('https://a.test/1'), + $provider->getCachePrefix('https://a.test/2'), + ); + } + + public function testUnregisterAllRemovesOnlyThatApp(): void { + $this->register(self::APP_A, 'phpunit_rp_a1'); + $this->register(self::APP_A, 'phpunit_rp_a2'); + $this->register(self::APP_B, 'phpunit_rp_b'); + + self::assertSame(2, $this->service->unregisterExAppReferenceProviders(self::APP_A)); + $ids = $this->enabledIds(); + self::assertNotContains('phpunit_rp_a1', $ids); + self::assertContains('phpunit_rp_b', $ids); + } +} diff --git a/webpack.js b/webpack.js index a5a151cad..f547485ae 100644 --- a/webpack.js +++ b/webpack.js @@ -29,6 +29,7 @@ const appId = 'app_api' webpackConfig.entry = { adminSettings: { import: path.join(__dirname, 'src', 'adminSettings.js'), filename: appId + '-adminSettings.js' }, filesplugin: { import: path.join(__dirname, 'src', 'filesplugin.js'), filename: appId + '-filesplugin.js' }, + reference: { import: path.join(__dirname, 'src', 'reference.js'), filename: appId + '-reference.js' }, } webpackConfig.plugins.push( From 775d18b72e41db11a1912e1d0862afc475ef95e8 Mon Sep 17 00:00:00 2001 From: meatybot Date: Fri, 30 Oct 2026 14:00:27 +0000 Subject: [PATCH 2/3] fix(ui): harden ExApp reference providers after review Assisted-by: omp:claude-opus-5-5 Signed-off-by: meatybot --- lib/Controller/OCSUiController.php | 8 +- lib/Listener/RenderReferenceListener.php | 19 +- lib/Service/UI/ReferenceProviderService.php | 198 +++++++++++++++--- openapi-full.json | 8 +- openapi.json | 8 +- .../Service/ReferenceProviderResolveTest.php | 120 +++++++++++ .../Service/ReferenceProviderServiceTest.php | 66 +++++- 7 files changed, 371 insertions(+), 56 deletions(-) create mode 100644 tests/php/Service/ReferenceProviderResolveTest.php diff --git a/lib/Controller/OCSUiController.php b/lib/Controller/OCSUiController.php index e8fcf6694..55099c2be 100644 --- a/lib/Controller/OCSUiController.php +++ b/lib/Controller/OCSUiController.php @@ -238,13 +238,15 @@ public function getExAppMenuEntry(string $name): DataResponse { * * The provider appears in the Smart Picker and resolves matching links into rich previews. * A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp. + * Every link a user pastes or renders that matches the patterns is sent to the ExApp, with the user's ID, + * so the patterns must name the ExApp's own hosts. * * @param string $providerId Unique ID of the provider; the picker script registers its custom element under this ID * @param string $title Title in the Smart Picker - * @param string $iconUrl Icon path inside the ExApp, or an absolute URL + * @param string $iconUrl Icon path inside the ExApp, served through HaRP or the AppAPI proxy * @param int $order Position in the Smart Picker; lower comes first - * @param list $searchProvidersIds Unified search providers that the picker can use for this provider - * @param list $matchPatterns PCRE patterns of links that this provider resolves + * @param list $searchProvidersIds Unified search providers that the picker can use for this provider, at most 10 + * @param list $matchPatterns PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\.com/item/\d+$#` * @param string $scriptPath Path of the picker script inside the ExApp; empty for no custom picker element * * @return DataResponse, array{}> diff --git a/lib/Listener/RenderReferenceListener.php b/lib/Listener/RenderReferenceListener.php index 626bb94d2..990a5a30a 100644 --- a/lib/Listener/RenderReferenceListener.php +++ b/lib/Listener/RenderReferenceListener.php @@ -26,6 +26,10 @@ * The scripts must be on the page when the picker opens: the picker hides a provider that has neither * a search provider nor a registered custom element. * + * The scripts load for every user on these pages and run with that user's session, the same trust given to + * scripts registered through {@see \OCA\AppAPI\Service\UI\ScriptsService}. An admin who installs an ExApp + * trusts its frontend code as much as a PHP app's. + * * @template-implements IEventListener */ class RenderReferenceListener implements IEventListener { @@ -52,7 +56,9 @@ public function handle(Event $event): void { if ($exApp === null) { continue; } - $baseUrl = $this->getExAppBaseUrl($provider->getAppid(), HarpService::isHarp($exApp->getDeployConfig())); + $baseUrl = ReferenceProviderService::getExAppBaseUrl( + $this->urlGenerator, $provider->getAppid(), HarpService::isHarp($exApp->getDeployConfig()) + ); $pickers[] = [ 'appid' => $provider->getAppid(), 'provider_id' => $provider->getProviderId(), @@ -67,15 +73,4 @@ public function handle(Event $event): void { $this->initialState->provideInitialState('ex_reference_pickers', $pickers); Util::addScript(Application::APP_ID, Application::APP_ID . '-reference'); } - - /** - * Base URL the browser uses to reach the ExApp: HaRP serves `/exapps/` directly, - * otherwise the request goes through the AppAPI PHP proxy. - */ - private function getExAppBaseUrl(string $appId, bool $isHarp): string { - if ($isHarp) { - return rtrim($this->urlGenerator->getWebroot(), '/') . '/exapps/' . $appId; - } - return rtrim($this->urlGenerator->linkToRoute('app_api.ExAppProxy.ExAppGet', ['appId' => $appId, 'other' => '']), '/'); - } } diff --git a/lib/Service/UI/ReferenceProviderService.php b/lib/Service/UI/ReferenceProviderService.php index 8cd2406cb..d82ab4ac9 100644 --- a/lib/Service/UI/ReferenceProviderService.php +++ b/lib/Service/UI/ReferenceProviderService.php @@ -14,6 +14,7 @@ use OCA\AppAPI\Db\UI\ReferenceProviderMapper; use OCA\AppAPI\Service\AppAPIService; use OCA\AppAPI\Service\ExAppService; +use OCA\AppAPI\Service\HarpService; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -25,6 +26,7 @@ use OCP\Http\Client\IResponse; use OCP\ICache; use OCP\ICacheFactory; +use OCP\IConfig; use OCP\IURLGenerator; use OCP\IUserSession; use OCP\Server; @@ -42,19 +44,53 @@ class ReferenceProviderService { public const RESOLVE_ROUTE = '/reference/resolve'; private const CACHE_KEY = '/ex_ui_reference_providers'; + // A cache that is not shared between processes (APCu) is not cleared by `occ` or by another PHP-FPM worker, + // so entries expire on their own. With a shared cache, writes clear the entry and the TTL is only a backstop. + private const CACHE_TTL = 300; + private const CACHE_TTL_PROCESS_LOCAL = 10; + // A failed resolve is remembered so a broken ExApp is not called on every render. + public const FAILURE_TTL = 300; + public const RESOLVE_TIMEOUT = 3; + + public const MAX_MATCH_PATTERNS = 10; + public const MAX_PATTERN_LENGTH = 256; + public const MAX_SEARCH_PROVIDERS = 10; + // Lengths of the columns in Version036000Date20261024120000. + private const MAX_ICON_LENGTH = 255; + private const MAX_SCRIPT_PATH_LENGTH = 255; + private const MAX_SEARCH_PROVIDER_ID_LENGTH = 64; + + // The pattern body must start with `^https://` (or `^https?://`) and a literal host, so a provider only + // ever sees links to hosts it names. `/.*/` would otherwise send every link a user pastes to the ExApp. + private const HOST_ANCHOR = '~^\^https\??://((?:[a-z0-9-]+\\\.)+[a-z]{2,63})(?:$|/|\\/|:|\(|\$)~i'; private ?ICache $cache = null; + private int $cacheTtl = self::CACHE_TTL; public function __construct( private readonly ReferenceProviderMapper $mapper, private readonly LoggerInterface $logger, ICacheFactory $cacheFactory, + IConfig $config, ) { if ($cacheFactory->isAvailable()) { $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_ui_reference_providers'); + if (self::isProcessLocalCache($config)) { + $this->cacheTtl = self::CACHE_TTL_PROCESS_LOCAL; + } } } + /** + * Whether the "distributed" cache is really APCu, which every PHP process keeps on its own. + * Same check as {@see ExAppService}. + */ + private static function isProcessLocalCache(IConfig $config): bool { + $distributed = ltrim($config->getSystemValueString('memcache.distributed', ''), '\\'); + $local = ltrim($config->getSystemValueString('memcache.local', ''), '\\'); + return ($distributed === '' ? $local : $distributed) === \OC\Memcache\APCu::class; + } + /** * @param list $searchProvidersIds * @param list $matchPatterns @@ -71,22 +107,28 @@ public function registerReferenceProvider( if (trim($title) === '' || mb_strlen($title) > 128) { throw new \InvalidArgumentException('title must be 1-128 characters'); } + if (count($matchPatterns) > self::MAX_MATCH_PATTERNS) { + throw new \InvalidArgumentException(sprintf('at most %d match patterns', self::MAX_MATCH_PATTERNS)); + } foreach ($matchPatterns as $pattern) { - if (!is_string($pattern) || @preg_match($pattern, '') === false) { - throw new \InvalidArgumentException(sprintf('Invalid match pattern: %s', (string)$pattern)); - } + self::validateMatchPattern($pattern); + } + if (count($searchProvidersIds) > self::MAX_SEARCH_PROVIDERS) { + throw new \InvalidArgumentException(sprintf('at most %d search providers', self::MAX_SEARCH_PROVIDERS)); } foreach ($searchProvidersIds as $searchProviderId) { - if (!is_string($searchProviderId) || $searchProviderId === '') { - throw new \InvalidArgumentException('searchProvidersIds must be a list of non-empty strings'); + if (!is_string($searchProviderId) || $searchProviderId === '' || strlen($searchProviderId) > self::MAX_SEARCH_PROVIDER_ID_LENGTH) { + throw new \InvalidArgumentException(sprintf('searchProvidersIds must be strings of 1-%d characters', self::MAX_SEARCH_PROVIDER_ID_LENGTH)); } } $scriptPath = ltrim($scriptPath, '/'); - if ($scriptPath !== '' && !self::isSafeRelativePath($scriptPath)) { + if ($scriptPath !== '' && !self::isSafeRelativePath($scriptPath, self::MAX_SCRIPT_PATH_LENGTH)) { throw new \InvalidArgumentException('scriptPath must be a relative path inside the ExApp, without ".." segments or a scheme'); } - if ($iconUrl !== '' && !preg_match('#^https?://#i', $iconUrl) && !self::isSafeRelativePath(ltrim($iconUrl, '/'))) { - throw new \InvalidArgumentException('iconUrl must be an http(s) URL or a relative path inside the ExApp, without ".." segments'); + // Only icons served by the ExApp: an outside URL would make every user's browser contact a third party. + $iconUrl = ltrim($iconUrl, '/'); + if ($iconUrl !== '' && !self::isSafeRelativePath($iconUrl, self::MAX_ICON_LENGTH)) { + throw new \InvalidArgumentException('iconUrl must be a relative path inside the ExApp, without ".." segments or a scheme'); } $existing = $this->findByProviderId($providerId); @@ -111,6 +153,13 @@ public function registerReferenceProvider( $entity = $this->mapper->insertOrUpdate($entity); $this->resetCacheEnabled(); return $entity; + } catch (\InvalidArgumentException $e) { + // Another ExApp inserted the same provider ID between the ownership check and this insert: + // insertOrUpdate() then falls back to update() on an entity without ID. + if ($existing === null && $this->findByProviderId($providerId)?->getAppid() !== $appId) { + throw new \InvalidArgumentException(sprintf('Provider ID "%s" belongs to another app', $providerId), 0, $e); + } + throw $e; } catch (Exception|\JsonException $e) { $this->logger->error( sprintf('Failed to register ExApp %s reference provider %s. Error: %s', $appId, $providerId, $e->getMessage()), @@ -151,8 +200,8 @@ public function getReferenceProvider(string $appId, string $providerId): ?Refere * (plain or percent-encoded) and `\` like `/`, so `../other_app/x.js` would load another ExApp's * script and `\host` could leave the origin. Allowed: `js/picker.js`, `js/fonts/a.woff2`. */ - public static function isSafeRelativePath(string $path): bool { - if ($path === '' || strlen($path) > 255) { + public static function isSafeRelativePath(string $path, int $maxLength = 255): bool { + if ($path === '' || strlen($path) > $maxLength) { return false; } if (!preg_match('#^[A-Za-z0-9._~/-]+$#', $path)) { @@ -166,6 +215,50 @@ public static function isSafeRelativePath(string $path): bool { return true; } + /** + * A valid PCRE, at most MAX_PATTERN_LENGTH long, anchored to `^https?://` and a literal host. + * + * Every pattern of every provider runs on each link a user pastes, and a matching link is sent to the + * ExApp, so a pattern that matches any host would hand the ExApp links to anything, share links included. + * + * @throws \InvalidArgumentException + */ + public static function validateMatchPattern(mixed $pattern): void { + if (!is_string($pattern) || $pattern === '' || strlen($pattern) > self::MAX_PATTERN_LENGTH) { + throw new \InvalidArgumentException(sprintf('match patterns must be strings of 1-%d characters', self::MAX_PATTERN_LENGTH)); + } + if (@preg_match($pattern, '') === false) { + throw new \InvalidArgumentException(sprintf('Invalid match pattern: %s', $pattern)); + } + $body = self::patternBody($pattern); + if ($body === null || preg_match(self::HOST_ANCHOR, $body) !== 1) { + throw new \InvalidArgumentException(sprintf( + 'Match pattern must start with ^https:// and a literal host with escaped dots, like #^https://example\.com/#: %s', $pattern + )); + } + } + + /** + * The part of a PCRE between its delimiters, or null if it has none. + */ + private static function patternBody(string $pattern): ?string { + $open = $pattern[0]; + $close = ['(' => ')', '{' => '}', '[' => ']', '<' => '>'][$open] ?? $open; + $end = strrpos($pattern, $close); + return ($end === false || $end === 0) ? null : substr($pattern, 1, $end - 1); + } + + /** + * Base URL the browser uses to reach the ExApp: HaRP serves `/exapps/` directly, otherwise + * the request goes through the AppAPI PHP proxy. + */ + public static function getExAppBaseUrl(IURLGenerator $urlGenerator, string $appId, bool $isHarp): string { + if ($isHarp) { + return rtrim($urlGenerator->getWebroot(), '/') . '/exapps/' . $appId; + } + return rtrim($urlGenerator->linkToRoute('app_api.ExAppProxy.ExAppGet', ['appId' => $appId, 'other' => '']), '/'); + } + private function findByProviderId(string $providerId): ?ReferenceProvider { try { return $this->mapper->findByProviderId($providerId); @@ -184,7 +277,7 @@ public function getEnabledReferenceProviders(): array { $records = $this->cache?->get(self::CACHE_KEY); if ($records === null) { $records = $this->mapper->findAllEnabled(); - $this->cache?->set(self::CACHE_KEY, $records); + $this->cache?->set(self::CACHE_KEY, $records, $this->cacheTtl); } return array_map(static fn (array $record) => new ReferenceProvider($record), $records); } catch (Exception) { @@ -199,14 +292,19 @@ public function resetCacheEnabled(): void { /** * Register one server reference provider for each enabled ExApp provider. * - * The server takes a class name, so each provider gets a synthetic one in the AppAPI namespace; - * the server container sends `OCA\AppAPI\...` lookups to this app's container, where the - * anonymous object is registered. + * The server takes a class name, so each provider gets a synthetic service key in the AppAPI namespace; + * the server container sends `OCA\AppAPI\...` lookups to this app's container, where the anonymous + * object is registered. The key is not a real class name and is never autoloaded: the container uses it + * as an opaque string. Its last segment is a hash so that `.` and `-` of a provider ID stay out of it. + * + * This runs in `Application::register()` on every request, because the server has no event to collect + * reference providers lazily (as `GetTaskProcessingProvidersEvent` does for task processing). The list + * comes from the cache, so the database is queried only when the cache is empty or expired. */ public function registerExAppReferenceProviders(IRegistrationContext $context): void { foreach ($this->getEnabledReferenceProviders() as $row) { /** @var class-string $className */ - $className = 'OCA\\AppAPI\\ExAppReferenceProvider\\' . $row->getAppid() . '\\' . $row->getProviderId(); + $className = 'OCA\\AppAPI\\ExAppReferenceProvider\\P' . hash('xxh128', $row->getAppid() . '/' . $row->getProviderId()); $provider = $this->getAnonymousReferenceProvider($row); $context->registerService($className, static fn () => $provider); $context->registerReferenceProvider($className); @@ -217,10 +315,16 @@ public function registerExAppReferenceProviders(IRegistrationContext $context): * @psalm-suppress MissingDependency */ public function getAnonymousReferenceProvider(ReferenceProvider $row): ADiscoverableReferenceProvider { - return new class($row, $this->logger) extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider { + return new class($row, $this->logger, $this->cache) extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider { + private const MAX_TEXT_LENGTH = 1024; + private const MAX_URL_LENGTH = 2048; + private const MAX_RICH_OBJECT_TYPE_LENGTH = 64; + private const MAX_RICH_OBJECT_BYTES = 16384; + public function __construct( private readonly ReferenceProvider $row, private readonly LoggerInterface $logger, + private readonly ?ICache $cache, ) { } @@ -242,12 +346,15 @@ public function getIconUrl(): string { if ($icon === '') { return $urlGenerator->getAbsoluteURL($urlGenerator->imagePath(Application::APP_ID, 'app.svg')); } - if (preg_match('#^https?://#i', $icon)) { - return $icon; + $exApp = Server::get(ExAppService::class)->getExApp($this->row->getAppid()); + if ($exApp === null) { + return $urlGenerator->getAbsoluteURL($urlGenerator->imagePath(Application::APP_ID, 'app.svg')); } - return $urlGenerator->linkToRouteAbsolute( - 'app_api.ExAppProxy.ExAppGet', ['appId' => $this->row->getAppid(), 'other' => ltrim($icon, '/')] + // Same base URL as the picker script: HaRP's /exapps/ when available, the PHP proxy otherwise. + $base = ReferenceProviderService::getExAppBaseUrl( + $urlGenerator, $this->row->getAppid(), HarpService::isHarp($exApp->getDeployConfig()) ); + return $urlGenerator->getAbsoluteURL($base . '/' . ltrim($icon, '/')); } public function getSupportedSearchProviderIds(): array { @@ -267,6 +374,24 @@ public function resolveReference(string $referenceText): ?IReference { if (!$this->matchReference($referenceText)) { return null; } + $failureKey = $this->failureKey($referenceText); + if ($this->cache?->get($failureKey) !== null) { + return null; + } + $reference = $this->requestReference($referenceText); + if ($reference === null) { + // The server caches only successes, so remember the failure for a while: a slow or broken + // ExApp would otherwise be called, and waited for, on every render of the link. + $this->cache?->set($failureKey, true, ReferenceProviderService::FAILURE_TTL); + } + return $reference; + } + + private function failureKey(string $referenceText): string { + return 'failed/' . md5($this->getCachePrefix($referenceText) . '/' . ($this->getCacheKey($referenceText) ?? '')); + } + + private function requestReference(string $referenceText): ?IReference { $exApp = Server::get(ExAppService::class)->getExApp($this->row->getAppid()); if ($exApp === null || !$exApp->getEnabled()) { return null; @@ -276,6 +401,7 @@ public function resolveReference(string $referenceText): ?IReference { $response = Server::get(AppAPIService::class)->requestToExApp( $exApp, ReferenceProviderService::RESOLVE_ROUTE, $userId, 'POST', ['provider_id' => $this->getId(), 'reference' => $referenceText], + ['timeout' => ReferenceProviderService::RESOLVE_TIMEOUT], ); } catch (Throwable $e) { $this->logger->warning('ExApp reference resolve failed', ['exception' => $e]); @@ -294,24 +420,40 @@ public function resolveReference(string $referenceText): ?IReference { private static function buildReference(string $referenceText, array $data): Reference { $reference = new Reference($referenceText); if (isset($data['title']) && is_string($data['title'])) { - $reference->setTitle($data['title']); + $reference->setTitle(mb_substr($data['title'], 0, self::MAX_TEXT_LENGTH)); } if (isset($data['description']) && is_string($data['description'])) { - $reference->setDescription($data['description']); + $reference->setDescription(mb_substr($data['description'], 0, self::MAX_TEXT_LENGTH)); } - if (isset($data['image_url']) && is_string($data['image_url'])) { - $reference->setImageUrl($data['image_url']); + // The default widget binds these as href/src, so only http(s) URLs: no javascript: or data:. + $imageUrl = self::httpUrlOrNull($data['image_url'] ?? null); + if ($imageUrl !== null) { + $reference->setImageUrl($imageUrl); } - $reference->setUrl(isset($data['url']) && is_string($data['url']) ? $data['url'] : $referenceText); - if (isset($data['rich_object_type']) && is_string($data['rich_object_type']) && $data['rich_object_type'] !== '') { + $reference->setUrl(self::httpUrlOrNull($data['url'] ?? null) ?? $referenceText); + $type = $data['rich_object_type'] ?? null; + if (is_string($type) && preg_match('/^[a-zA-Z0-9_.-]{1,' . self::MAX_RICH_OBJECT_TYPE_LENGTH . '}$/', $type)) { $richObject = isset($data['rich_object']) && is_array($data['rich_object']) ? $data['rich_object'] : []; - $reference->setRichObject($data['rich_object_type'], $richObject); + // Cached for an hour per user and sent to every client: keep it small. + if (strlen((string)json_encode($richObject)) <= self::MAX_RICH_OBJECT_BYTES) { + $reference->setRichObject($type, $richObject); + } } return $reference; } + private static function httpUrlOrNull(mixed $url): ?string { + if (!is_string($url) || strlen($url) > self::MAX_URL_LENGTH) { + return null; + } + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + $host = parse_url($url, PHP_URL_HOST); + return (($scheme === 'http' || $scheme === 'https') && is_string($host) && $host !== '') ? $url : null; + } + public function getCachePrefix(string $referenceId): string { - return $referenceId; + // Namespaced, so a result cached for this provider is never served for another one. + return $this->row->getAppid() . '/' . $this->row->getProviderId() . '/' . $referenceId; } public function getCacheKey(string $referenceId): ?string { diff --git a/openapi-full.json b/openapi-full.json index c40c21dc3..b88586785 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -7432,7 +7432,7 @@ "post": { "operationId": "ocs_ui-register-ex-app-reference-provider", "summary": "Register a reference provider for the calling ExApp", - "description": "The provider appears in the Smart Picker and resolves matching links into rich previews. A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp.", + "description": "The provider appears in the Smart Picker and resolves matching links into rich previews. A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp. Every link a user pastes or renders that matches the patterns is sent to the ExApp, with the user's ID, so the patterns must name the ExApp's own hosts.", "tags": [ "ocs_ui" ], @@ -7467,7 +7467,7 @@ "iconUrl": { "type": "string", "default": "", - "description": "Icon path inside the ExApp, or an absolute URL" + "description": "Icon path inside the ExApp, served through HaRP or the AppAPI proxy" }, "order": { "type": "integer", @@ -7478,7 +7478,7 @@ "searchProvidersIds": { "type": "array", "default": [], - "description": "Unified search providers that the picker can use for this provider", + "description": "Unified search providers that the picker can use for this provider, at most 10", "items": { "type": "string" } @@ -7486,7 +7486,7 @@ "matchPatterns": { "type": "array", "default": [], - "description": "PCRE patterns of links that this provider resolves", + "description": "PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\\.com/item/\\d+$#`", "items": { "type": "string" } diff --git a/openapi.json b/openapi.json index 78b7ad0fd..b5f8416bd 100644 --- a/openapi.json +++ b/openapi.json @@ -3583,7 +3583,7 @@ "post": { "operationId": "ocs_ui-register-ex-app-reference-provider", "summary": "Register a reference provider for the calling ExApp", - "description": "The provider appears in the Smart Picker and resolves matching links into rich previews. A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp.", + "description": "The provider appears in the Smart Picker and resolves matching links into rich previews. A link that matches one of `matchPatterns` is resolved with `POST /reference/resolve` on the ExApp. Every link a user pastes or renders that matches the patterns is sent to the ExApp, with the user's ID, so the patterns must name the ExApp's own hosts.", "tags": [ "ocs_ui" ], @@ -3618,7 +3618,7 @@ "iconUrl": { "type": "string", "default": "", - "description": "Icon path inside the ExApp, or an absolute URL" + "description": "Icon path inside the ExApp, served through HaRP or the AppAPI proxy" }, "order": { "type": "integer", @@ -3629,7 +3629,7 @@ "searchProvidersIds": { "type": "array", "default": [], - "description": "Unified search providers that the picker can use for this provider", + "description": "Unified search providers that the picker can use for this provider, at most 10", "items": { "type": "string" } @@ -3637,7 +3637,7 @@ "matchPatterns": { "type": "array", "default": [], - "description": "PCRE patterns of links that this provider resolves", + "description": "PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\\.com/item/\\d+$#`", "items": { "type": "string" } diff --git a/tests/php/Service/ReferenceProviderResolveTest.php b/tests/php/Service/ReferenceProviderResolveTest.php new file mode 100644 index 000000000..c3025b921 --- /dev/null +++ b/tests/php/Service/ReferenceProviderResolveTest.php @@ -0,0 +1,120 @@ +insertFakeExApp(self::APP, 23111); + $this->service = Server::get(ReferenceProviderService::class); + $this->service->registerReferenceProvider(self::APP, self::PROVIDER, 'Resolve', '', 0, [], ['#^https://resolve\.test/#'], ''); + $this->appApi = $this->createMock(AppAPIService::class); + $this->overwriteService(AppAPIService::class, $this->appApi); + } + + protected function tearDown(): void { + $this->restoreAllServices(); + $this->service->unregisterExAppReferenceProviders(self::APP); + $this->deleteFakeExApp(self::APP); + parent::tearDown(); + } + + private function provider(): IReferenceProvider { + return $this->service->getAnonymousReferenceProvider($this->service->getReferenceProvider(self::APP, self::PROVIDER)); + } + + private function answer(array $body, int $status = 200): void { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn($status); + $response->method('getBody')->willReturn(json_encode($body)); + $this->appApi->method('requestToExApp')->willReturn($response); + } + + public function testUrlsThatAreNotHttpAreDropped(): void { + $this->answer([ + 'title' => 'x', + 'url' => 'javascript:alert(document.cookie)', + 'image_url' => 'data:image/svg+xml,', + ]); + $link = 'https://resolve.test/a'; + $reference = $this->provider()->resolveReference($link); + + self::assertNotNull($reference); + self::assertSame($link, $reference->getUrl(), 'falls back to the link itself'); + self::assertNull($reference->getImageUrl()); + } + + public function testHttpUrlsAreKept(): void { + $this->answer(['url' => 'https://resolve.test/canonical', 'image_url' => 'http://img.resolve.test/a.png']); + $reference = $this->provider()->resolveReference('https://resolve.test/b'); + + self::assertSame('https://resolve.test/canonical', $reference->getUrl()); + self::assertSame('http://img.resolve.test/a.png', $reference->getImageUrl()); + } + + public function testOversizedOrInvalidRichObjectIsDropped(): void { + $this->answer(['rich_object_type' => 'big', 'rich_object' => ['blob' => str_repeat('x', 20000)]]); + // No rich object: the reference falls back to the plain open-graph preview. + self::assertSame('open-graph', $this->provider()->resolveReference('https://resolve.test/c')->getRichObjectType()); + } + + public function testRichObjectTypeWithMarkupIsDropped(): void { + $this->answer(['rich_object_type' => '', 'rich_object' => ['a' => 1]]); + self::assertSame('open-graph', $this->provider()->resolveReference('https://resolve.test/d')->getRichObjectType()); + } + + public function testValidRichObjectIsKept(): void { + $this->answer(['rich_object_type' => 'phpunit_meme', 'rich_object' => ['slug' => 'abc']]); + $reference = $this->provider()->resolveReference('https://resolve.test/e'); + self::assertSame('phpunit_meme', $reference->getRichObjectType()); + self::assertSame(['slug' => 'abc'], $reference->getRichObject()); + } + + public function testFailureIsRememberedSoTheExAppIsNotCalledAgain(): void { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(500); + $this->appApi->expects(self::once())->method('requestToExApp')->willReturn($response); + $link = 'https://resolve.test/broken/' . uniqid(); + + self::assertNull($this->provider()->resolveReference($link)); + self::assertNull($this->provider()->resolveReference($link), 'second render is answered from the failure cache'); + } + + public function testRequestUsesABoundedTimeout(): void { + $this->appApi->expects(self::once())->method('requestToExApp') + ->with(self::anything(), '/reference/resolve', self::anything(), 'POST', self::anything(), + self::callback(static fn (array $options) => ($options['timeout'] ?? 0) === ReferenceProviderService::RESOLVE_TIMEOUT)) + ->willReturn(['error' => 'stub']); + $this->provider()->resolveReference('https://resolve.test/timeout/' . uniqid()); + } +} diff --git a/tests/php/Service/ReferenceProviderServiceTest.php b/tests/php/Service/ReferenceProviderServiceTest.php index 4ed7c5381..4d122b863 100644 --- a/tests/php/Service/ReferenceProviderServiceTest.php +++ b/tests/php/Service/ReferenceProviderServiceTest.php @@ -119,15 +119,71 @@ public function testScriptPathEscapingTheExAppIsRejected(string $path): void { } #[\PHPUnit\Framework\Attributes\DataProvider('escapingPaths')] - public function testRelativeIconUrlEscapingTheExAppIsRejected(string $path): void { - if (str_starts_with($path, 'https://')) { - self::assertNotNull($this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Ok', $path, 0, [], [], '')); - return; // absolute http(s) icons are allowed - } + public function testIconUrlOutsideTheExAppIsRejected(string $path): void { + // Absolute URLs included: an outside icon would make every user's browser contact a third party. $this->expectException(\InvalidArgumentException::class); $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Bad', $path, 0, [], [], ''); } + /** + * Patterns that would let a provider see links to hosts it does not name, or that are not anchored at all. + */ + public static function unanchoredPatterns(): array { + return [ + 'match everything' => ['/.*/'], + 'any https host' => ['#^https://.*$#'], + 'not anchored' => ['#https://example\.com/#'], + 'host as a class' => ['#^https://[a-z]+\.com/#'], + 'unescaped dot' => ['#^https://exam.le\.com/#'], + 'host then any suffix' => ['#^https://example\.com.evil\.net/#'], + 'no scheme' => ['#^example\.com/#'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unanchoredPatterns')] + public function testPatternNotAnchoredToAHostIsRejected(string $pattern): void { + $this->expectException(\InvalidArgumentException::class); + $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Bad', '', 0, [], [$pattern], ''); + } + + public function testHostAnchoredPatternsAreAccepted(): void { + $patterns = [ + '#^https://i\.imgflip\.com/[0-9a-z]+\.(jpg|png)$#', + '#^https?://example\.co\.uk(?:/|$)#', + '~^https://example\.com:8443/x~', + ]; + $row = $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_a', 'Ok', '', 0, [], $patterns, ''); + self::assertSame($patterns, $row->getMatchPatternsList()); + } + + public function testPatternCountAndLengthAreCapped(): void { + $ok = '#^https://example\.com/#'; + try { + $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Bad', '', 0, [], array_fill(0, 11, $ok), ''); + self::fail('11 patterns accepted'); + } catch (\InvalidArgumentException) { + } + $this->expectException(\InvalidArgumentException::class); + $this->service->registerReferenceProvider( + self::APP_A, 'phpunit_rp_bad', 'Bad', '', 0, [], ['#^https://example\.com/' . str_repeat('a', 250) . '#'], ''); + } + + public function testTooLongIconPathIsRejectedWithAClearError(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('iconUrl'); + $this->service->registerReferenceProvider(self::APP_A, 'phpunit_rp_bad', 'Bad', 'img/' . str_repeat('a', 252) . '.svg', 0, [], [], ''); + } + + public function testCachePrefixIsNamespacedPerProvider(): void { + $pattern = '#^https://shared\.test/#'; + $this->register(self::APP_A, 'phpunit_rp_a', [$pattern]); + $this->register(self::APP_B, 'phpunit_rp_b', [$pattern]); + $a = $this->service->getAnonymousReferenceProvider($this->service->getReferenceProvider(self::APP_A, 'phpunit_rp_a')); + $b = $this->service->getAnonymousReferenceProvider($this->service->getReferenceProvider(self::APP_B, 'phpunit_rp_b')); + + self::assertNotSame($a->getCachePrefix('https://shared.test/1'), $b->getCachePrefix('https://shared.test/1')); + } + public function testScriptPathInsideTheExAppIsAccepted(): void { $row = $this->service->registerReferenceProvider( self::APP_A, 'phpunit_rp_a', 'Ok', '/img/app.svg', 0, [], [], '/js/caption_obvious-reference.js'); From 55ae214f295bf94088321dd0dd98796a4e357968 Mon Sep 17 00:00:00 2001 From: meatybot Date: Fri, 6 Nov 2026 03:01:11 +0000 Subject: [PATCH 3/3] fix(ui): only match links on the host a reference pattern declares The host anchor check looked only at the start of a pattern, so a pattern like #^https://example\.com/|.*# passed and then matched every link. A pattern now matches a link only if the link's scheme and host are the ones the pattern declares. Assisted-by: omp:claude-opus-5-5 Signed-off-by: meatybot --- lib/Controller/OCSUiController.php | 2 +- lib/Service/UI/ReferenceProviderService.php | 46 ++++++++++++++++--- openapi-full.json | 2 +- openapi.json | 2 +- .../Service/ReferenceProviderServiceTest.php | 26 +++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/lib/Controller/OCSUiController.php b/lib/Controller/OCSUiController.php index 55099c2be..ac3e5d610 100644 --- a/lib/Controller/OCSUiController.php +++ b/lib/Controller/OCSUiController.php @@ -246,7 +246,7 @@ public function getExAppMenuEntry(string $name): DataResponse { * @param string $iconUrl Icon path inside the ExApp, served through HaRP or the AppAPI proxy * @param int $order Position in the Smart Picker; lower comes first * @param list $searchProvidersIds Unified search providers that the picker can use for this provider, at most 10 - * @param list $matchPatterns PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\.com/item/\d+$#` + * @param list $matchPatterns PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\.com/item/\d+$#`. A pattern only matches links on that scheme and host * @param string $scriptPath Path of the picker script inside the ExApp; empty for no custom picker element * * @return DataResponse, array{}> diff --git a/lib/Service/UI/ReferenceProviderService.php b/lib/Service/UI/ReferenceProviderService.php index d82ab4ac9..8d058e45e 100644 --- a/lib/Service/UI/ReferenceProviderService.php +++ b/lib/Service/UI/ReferenceProviderService.php @@ -60,9 +60,10 @@ class ReferenceProviderService { private const MAX_SCRIPT_PATH_LENGTH = 255; private const MAX_SEARCH_PROVIDER_ID_LENGTH = 64; - // The pattern body must start with `^https://` (or `^https?://`) and a literal host, so a provider only - // ever sees links to hosts it names. `/.*/` would otherwise send every link a user pastes to the ExApp. - private const HOST_ANCHOR = '~^\^https\??://((?:[a-z0-9-]+\\\.)+[a-z]{2,63})(?:$|/|\\/|:|\(|\$)~i'; + // The pattern body must start with `^https://` (or `^https?://`) and a literal host. That host is the only + // one the pattern may match: matchReference() checks the parsed link against it before running the pattern, + // because the rest of a PCRE (`|.*`, `\Q…\E`, `:.*@`) can reach any host. + private const HOST_ANCHOR = '~^\^(https\??)://((?:[a-z0-9-]+\\\.)+[a-z]{2,63})(?:$|/|\\/|:|\(|\$)~i'; private ?ICache $cache = null; private int $cacheTtl = self::CACHE_TTL; @@ -88,7 +89,8 @@ public function __construct( private static function isProcessLocalCache(IConfig $config): bool { $distributed = ltrim($config->getSystemValueString('memcache.distributed', ''), '\\'); $local = ltrim($config->getSystemValueString('memcache.local', ''), '\\'); - return ($distributed === '' ? $local : $distributed) === \OC\Memcache\APCu::class; + // The class name as a string: \OC\Memcache\APCu is private to the server. + return ($distributed === '' ? $local : $distributed) === 'OC\Memcache\APCu'; } /** @@ -220,6 +222,7 @@ public static function isSafeRelativePath(string $path, int $maxLength = 255): b * * Every pattern of every provider runs on each link a user pastes, and a matching link is sent to the * ExApp, so a pattern that matches any host would hand the ExApp links to anything, share links included. + * The anchor only declares the host; matchReference() enforces it. * * @throws \InvalidArgumentException */ @@ -230,14 +233,43 @@ public static function validateMatchPattern(mixed $pattern): void { if (@preg_match($pattern, '') === false) { throw new \InvalidArgumentException(sprintf('Invalid match pattern: %s', $pattern)); } - $body = self::patternBody($pattern); - if ($body === null || preg_match(self::HOST_ANCHOR, $body) !== 1) { + if (self::patternTarget($pattern) === null) { throw new \InvalidArgumentException(sprintf( 'Match pattern must start with ^https:// and a literal host with escaped dots, like #^https://example\.com/#: %s', $pattern )); } } + /** + * The schemes and lower-case host that a pattern declares in its `^https?://host` anchor, or null. + * + * @return array{schemes: list, host: string}|null + */ + public static function patternTarget(string $pattern): ?array { + $body = self::patternBody($pattern); + if ($body === null || preg_match(self::HOST_ANCHOR, $body, $m) !== 1) { + return null; + } + return [ + 'schemes' => strlen($m[1]) === 5 ? ['https'] : ['http', 'https'], + 'host' => strtolower(str_replace('\\.', '.', $m[2])), + ]; + } + + /** + * True if the link is on the scheme and host that the pattern declares, and the pattern matches it. + */ + public static function patternMatches(string $pattern, string $link): bool { + $target = self::patternTarget($pattern); + $parts = parse_url($link); + if ($target === null || !is_array($parts) || !isset($parts['scheme'], $parts['host']) + || !in_array(strtolower($parts['scheme']), $target['schemes'], true) + || strtolower($parts['host']) !== $target['host']) { + return false; + } + return @preg_match($pattern, $link) === 1; + } + /** * The part of a PCRE between its delimiters, or null if it has none. */ @@ -363,7 +395,7 @@ public function getSupportedSearchProviderIds(): array { public function matchReference(string $referenceText): bool { foreach ($this->row->getMatchPatternsList() as $pattern) { - if (@preg_match($pattern, $referenceText) === 1) { + if (ReferenceProviderService::patternMatches($pattern, $referenceText)) { return true; } } diff --git a/openapi-full.json b/openapi-full.json index b88586785..3f54c71da 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -7486,7 +7486,7 @@ "matchPatterns": { "type": "array", "default": [], - "description": "PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\\.com/item/\\d+$#`", + "description": "PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\\.com/item/\\d+$#`. A pattern only matches links on that scheme and host", "items": { "type": "string" } diff --git a/openapi.json b/openapi.json index b5f8416bd..bd73a0cd7 100644 --- a/openapi.json +++ b/openapi.json @@ -3637,7 +3637,7 @@ "matchPatterns": { "type": "array", "default": [], - "description": "PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\\.com/item/\\d+$#`", + "description": "PCRE patterns of links that this provider resolves, at most 10 of up to 256 characters. Each must start with `^https://` (or `^https?://`) and a literal host, like `#^https://example\\.com/item/\\d+$#`. A pattern only matches links on that scheme and host", "items": { "type": "string" } diff --git a/tests/php/Service/ReferenceProviderServiceTest.php b/tests/php/Service/ReferenceProviderServiceTest.php index 4d122b863..bf3f731f0 100644 --- a/tests/php/Service/ReferenceProviderServiceTest.php +++ b/tests/php/Service/ReferenceProviderServiceTest.php @@ -217,6 +217,32 @@ public function testAnonymousProviderMatchesOnlyItsPatterns(): void { self::assertFalse($provider->matchReference('https://other.test/item/42')); } + /** + * Valid PCREs whose anchor names a.test, but whose remainder can match a link on another host. + * + * @return array + */ + public static function patternsThatEscapeTheirHost(): array { + return [ + 'top-level alternative' => ['#^https://a\.test/|.*#', 'https://evil.test/x', 'https://a.test/x'], + 'empty alternative' => ['#^https://a\.test/|#', 'https://evil.test/x', 'https://a.test/x'], + 'alternative hidden in \Q...\E' => ['#^https://a\.test/\Q(\E|.*#', 'https://evil.test/x', 'https://a.test/x'], + 'multiline anchor' => ['#^https://a\.test/$|^.*#m', 'https://evil.test/x', 'https://a.test/'], + 'user info before the real host' => ['#^https://a\.test:.*#', 'https://a.test:x@evil.test/x', 'https://a.test:8443/x'], + 'other scheme' => ['#^https://a\.test/|^http://.*#', 'http://a.test/x', 'https://a.test/x'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('patternsThatEscapeTheirHost')] + public function testPatternMatchesOnlyLinksOnItsDeclaredHost(string $pattern, string $foreignLink, string $ownLink): void { + $this->register(self::APP_A, 'phpunit_rp_a', [$pattern]); + $provider = $this->service->getAnonymousReferenceProvider( + $this->service->getReferenceProvider(self::APP_A, 'phpunit_rp_a')); + + self::assertFalse($provider->matchReference($foreignLink)); + self::assertTrue($provider->matchReference($ownLink)); + } + public function testProviderWithoutPatternsMatchesNothingAndResolvesNothing(): void { $this->register(self::APP_A, 'phpunit_rp_a'); $provider = $this->service->getAnonymousReferenceProvider(