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,