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..ac3e5d610 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,89 @@ 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. + * 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, 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+$#`. 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{}> + * @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..990a5a30a --- /dev/null +++ b/lib/Listener/RenderReferenceListener.php @@ -0,0 +1,76 @@ + + */ +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 = ReferenceProviderService::getExAppBaseUrl( + $this->urlGenerator, $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'); + } +} 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..8d058e45e --- /dev/null +++ b/lib/Service/UI/ReferenceProviderService.php @@ -0,0 +1,497 @@ +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', ''), '\\'); + // The class name as a string: \OC\Memcache\APCu is private to the server. + return ($distributed === '' ? $local : $distributed) === 'OC\Memcache\APCu'; + } + + /** + * @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'); + } + if (count($matchPatterns) > self::MAX_MATCH_PATTERNS) { + throw new \InvalidArgumentException(sprintf('at most %d match patterns', self::MAX_MATCH_PATTERNS)); + } + foreach ($matchPatterns as $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 === '' || 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, self::MAX_SCRIPT_PATH_LENGTH)) { + throw new \InvalidArgumentException('scriptPath must be a relative path inside the ExApp, without ".." segments or a scheme'); + } + // 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); + 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 (\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()), + ['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, int $maxLength = 255): bool { + if ($path === '' || strlen($path) > $maxLength) { + 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; + } + + /** + * 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. + * The anchor only declares the host; matchReference() enforces it. + * + * @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)); + } + 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. + */ + 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); + } 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, $this->cacheTtl); + } + 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 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\\P' . hash('xxh128', $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, $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, + ) { + } + + 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')); + } + $exApp = Server::get(ExAppService::class)->getExApp($this->row->getAppid()); + if ($exApp === null) { + return $urlGenerator->getAbsoluteURL($urlGenerator->imagePath(Application::APP_ID, 'app.svg')); + } + // 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 { + return $this->row->getSearchProvidersIdsList(); + } + + public function matchReference(string $referenceText): bool { + foreach ($this->row->getMatchPatternsList() as $pattern) { + if (ReferenceProviderService::patternMatches($pattern, $referenceText)) { + return true; + } + } + return false; + } + + 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; + } + $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], + ['timeout' => ReferenceProviderService::RESOLVE_TIMEOUT], + ); + } 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(mb_substr($data['title'], 0, self::MAX_TEXT_LENGTH)); + } + if (isset($data['description']) && is_string($data['description'])) { + $reference->setDescription(mb_substr($data['description'], 0, self::MAX_TEXT_LENGTH)); + } + // 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(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'] : []; + // 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 { + // 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 { + // 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..3f54c71da 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. 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" + ], + "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, served through HaRP or the AppAPI proxy" + }, + "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, at most 10", + "items": { + "type": "string" + } + }, + "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+$#`. A pattern only matches links on that scheme and host", + "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..bd73a0cd7 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. 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" + ], + "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, served through HaRP or the AppAPI proxy" + }, + "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, at most 10", + "items": { + "type": "string" + } + }, + "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+$#`. A pattern only matches links on that scheme and host", + "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/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 new file mode 100644 index 000000000..bf3f731f0 --- /dev/null +++ b/tests/php/Service/ReferenceProviderServiceTest.php @@ -0,0 +1,277 @@ +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 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'); + 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')); + } + + /** + * 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( + $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(