diff --git a/composer.lock b/composer.lock index 18585b0d4f..d986c1217e 100644 --- a/composer.lock +++ b/composer.lock @@ -264,12 +264,12 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "aebdbace84b4c0b55517174fd39bc64c4b3a47d9" + "reference": "57eba542a591eb3c3c093cd12b0dce770132adda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/aebdbace84b4c0b55517174fd39bc64c4b3a47d9", - "reference": "aebdbace84b4c0b55517174fd39bc64c4b3a47d9", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/57eba542a591eb3c3c093cd12b0dce770132adda", + "reference": "57eba542a591eb3c3c093cd12b0dce770132adda", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/master" }, - "time": "2026-06-18T02:35:58+00:00" + "time": "2026-06-19T02:51:56+00:00" }, { "name": "psr/clock", @@ -620,12 +620,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "6b43b34b35cef5e93f19f389daf029845e391351" + "reference": "bfdbd26a02c71b3b00586f056c8cf5efea22a27a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/6b43b34b35cef5e93f19f389daf029845e391351", - "reference": "6b43b34b35cef5e93f19f389daf029845e391351", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/bfdbd26a02c71b3b00586f056c8cf5efea22a27a", + "reference": "bfdbd26a02c71b3b00586f056c8cf5efea22a27a", "shasum": "" }, "conflict": { @@ -731,9 +731,9 @@ "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cardgate/magento2": "<2.0.33", "cardgate/woocommerce": "<=3.1.15", - "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", "cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4", - "cartalyst/sentry": "<=2.1.6", + "cartalyst/sentry": "<2.1.7", "catfan/medoo": "<1.7.5", "causal/oidc": "<4", "cecil/cecil": "<7.47.1", @@ -771,12 +771,13 @@ "coreshop/core-shop": "<4.1.9|==5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", + "cotonti/cotonti": "<=1", "couleurcitron/tarteaucitron-wp": "<0.3", "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", "craftcms/aws-s3": ">=2.0.2,<=2.2.4", "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", - "craftcms/cms": "<4.17.12|>=5,<5.9.18", - "craftcms/commerce": ">=4,<4.11|>=5,<5.6", + "craftcms/cms": "<4.18|>=5,<5.10", + "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", @@ -831,7 +832,7 @@ "drupal/commerce_alphabank_redirect": "<1.0.3", "drupal/commerce_eurobank_redirect": "<2.1.1", "drupal/config_split": "<1.10|>=2,<2.0.2", - "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.9|>=10.6,<10.6.7|>=11,<11.2.11|>=11.3,<11.3.7", + "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.10|>=10.6,<10.6.9|>=11,<11.2.12|>=11.3,<11.3.10", "drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", "drupal/currency": "<3.5", "drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", @@ -952,10 +953,10 @@ "georgringer/news": "<10.0.4|>=11,<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", - "getgrav/grav": "<=2.0.0.0-RC1", + "getgrav/grav": "<=2.0.0.0-RC8", "getgrav/grav-plugin-api": "<1.0.0.0-beta15", "getgrav/grav-plugin-form": "<9.1", - "getkirby/cms": "<=4.9|>=5,<=5.4", + "getkirby/cms": "<=4.9.3|>=5,<=5.4.3", "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", "getkirby/panel": "<2.5.14", "getkirby/starterkit": "<=3.7.0.2", @@ -972,10 +973,10 @@ "gregwar/rst": "<1.0.3", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", - "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", + "guzzlehttp/guzzle": "<7.12.1", "guzzlehttp/guzzle-services": "<1.5.4", "guzzlehttp/oauth-subscriber": "<0.8.1", - "guzzlehttp/psr7": "<2.10.2", + "guzzlehttp/psr7": "<2.12.1", "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", "handcraftedinthealps/goodby-csv": "<1.4.3", "harvesthq/chosen": "<1.8.7", @@ -1030,6 +1031,7 @@ "jasig/phpcas": "<1.3.3", "jbartels/wec-map": "<3.0.3", "jcbrand/converse.js": "<3.3.3", + "jleehr/canto-saas-api": "<=2", "joedolson/my-calendar": "<3.7.7", "joelbutcher/socialstream": "<5.6|>=6,<6.2", "johnbillion/query-monitor": "<3.20.4", @@ -1251,7 +1253,7 @@ "phenx/php-svg-lib": "<0.5.2", "php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5", "php-mod/curl": "<2.3.2", - "phpbb/phpbb": "<3.3.11", + "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1", "phpems/phpems": ">=6,<=6.1.3", "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", "phpmailer/phpmailer": "<6.5", @@ -1279,7 +1281,7 @@ "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=12.3.6", + "pimcore/pimcore": "<=12.3.8", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", @@ -1303,8 +1305,8 @@ "prestashop/ps_linklist": "<3.1", "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", "processwire/processwire": "<=3.0.255", - "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", - "propel/propel1": ">=1,<=1.7.1", + "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8", + "propel/propel1": ">=1,<1.7.2", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", "pterodactyl/panel": "<1.12.3", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", @@ -1367,7 +1369,7 @@ "silverstripe/assets": "<2.4.5|>=3,<3.1.3", "silverstripe/cms": "<4.11.3", "silverstripe/comments": ">=1.3,<3.1.1", - "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", + "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4", "silverstripe/framework": "<5.3.23", "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", @@ -1485,7 +1487,9 @@ "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/ux-autocomplete": "<2.36|>=3,<3.1", + "symfony/ux-icons": ">=2.17,<2.36.1|>=3,<3.2", "symfony/ux-live-component": "<2.36|>=3,<3.1", + "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2", "symfony/ux-twig-component": "<2.25.1", "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", @@ -1587,7 +1591,8 @@ "web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4", "web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4", "web-feet/coastercms": "==5.5", - "web-token/jwt-framework": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", + "web-token/jwt-experimental": "<=4.1.6", + "web-token/jwt-framework": "<=4.2.99", "web-token/jwt-library": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", "web-tp3/wec_map": "<3.0.3", "webbuilders-group/silverstripe-kapost-bridge": "<0.4", @@ -1714,7 +1719,7 @@ "type": "tidelift" } ], - "time": "2026-06-17T19:44:27+00:00" + "time": "2026-06-19T21:28:22+00:00" } ], "aliases": [], diff --git a/eslint.config.mjs b/eslint.config.mjs index db96bf43ed..362ccce0aa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,11 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import js from '@eslint/js' +import { FlatCompat } from '@eslint/eslintrc' import nextcloudConfig from '@nextcloud/eslint-config' -import globals from 'globals' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const compat = new FlatCompat({ + baseDirectory: dirname(fileURLToPath(import.meta.url)), + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}) + +const compatConfigs = (Array.isArray(nextcloudConfig) ? nextcloudConfig : [nextcloudConfig]) + .flatMap((config) => compat.config(config)) export default [ - ...(Array.isArray(nextcloudConfig) ? nextcloudConfig : [nextcloudConfig]), + ...compatConfigs, { name: 'libresign/ignores', diff --git a/img/preview_signature.png b/img/preview_signature.png new file mode 100644 index 0000000000..48d2d9398f Binary files /dev/null and b/img/preview_signature.png differ diff --git a/lib/Activity/Provider/SignRequest.php b/lib/Activity/Provider/SignRequest.php index 46221df43c..ab68777025 100644 --- a/lib/Activity/Provider/SignRequest.php +++ b/lib/Activity/Provider/SignRequest.php @@ -83,9 +83,11 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): private function getParsedSubject(\OCP\IL10N $l, string $subject) { if ($subject === 'new_sign_request') { + // TRANSLATORS Activity message shown to a signer. {from} is the requester name and {file} is the document filename. return $l->t('{from} requested your signature on {file}'); } elseif ($subject === 'update_sign_request') { - return $l->t('{from} made changes on {file}'); + // TRANSLATORS Activity message shown when an existing signature request was updated. {from} is the requester name and {file} is the document filename. + return $l->t('{from} updated the signature request for {file}'); } } } diff --git a/lib/Activity/Provider/SignRequestCanceled.php b/lib/Activity/Provider/SignRequestCanceled.php index b4a2cdda33..d6ada87595 100644 --- a/lib/Activity/Provider/SignRequestCanceled.php +++ b/lib/Activity/Provider/SignRequestCanceled.php @@ -66,6 +66,7 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): $l = $this->languageFactory->get(Application::APP_ID, $language); $parameters = $event->getSubjectParameters(); + // TRANSLATORS Activity message shown when a signature request is canceled. {from} is the requester name and {file} is the document filename. $subject = $l->t('{from} canceled the signature request for {file}'); $event->setParsedSubject( str_replace( diff --git a/lib/Activity/Provider/Signed.php b/lib/Activity/Provider/Signed.php index 69bb921a35..c48fe9d0f9 100644 --- a/lib/Activity/Provider/Signed.php +++ b/lib/Activity/Provider/Signed.php @@ -84,6 +84,7 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): $l = $this->languageFactory->get(Application::APP_ID, $language); $parameters = $event->getSubjectParameters(); + // TRANSLATORS Activity message shown after a signer completes the document. {from} is the signer name and {file} is the document filename. $subject = $l->t('{from} signed {file}'); $event->setParsedSubject( str_replace( diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a454447802..7aa3640063 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -60,7 +60,6 @@ public function register(IRegistrationContext $context): void { $context->registerNotifierService(Notifier::class); $context->registerSearchProvider(FileSearchProvider::class); - $context->registerEventListener(LoadSidebar::class, TemplateLoader::class); $context->registerEventListener(BeforeNodeDeletedEvent::class, BeforeNodeDeletedListener::class); $context->registerEventListener(CacheEntryRemovedEvent::class, BeforeNodeDeletedListener::class); diff --git a/lib/Capabilities.php b/lib/Capabilities.php index b024f13ee7..c3c6abcd6c 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -8,13 +8,13 @@ namespace OCA\Libresign; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Service\Envelope\EnvelopeService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Confetti\ConfettiPolicy; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\App\IAppManager; use OCP\Capabilities\IPublicCapability; -use OCP\IAppConfig; /** * @psalm-import-type LibresignCapabilities from ResponseDefinitions @@ -29,7 +29,7 @@ public function __construct( protected SignatureTextService $signatureTextService, protected IAppManager $appManager, protected EnvelopeService $envelopeService, - protected IAppConfig $appConfig, + protected PolicyService $policyService, ) { } @@ -43,7 +43,7 @@ public function getCapabilities(): array { $capabilities = [ 'features' => self::FEATURES, 'config' => [ - 'show-confetti' => $this->appConfig->getValueBool(Application::APP_ID, 'show_confetti_after_signing', true), + 'show-confetti' => $this->policyService->resolve(ConfettiPolicy::KEY)->getEffectiveValueAsBool(true), 'sign-elements' => [ 'is-available' => $this->signerElementsService->isSignElementsAvailable(), 'can-create-signature' => $this->signerElementsService->canCreateSignature(), diff --git a/lib/Collaboration/Collaborators/AccountPhonePlugin.php b/lib/Collaboration/Collaborators/AccountPhonePlugin.php index 7ba65b498b..0608212f7a 100644 --- a/lib/Collaboration/Collaborators/AccountPhonePlugin.php +++ b/lib/Collaboration/Collaborators/AccountPhonePlugin.php @@ -11,6 +11,7 @@ use OC\KnownUser\KnownUserService; use OCA\Libresign\Service\Identify\SearchNormalizer; use OCA\Libresign\Service\Identify\SignerSearchContext; +use OCA\Libresign\Service\IdentifyMethodService; use OCP\Accounts\IAccountManager; use OCP\Collaboration\Collaborators\ISearchPlugin; use OCP\Collaboration\Collaborators\ISearchResult; @@ -22,7 +23,6 @@ class AccountPhonePlugin implements ISearchPlugin { public const TYPE_SIGNER_ACCOUNT_PHONE = 51; - private const array PHONE_BASED_METHODS = ['whatsapp', 'sms', 'telegram', 'signal']; public function __construct( private IAppConfig $appConfig, @@ -44,7 +44,7 @@ public function search($search, $limit, $offset, ISearchResult $searchResult): b $method = $this->searchContext->getMethod(); $search = trim((string)$search); - if ($search === '' || !in_array($method, self::PHONE_BASED_METHODS, true)) { + if ($search === '' || !in_array($method, IdentifyMethodService::IDENTIFY_PHONE_METHODS, true)) { return false; } diff --git a/lib/Collaboration/Collaborators/ContactPhonePlugin.php b/lib/Collaboration/Collaborators/ContactPhonePlugin.php index 7de02e151c..8420c4e6a3 100644 --- a/lib/Collaboration/Collaborators/ContactPhonePlugin.php +++ b/lib/Collaboration/Collaborators/ContactPhonePlugin.php @@ -11,6 +11,7 @@ use OC\KnownUser\KnownUserService; use OCA\Libresign\Service\Identify\SearchNormalizer; use OCA\Libresign\Service\Identify\SignerSearchContext; +use OCA\Libresign\Service\IdentifyMethodService; use OCP\Collaboration\Collaborators\ISearchPlugin; use OCP\Collaboration\Collaborators\ISearchResult; use OCP\Collaboration\Collaborators\SearchResultType; @@ -22,7 +23,6 @@ class ContactPhonePlugin implements ISearchPlugin { public const TYPE_SIGNER_CONTACT_PHONE = 52; - private const array PHONE_BASED_METHODS = ['whatsapp', 'sms', 'telegram', 'signal']; public function __construct( private IAppConfig $appConfig, @@ -44,7 +44,7 @@ public function search($search, $limit, $offset, ISearchResult $searchResult): b $method = $this->searchContext->getMethod(); $search = trim((string)$search); - if ($search === '' || !in_array($method, self::PHONE_BASED_METHODS, true)) { + if ($search === '' || !in_array($method, IdentifyMethodService::IDENTIFY_PHONE_METHODS, true)) { return false; } diff --git a/lib/Collaboration/Collaborators/ManualPhonePlugin.php b/lib/Collaboration/Collaborators/ManualPhonePlugin.php index 4301e596d7..5af44cfa3c 100644 --- a/lib/Collaboration/Collaborators/ManualPhonePlugin.php +++ b/lib/Collaboration/Collaborators/ManualPhonePlugin.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Collaboration\Collaborators; use OCA\Libresign\Service\Identify\SignerSearchContext; +use OCA\Libresign\Service\IdentifyMethodService; use OCP\Collaboration\Collaborators\ISearchPlugin; use OCP\Collaboration\Collaborators\ISearchResult; use OCP\Collaboration\Collaborators\SearchResultType; @@ -17,7 +18,6 @@ class ManualPhonePlugin implements ISearchPlugin { public const TYPE_SIGNER_MANUAL_PHONE = 53; - private const array PHONE_BASED_METHODS = ['whatsapp', 'sms', 'telegram', 'signal']; public function __construct( private IConfig $config, @@ -34,7 +34,7 @@ public function search($search, $limit, $offset, ISearchResult $searchResult): b $method = $this->searchContext->getMethod(); $search = trim((string)$search); - if ($search === '' || !in_array($method, self::PHONE_BASED_METHODS, true)) { + if ($search === '' || !in_array($method, IdentifyMethodService::IDENTIFY_PHONE_METHODS, true)) { return false; } diff --git a/lib/Command/Developer/Reset.php b/lib/Command/Developer/Reset.php index 18b58dc58b..a17a563b77 100644 --- a/lib/Command/Developer/Reset.php +++ b/lib/Command/Developer/Reset.php @@ -96,6 +96,12 @@ protected function configure(): void { mode: InputOption::VALUE_NONE, description: 'Reset config' ) + ->addOption( + name: 'policy', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Reset policy data' + ) ; } @@ -140,6 +146,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->resetConfig(); $ok = true; } + if ($input->getOption('policy') || $all) { + $this->resetPolicy(); + $ok = true; + } } catch (\Exception $e) { $this->logger->error($e->getMessage()); throw $e; @@ -254,4 +264,23 @@ private function resetConfig(): void { } catch (\Throwable) { } } + + private function resetPolicy(): void { + try { + $delete = $this->db->getQueryBuilder(); + $delete->delete('libresign_permission_set_binding') + ->executeStatement(); + + $delete = $this->db->getQueryBuilder(); + $delete->delete('libresign_permission_set') + ->executeStatement(); + + $delete = $this->db->getQueryBuilder(); + $delete->delete('preferences') + ->where($delete->expr()->eq('appid', $delete->createNamedParameter(Application::APP_ID))) + ->andWhere($delete->expr()->like('configkey', $delete->createNamedParameter('policy.%'))) + ->executeStatement(); + } catch (\Throwable) { + } + } } diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 46726a0b43..d419de581f 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -8,35 +8,24 @@ namespace OCA\Libresign\Controller; -use DateTimeInterface; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Controller\Traits\UploadValidator; use OCA\Libresign\Db\FileMapper; -use OCA\Libresign\Enum\DocMdpLevel; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\CertificateEngine\IEngineHandler; -use OCA\Libresign\Helper\ConfigureCheckHelper; use OCA\Libresign\Service\Certificate\ValidateService; use OCA\Libresign\Service\CertificatePolicyService; -use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; -use OCA\Libresign\Service\FooterService; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\Install\InstallService; -use OCA\Libresign\Service\ReminderService; use OCA\Libresign\Service\SetupCheckResultService; use OCA\Libresign\Service\SignatureBackgroundService; -use OCA\Libresign\Service\SignatureTextService; -use OCA\Libresign\Settings\Admin; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; -use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\ContentSecurityPolicy; -use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; -use OCP\Files\SimpleFS\InMemoryFile; use OCP\IAppConfig; use OCP\IEventSource; use OCP\IEventSourceFactory; @@ -57,16 +46,14 @@ * @psalm-import-type LibresignEngineHandler from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignIdentifyMethodSetting from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignMessageResponse from \OCA\Libresign\ResponseDefinitions - * @psalm-import-type LibresignSignatureTextSettingsResponse from \OCA\Libresign\ResponseDefinitions - * @psalm-import-type LibresignSignatureTemplateSettingsResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignSuccessStatusResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignFailureStatusResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignActiveSigningsResponse from \OCA\Libresign\ResponseDefinitions - * @psalm-import-type LibresignReminderSettings from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignRootCertificate from \OCA\Libresign\ResponseDefinitions - * @psalm-import-type LibresignFooterTemplateResponse from \OCA\Libresign\ResponseDefinitions */ class AdminController extends AEnvironmentAwareController { + use UploadValidator; + private IEventSource $eventSource; public function __construct( IRequest $request, @@ -74,15 +61,11 @@ public function __construct( private InstallService $installService, private CertificateEngineFactory $certificateEngineFactory, private IEventSourceFactory $eventSourceFactory, - private SignatureTextService $signatureTextService, - private IL10N $l10n, + protected IL10N $l10n, protected ISession $session, private SignatureBackgroundService $signatureBackgroundService, private CertificatePolicyService $certificatePolicyService, private ValidateService $validateService, - private ReminderService $reminderService, - private FooterService $footerService, - private DocMdpConfigService $docMdpConfigService, private IdentifyMethodService $identifyMethodService, private FileMapper $fileMapper, private SetupCheckResultService $setupCheckResultService, @@ -184,14 +167,15 @@ public function setCertificateEngine(string $engine): DataResponse { ); } - $handler = $this->certificateEngineFactory->getEngine(); - $handler->setEngine($engine); + $this->certificateEngineFactory->setEngine($engine); $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings(); - - return new DataResponse([ + /** @var LibresignCertificateEngineConfigResponse $response */ + $response = [ 'engine' => $engine, 'identify_methods' => $identifyMethods, - ]); + ]; + + return new DataResponse($response); } private function generateCertificate( @@ -234,14 +218,8 @@ private function generateCertificate( #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/certificate', requirements: ['apiVersion' => '(v1)'])] public function loadCertificate(): DataResponse { $engine = $this->certificateEngineFactory->getEngine(); - /** @var LibresignEngineHandler */ + /** @var LibresignCertificateDataGenerated */ $certificate = $engine->toArray(); - $configureResult = $engine->configureCheck(); - $success = array_filter( - $configureResult, - fn (ConfigureCheckHelper $config) => $config->getStatus() === 'success' - ); - $certificate['generated'] = count($success) === count($configureResult); return new DataResponse($certificate); } @@ -263,27 +241,6 @@ public function configureCheck(): DataResponse { return new DataResponse($checks); } - /** - * Disable hate limit to current session - * - * This will disable hate limit to current session. - * - * @return DataResponse - * - * 200: OK - */ - #[NoCSRFRequired] - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/disable-hate-limit', requirements: ['apiVersion' => '(v1)'])] - public function disableHateLimit(): DataResponse { - $this->session->set('app_api', true); - - // TODO: Remove after drop support NC29 - // deprecated since AppAPI 2.8.0 - $this->session->set('app_api_system', true); - - return new DataResponse(); - } - /** * @IgnoreOpenAPI */ @@ -343,29 +300,9 @@ public function installAndValidate(): void { #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])] public function signatureBackgroundSave(): DataResponse { $image = $this->request->getUploadedFile('image'); - $phpFileUploadErrors = [ - UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), - UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), - UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), - UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), - UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), - UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), - UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), - UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), - ]; - if (empty($image)) { - $error = $this->l10n->t('No file uploaded'); - } elseif (!empty($image) && array_key_exists('error', $image) && $image['error'] !== UPLOAD_ERR_OK) { - $error = $phpFileUploadErrors[$image['error']]; - } - if ($error !== null) { - return new DataResponse( - [ - 'message' => $error, - 'status' => 'failure', - ], - Http::STATUS_UNPROCESSABLE_ENTITY - ); + $uploadError = $this->validateUploadedFile($image, 'image'); + if ($uploadError !== null) { + return $uploadError; } try { $this->signatureBackgroundService->updateImage($image['tmp_name']); @@ -409,23 +346,6 @@ public function signatureBackgroundGet(): FileDisplayResponse { return $response; } - /** - * Reset the background image to be the default of LibreSign - * - * @return DataResponse - * - * 200: Image reseted to default - */ - #[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/admin/signature-background', requirements: ['apiVersion' => '(v1)'])] - public function signatureBackgroundReset(): DataResponse { - $this->signatureBackgroundService->reset(); - return new DataResponse( - [ - 'status' => 'success', - ] - ); - } - /** * Delete background image * @@ -444,182 +364,20 @@ public function signatureBackgroundDelete(): DataResponse { } /** - * Save signature text service - * - * @param string $template Template to signature text - * @param float $templateFontSize Font size used when print the parsed text of this template at PDF file - * @param float $signatureFontSize Font size used when the signature mode is SIGNAME_AND_DESCRIPTION - * @param float $signatureWidth Signature box width, minimum 1 - * @param float $signatureHeight Signature box height, minimum 1 - * @param string $renderMode Signature render mode - * @return DataResponse|DataResponse - * - * 200: OK - * 400: Bad request - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-text', requirements: ['apiVersion' => '(v1)'])] - public function signatureTextSave( - string $template, - /** @todo openapi package don't evaluate SignatureTextService::TEMPLATE_DEFAULT_FONT_SIZE */ - float $templateFontSize = 10, - /** @todo openapi package don't evaluate SignatureTextService::SIGNATURE_DEFAULT_FONT_SIZE */ - float $signatureFontSize = 20, - /** @todo openapi package don't evaluate SignatureTextService::DEFAULT_SIGNATURE_WIDTH */ - float $signatureWidth = 350, - /** @todo openapi package don't evaluate SignatureTextService::DEFAULT_SIGNATURE_HEIGHT */ - float $signatureHeight = 100, - string $renderMode = 'GRAPHIC_AND_DESCRIPTION', - ): DataResponse { - try { - $return = $this->signatureTextService->save( - $template, - $templateFontSize, - $signatureFontSize, - $signatureWidth, - $signatureHeight, - $renderMode, - ); - return new DataResponse( - $return, - Http::STATUS_OK - ); - } catch (LibresignException $th) { - return new DataResponse( - [ - 'error' => $th->getMessage(), - ], - Http::STATUS_BAD_REQUEST - ); - } - } - - /** - * Get parsed signature text service - * - * @param string $template Template to signature text - * @param string $context Context for parsing the template - * @return DataResponse|DataResponse - * - * 200: OK - * 400: Bad request - */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signature-text', requirements: ['apiVersion' => '(v1)'])] - public function signatureTextGet(string $template = '', string $context = ''): DataResponse { - $context = json_decode($context, true) ?? []; - try { - $return = $this->signatureTextService->parse($template, $context); - return new DataResponse( - $return, - Http::STATUS_OK - ); - } catch (LibresignException $th) { - return new DataResponse( - [ - 'error' => $th->getMessage(), - ], - Http::STATUS_BAD_REQUEST - ); - } - } - - /** - * Get signature settings - * - * @return DataResponse - * - * 200: OK - */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signature-settings', requirements: ['apiVersion' => '(v1)'])] - public function getSignatureSettings(): DataResponse { - $response = [ - 'signature_available_variables' => $this->signatureTextService->getAvailableVariables(), - 'default_signature_text_template' => $this->signatureTextService->getDefaultTemplate(), - ]; - return new DataResponse($response); - } - - /** - * Convert signer name as image - * - * @param int $width Image width, - * @param int $height Image height - * @param string $text Text to be added to image - * @param float $fontSize Font size of text - * @param bool $isDarkTheme Color of text, white if is tark theme and black if not - * @param string $align Align of text: left, center or right - * @return FileDisplayResponse|DataResponse - * - * 200: OK - * 400: Bad request - */ - #[NoCSRFRequired] - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/signer-name', requirements: ['apiVersion' => '(v1)'])] - public function signerName( - int $width, - int $height, - string $text, - float $fontSize, - bool $isDarkTheme, - string $align, - ): FileDisplayResponse|DataResponse { - try { - $blob = $this->signatureTextService->signerNameImage( - width: $width, - height: $height, - text: $text, - fontSize: $fontSize, - isDarkTheme: $isDarkTheme, - align: $align, - ); - $file = new InMemoryFile('signer-name.png', $blob); - return new FileDisplayResponse($file, Http::STATUS_OK, [ - 'Content-Disposition' => 'inline; filename="signer-name.png"', - 'Content-Type' => 'image/png', - ]); - } catch (LibresignException $th) { - return new DataResponse( - [ - 'error' => $th->getMessage(), - ], - Http::STATUS_BAD_REQUEST - ); - } - } - - /** - * Update certificate policy of this instance + * Upload new certificate policy PDF for this instance * * @return DataResponse|DataResponse * * 200: OK - * 422: Not found + * 422: Upload or validation error */ #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/certificate-policy', requirements: ['apiVersion' => '(v1)'])] public function saveCertificatePolicy(): DataResponse { + // Handle POST method - upload PDF $pdf = $this->request->getUploadedFile('pdf'); - $phpFileUploadErrors = [ - UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), - UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), - UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), - UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), - UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), - UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), - UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), - UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), - ]; - if (empty($pdf)) { - $error = $this->l10n->t('No file uploaded'); - } elseif (!empty($pdf) && array_key_exists('error', $pdf) && $pdf['error'] !== UPLOAD_ERR_OK) { - $error = $phpFileUploadErrors[$pdf['error']]; - } - if ($error !== null) { - return new DataResponse( - [ - 'message' => $error, - 'status' => 'failure', - ], - Http::STATUS_UNPROCESSABLE_ENTITY - ); + $uploadError = $this->validateUploadedFile($pdf, 'pdf'); + if ($uploadError !== null) { + return $uploadError; } try { $cps = $this->certificatePolicyService->updateFile($pdf['tmp_name']); @@ -643,15 +401,25 @@ public function saveCertificatePolicy(): DataResponse { /** * Delete certificate policy of this instance * - * @return DataResponse + * @return DataResponse|DataResponse * * 200: OK - * 404: Not found + * 422: Error */ #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/certificate-policy', requirements: ['apiVersion' => '(v1)'])] public function deleteCertificatePolicy(): DataResponse { - $this->certificatePolicyService->deleteFile(); - return new DataResponse(); + try { + $this->certificatePolicyService->deleteFile(); + } catch (\Exception $e) { + return new DataResponse( + [ + 'message' => $e->getMessage(), + 'status' => 'failure', + ], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + return new DataResponse(['status' => 'success']); } /** @@ -683,393 +451,6 @@ public function updateOid(string $oid): DataResponse { } } - /** - * Get reminder settings - * - * @return DataResponse - * - * 200: OK - */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/reminder', requirements: ['apiVersion' => '(v1)'])] - public function reminderFetch(): DataResponse { - $response = $this->reminderService->getSettings(); - if ($response['next_run'] instanceof \DateTime) { - $response['next_run'] = $response['next_run']->format(DateTimeInterface::ATOM); - } - return new DataResponse($response); - } - - /** - * Save reminder - * - * @param int $daysBefore First reminder after (days) - * @param int $daysBetween Days between reminders - * @param int $max Max reminders per signer - * @param string $sendTimer Send time (HH:mm) - * @return DataResponse - * - * 200: OK - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/reminder', requirements: ['apiVersion' => '(v1)'])] - public function reminderSave( - int $daysBefore, - int $daysBetween, - int $max, - string $sendTimer, - ): DataResponse { - $response = $this->reminderService->save($daysBefore, $daysBetween, $max, $sendTimer); - if ($response['next_run'] instanceof \DateTime) { - $response['next_run'] = $response['next_run']->format(DateTimeInterface::ATOM); - } - return new DataResponse($response); - } - - /** - * Set TSA configuration values with proper sensitive data handling - * - * Only saves configuration if tsa_url is provided. Automatically manages - * username/password fields based on authentication type. - * - * @param string|null $tsa_url TSA server URL (required for saving) - * @param string|null $tsa_policy_oid TSA policy OID - * @param string|null $tsa_auth_type Authentication type (none|basic), defaults to 'none' - * @param string|null $tsa_username Username for basic authentication - * @param string|null $tsa_password Password for basic authentication (stored as sensitive data) - * @return DataResponse|DataResponse - * - * 200: OK - * 400: Validation error - */ - #[NoCSRFRequired] - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/tsa', requirements: ['apiVersion' => '(v1)'])] - public function setTsaConfig( - ?string $tsa_url = null, - ?string $tsa_policy_oid = null, - ?string $tsa_auth_type = null, - ?string $tsa_username = null, - ?string $tsa_password = null, - ): DataResponse { - if (empty($tsa_url)) { - return $this->deleteTsaConfig(); - } - - $trimmedUrl = trim($tsa_url); - if (!filter_var($trimmedUrl, FILTER_VALIDATE_URL) - || !in_array(parse_url($trimmedUrl, PHP_URL_SCHEME), ['http', 'https'])) { - return new DataResponse([ - 'status' => 'error', - 'message' => 'Invalid URL format' - ], Http::STATUS_BAD_REQUEST); - } - - $this->appConfig->setValueString(Application::APP_ID, 'tsa_url', $trimmedUrl); - - if (empty($tsa_policy_oid)) { - $this->appConfig->deleteKey(Application::APP_ID, 'tsa_policy_oid'); - } else { - $trimmedOid = trim($tsa_policy_oid); - if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $trimmedOid)) { - return new DataResponse([ - 'status' => 'error', - 'message' => 'Invalid OID format' - ], Http::STATUS_BAD_REQUEST); - } - $this->appConfig->setValueString(Application::APP_ID, 'tsa_policy_oid', $trimmedOid); - } - - $authType = $tsa_auth_type ?? 'none'; - $this->appConfig->setValueString(Application::APP_ID, 'tsa_auth_type', $authType); - - if ($authType === 'basic') { - $hasUsername = !empty($tsa_username); - $hasPassword = !empty($tsa_password) && $tsa_password !== Admin::PASSWORD_PLACEHOLDER; - - if (!$hasUsername && !$hasPassword) { - return new DataResponse([ - 'status' => 'error', - 'message' => 'Username and password are required for basic authentication' - ], Http::STATUS_BAD_REQUEST); - } elseif (!$hasUsername) { - return new DataResponse([ - 'status' => 'error', - 'message' => 'Username is required' - ], Http::STATUS_BAD_REQUEST); - } elseif (!$hasPassword) { - return new DataResponse([ - 'status' => 'error', - 'message' => 'Password is required' - ], Http::STATUS_BAD_REQUEST); - } - - $this->appConfig->setValueString(Application::APP_ID, 'tsa_username', trim($tsa_username)); - $this->appConfig->setValueString( - Application::APP_ID, - key: 'tsa_password', - value: $tsa_password, - sensitive: true, - ); - } else { - $this->appConfig->deleteKey(Application::APP_ID, 'tsa_username'); - $this->appConfig->deleteKey(Application::APP_ID, 'tsa_password'); - } - - return new DataResponse(['status' => 'success']); - } - - /** - * Delete TSA configuration - * - * Delete all TSA configuration fields from the application settings. - * - * @return DataResponse - * - * 200: OK - */ - #[NoCSRFRequired] - #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/tsa', requirements: ['apiVersion' => '(v1)'])] - public function deleteTsaConfig(): DataResponse { - $fields = ['tsa_url', 'tsa_policy_oid', 'tsa_auth_type', 'tsa_username', 'tsa_password']; - - foreach ($fields as $field) { - $this->appConfig->deleteKey(Application::APP_ID, $field); - } - - return new DataResponse(['status' => 'success']); - } - - /** - * Get footer template - * - * Returns the current footer template if set, otherwise returns the default template. - * - * @return DataResponse - * - * 200: OK - */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/footer-template', requirements: ['apiVersion' => '(v1)'])] - public function getFooterTemplate(): DataResponse { - return new DataResponse([ - 'template' => $this->footerService->getTemplate(), - 'isDefault' => $this->footerService->isDefaultTemplate(), - 'preview_width' => $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_width', 595), - 'preview_height' => $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_height', 100), - ]); - } - - /** - * Save footer template and render preview - * - * Saves the footer template and returns the rendered PDF preview. - * - * @param string $template The Twig template to save (empty to reset to default) - * @param int $width Width of preview in points (default: 595 - A4 width) - * @param int $height Height of preview in points (default: 50) - * @return DataDownloadResponse|DataResponse - * - * 200: OK - * 400: Bad request - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/footer-template', requirements: ['apiVersion' => '(v1)'])] - public function saveFooterTemplate(string $template = '', int $width = 595, int $height = 50) { - try { - $this->footerService->saveTemplate($template); - $pdf = $this->footerService->renderPreviewPdf('', $width, $height); - - return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); - } catch (\Exception $e) { - return new DataResponse([ - 'error' => $e->getMessage(), - ], Http::STATUS_BAD_REQUEST); - } - } - - /** - * Preview footer template as PDF - * - * @param string $template Template to preview - * @param int $width Width of preview in points (default: 595 - A4 width) - * @param int $height Height of preview in points (default: 50) - * @return DataDownloadResponse|DataResponse - * - * 200: OK - * 400: Bad request - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/footer-template/preview-pdf', requirements: ['apiVersion' => '(v1)'])] - #[NoCSRFRequired] - #[NoAdminRequired] - public function footerTemplatePreviewPdf(string $template = '', int $width = 595, int $height = 50) { - try { - $pdf = $this->footerService->renderPreviewPdf($template ?: null, $width, $height); - return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); - } catch (\Exception $e) { - return new DataResponse([ - 'error' => $e->getMessage(), - ], Http::STATUS_BAD_REQUEST); - } - } - - /** - * Set signing mode configuration - * - * Configure whether document signing should be synchronous or asynchronous - * - * @param string $mode Signing mode: "sync" or "async" - * @param string|null $workerType Worker type when async: "local" or "external" (optional) - * @return DataResponse|DataResponse|DataResponse - * - * 200: Settings saved - * 400: Invalid parameters - * 500: Internal server error - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signing-mode/config', requirements: ['apiVersion' => '(v1)'])] - public function setSigningModeConfig(string $mode, ?string $workerType = null): DataResponse { - try { - if (!in_array($mode, ['sync', 'async'], true)) { - return new DataResponse([ - 'error' => $this->l10n->t('Invalid signing mode. Use "sync" or "async".'), - ], Http::STATUS_BAD_REQUEST); - } - - if ($workerType !== null && !in_array($workerType, ['local', 'external'], true)) { - return new DataResponse([ - 'error' => $this->l10n->t('Invalid worker type. Use "local" or "external".'), - ], Http::STATUS_BAD_REQUEST); - } - - $this->saveOrDeleteConfig('signing_mode', $mode, 'sync'); - $this->saveOrDeleteConfig('worker_type', $workerType, 'local'); - - return new DataResponse([ - 'message' => $this->l10n->t('Settings saved'), - ]); - } catch (\Exception $e) { - return new DataResponse([ - 'error' => $e->getMessage(), - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - - private function saveOrDeleteConfig(string $key, ?string $value, string $default): void { - if ($value === $default) { - $this->appConfig->deleteKey(Application::APP_ID, $key); - } else { - $this->appConfig->setValueString(Application::APP_ID, $key, $value); - } - } - - /** - * Persist groups allowed to request signatures as typed app config array - * - * @param list $groups List of group IDs allowed to request signatures - * @return DataResponse|DataResponse - * - * 200: Settings saved - * 500: Internal server error - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/groups-request-sign/config', requirements: ['apiVersion' => '(v1)'])] - public function setGroupsRequestSignConfig(array $groups = []): DataResponse { - try { - $normalizedGroups = array_values(array_map(static fn (mixed $group): string => (string)$group, $groups)); - $this->appConfig->setValueArray(Application::APP_ID, 'groups_request_sign', $normalizedGroups); - - return new DataResponse([ - 'message' => $this->l10n->t('Settings saved'), - ]); - } catch (\Exception $e) { - return new DataResponse([ - 'error' => $e->getMessage(), - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - - /** - * Set signature flow configuration - * - * @param bool $enabled Whether to force a signature flow for all documents - * @param string|null $mode Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true) - * @return DataResponse|DataResponse|DataResponse - * - * 200: Configuration saved successfully - * 400: Invalid signature flow mode provided - * 500: Internal server error - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-flow/config', requirements: ['apiVersion' => '(v1)'])] - public function setSignatureFlowConfig(bool $enabled, ?string $mode = null): DataResponse { - try { - if (!$enabled) { - $this->appConfig->deleteKey(Application::APP_ID, 'signature_flow'); - return new DataResponse([ - 'message' => $this->l10n->t('Settings saved'), - ]); - } - - if ($mode === null) { - return new DataResponse([ - 'error' => $this->l10n->t('Mode is required when signature flow is enabled.'), - ], Http::STATUS_BAD_REQUEST); - } - - try { - $signatureFlow = \OCA\Libresign\Enum\SignatureFlow::from($mode); - } catch (\ValueError) { - return new DataResponse([ - 'error' => $this->l10n->t('Invalid signature flow mode. Use "parallel" or "ordered_numeric".'), - ], Http::STATUS_BAD_REQUEST); - } - - $this->appConfig->setValueString( - Application::APP_ID, - 'signature_flow', - $signatureFlow->value - ); - - return new DataResponse([ - 'message' => $this->l10n->t('Settings saved'), - ]); - } catch (\Exception $e) { - return new DataResponse([ - 'error' => $e->getMessage(), - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - - /** - * Configure DocMDP signature restrictions - * - * @param bool $enabled Whether to enable DocMDP restrictions - * @param int $defaultLevel DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations) - * @return DataResponse|DataResponse|DataResponse - * - * 200: Configuration saved successfully - * 400: Invalid DocMDP level provided - * 500: Internal server error - */ - #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/docmdp/config', requirements: ['apiVersion' => '(v1)'])] - public function setDocMdpConfig(bool $enabled, int $defaultLevel = 2): DataResponse { - try { - $this->docMdpConfigService->setEnabled($enabled); - - if ($enabled) { - $level = DocMdpLevel::tryFrom($defaultLevel); - if ($level === null) { - return new DataResponse([ - 'error' => $this->l10n->t('Invalid DocMDP level'), - ], Http::STATUS_BAD_REQUEST); - } - - $this->docMdpConfigService->setLevel($level); - } - - return new DataResponse([ - 'message' => $this->l10n->t('Settings saved'), - ]); - } catch (\Exception $e) { - return new DataResponse([ - 'error' => $e->getMessage(), - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - /** * Get list of files currently being signed (status = SIGNING_IN_PROGRESS) * diff --git a/lib/Controller/DevelopController.php b/lib/Controller/DevelopController.php index f6cbc2ada3..930fba1bb4 100644 --- a/lib/Controller/DevelopController.php +++ b/lib/Controller/DevelopController.php @@ -42,6 +42,7 @@ public function __construct( * * 200: PDF returned * 404: Debug mode not enabled + * @psalm-suppress InvalidReturnType */ #[NoCSRFRequired] #[PublicPage] @@ -56,6 +57,7 @@ public function pdf(): FileDisplayResponse|Response { 'Content-Disposition' => 'inline; filename="file.pdf"', 'Content-Type' => 'application/pdf', ]); + /** @psalm-suppress InvalidReturnStatement */ return $response; } diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 1df0dd74ec..5d4d291cde 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -24,6 +24,7 @@ use OCA\Libresign\Service\File\FileListService; use OCA\Libresign\Service\File\SettingsLoader; use OCA\Libresign\Service\FileService; +use OCA\Libresign\Service\Policy\ValidationEffectivePolicyService; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SessionService; use OCP\AppFramework\Db\DoesNotExistException; @@ -59,6 +60,8 @@ * @psalm-import-type LibresignPagination from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignSettings from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignValidatedFile from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignValidatedFileResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignEffectivePolicyState from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignValidateMetadata from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignVisibleElement from \OCA\Libresign\ResponseDefinitions */ @@ -73,6 +76,7 @@ public function __construct( private FileMapper $fileMapper, private RequestSignatureService $requestSignatureService, private AccountService $accountService, + private ValidationEffectivePolicyService $validationEffectivePolicyService, private IPreview $preview, private IMimeIconProvider $mimeIconProvider, private FileService $fileService, @@ -96,13 +100,13 @@ public function __construct( * @param bool $showVisibleElements Whether to include visible elements in the response * @param bool $showMessages Whether to include validation messages in the response * @param bool $showValidateFile Whether to include the file payload in the response - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: OK * 404: Request failed * 422: Request failed */ - #[PrivateValidation] + #[PrivateValidation(allowValidSignRequestUuid: true)] #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] @@ -128,7 +132,7 @@ public function validateUuid( * @param bool $showVisibleElements Whether to include visible elements in the response * @param bool $showMessages Whether to include validation messages in the response * @param bool $showValidateFile Whether to include the file payload in the response - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: OK * 404: Request failed @@ -157,7 +161,7 @@ public function validateFileId( * For `nodeType=file`, `filesCount=1` and `files` contains the current file. * For `nodeType=envelope`, `files` contains envelope child files. * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: OK * 404: Request failed @@ -171,7 +175,8 @@ public function validateFileId( public function validateBinary(): DataResponse { try { $file = $this->request->getUploadedFile('file'); - $return = $this->fileService + /** @var LibresignValidatedFile $validatedFile */ + $validatedFile = $this->fileService ->setMe($this->userSession->getUser()) ->setFileFromRequest($file) ->setHost($this->request->getServerHost()) @@ -181,23 +186,33 @@ public function validateBinary(): DataResponse { ->showMessages() ->showValidateFile() ->toArray(); - $statusCode = Http::STATUS_OK; + + /** @var LibresignValidatedFileResponse $response */ + $response = $this->validationEffectivePolicyService->appendEffectivePolicies($validatedFile); + + return new DataResponse($response, Http::STATUS_OK); } catch (InvalidArgumentException $e) { - $message = $this->l10n->t($e->getMessage()); - $return = [ + $message = $e->getMessage(); + /** @var LibresignActionErrorResponse $response */ + $response = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] ]; - $statusCode = Http::STATUS_NOT_FOUND; + + return new DataResponse($response, Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Failed to post file to validate', [ 'exception' => $e, ]); - $return = ['message' => $this->l10n->t('Internal error. Contact admin.')]; - $statusCode = Http::STATUS_BAD_REQUEST; + /** @var LibresignActionErrorResponse $response */ + $response = [ + 'action' => JSActions::ACTION_DO_NOTHING, + 'errors' => [['message' => $this->l10n->t('Internal error. Contact admin.')]], + ]; + + return new DataResponse($response, Http::STATUS_BAD_REQUEST); } - return new DataResponse($return, $statusCode); } /** @@ -205,7 +220,7 @@ public function validateBinary(): DataResponse { * * @param string|null $type The type of identifier could be Uuid or FileId * @param string|int $identifier The identifier value, could be string or integer, if UUID will be a string, if FileId will be an integer - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse */ private function validate( ?string $type = null, @@ -243,7 +258,8 @@ private function validate( $this->fileService->setSignRequest($signRequest); } - $return = $this->fileService + /** @var LibresignValidatedFile $validatedFile */ + $validatedFile = $this->fileService ->setMe($this->userSession->getUser()) ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId()) ->setHost($this->request->getServerHost()) @@ -253,25 +269,31 @@ private function validate( ->showMessages($showMessages) ->showValidateFile($showValidateFile) ->toArray(); - $statusCode = Http::STATUS_OK; + + /** @var LibresignValidatedFileResponse $response */ + $response = $this->validationEffectivePolicyService->appendEffectivePolicies($validatedFile); + + return new DataResponse($response, Http::STATUS_OK); } catch (LibresignException $e) { - $message = $this->l10n->t($e->getMessage()); - $return = [ + $message = $e->getMessage(); + /** @var LibresignActionErrorResponse $response */ + $response = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] ]; - $statusCode = Http::STATUS_NOT_FOUND; + + return new DataResponse($response, Http::STATUS_NOT_FOUND); } catch (\Throwable $th) { - $message = $this->l10n->t($th->getMessage()); - $this->logger->error($message); - $return = [ + $this->logger->error($th->getMessage(), ['exception' => $th]); + $message = $this->l10n->t('Internal error. Contact admin.'); + /** @var LibresignActionErrorResponse $response */ + $response = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] ]; - $statusCode = Http::STATUS_NOT_FOUND; - } - return new DataResponse($return, $statusCode); + return new DataResponse($response, Http::STATUS_NOT_FOUND); + } } /** @@ -499,7 +521,7 @@ private function fetchPreview( /** * Send a file * - * Send a new file to Nextcloud and return the fileId to request signature. + * Send a new file to Nextcloud and return the fileId used to create a signature request. * Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'. * * **Note on multiple file uploads:** diff --git a/lib/Controller/FooterTemplateController.php b/lib/Controller/FooterTemplateController.php new file mode 100644 index 0000000000..92dab83d5d --- /dev/null +++ b/lib/Controller/FooterTemplateController.php @@ -0,0 +1,131 @@ +|DataResponse + * + * 200: OK + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/footer-template', requirements: ['apiVersion' => '(v1)'])] + public function getFooterTemplate(): DataResponse { + if (!$this->footerService->isPreviewAllowed()) { + return new DataResponse([ + 'error' => 'Footer template is disabled by policy for the current user.', + ], Http::STATUS_FORBIDDEN); + } + + $previewSettings = $this->footerService->getPreviewSettings(); + + return new DataResponse([ + 'template' => $this->footerService->getTemplate(), + 'isDefault' => $this->footerService->isDefaultTemplate(), + 'template_variables' => $this->footerService->getTemplateVariablesMetadata(), + 'preview_width' => $previewSettings['preview_width'], + 'preview_height' => $previewSettings['preview_height'], + 'preview_zoom' => $previewSettings['preview_zoom'], + ]); + } + + /** + * Save footer template and render preview + * + * Saves the footer template and returns the rendered PDF preview. + * + * @param string $template The Twig template to save (empty to reset to default) + * @param int $width Width of preview in points (default: 595 - A4 width) + * @param int $height Height of preview in points (default: 50) + * @return DataDownloadResponse|DataResponse + * + * 200: OK + * 400: Bad request + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/footer-template', requirements: ['apiVersion' => '(v1)'])] + public function saveFooterTemplate(string $template = '', int $width = 595, int $height = 50) { + if (!$this->footerService->isPreviewAllowed()) { + return new DataResponse([ + 'error' => 'Footer template is disabled by policy for the current user.', + ], Http::STATUS_FORBIDDEN); + } + + try { + $this->footerService->saveTemplate($template, $width, $height); + $pdf = $this->footerService->renderPreviewPdf('', $width, $height); + + return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); + } catch (\Exception $e) { + return new DataResponse([ + 'error' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } + } + + /** + * Preview footer template as PDF + * + * @param string $template Template to preview + * @param int $width Width of preview in points (default: 595 - A4 width) + * @param int $height Height of preview in points (default: 50) + * @param ?bool $writeQrcodeOnFooter Whether to force QR code rendering in footer preview (null uses policy) + * @return DataDownloadResponse|DataResponse + * + * 200: OK + * 400: Bad request + * 403: Forbidden + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/footer-template/preview-pdf', requirements: ['apiVersion' => '(v1)'])] + public function previewPdf(string $template = '', int $width = 595, int $height = 50, ?bool $writeQrcodeOnFooter = null) { + if (!$this->footerService->isPreviewAllowed()) { + return new DataResponse([ + 'error' => 'Footer preview is disabled by policy for the current user.', + ], Http::STATUS_FORBIDDEN); + } + + try { + $pdf = $this->footerService->renderPreviewPdf($template, $width, $height, $writeQrcodeOnFooter); + return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); + } catch (\Exception $e) { + return new DataResponse([ + 'error' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } + } +} diff --git a/lib/Controller/IdentifyController.php b/lib/Controller/IdentifyController.php index ac1b4d2bcf..e5ee5581cd 100644 --- a/lib/Controller/IdentifyController.php +++ b/lib/Controller/IdentifyController.php @@ -20,6 +20,9 @@ use OCA\Libresign\Service\Identify\SearchNormalizer; use OCA\Libresign\Service\Identify\ShareTypeResolver; use OCA\Libresign\Service\Identify\SignerSearchContext; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicyValue; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -41,6 +44,7 @@ public function __construct( private ResultFilter $resultFilter, private ResultFormatter $resultFormatter, private ResultEnricher $resultEnricher, + private PolicyService $policyService, ) { parent::__construct(Application::APP_ID, $request); } @@ -48,10 +52,10 @@ public function __construct( /** * List possible signers * - * Used to identify who can sign the document. The return of this endpoint is related with Administration Settiongs > LibreSign > Identify method. + * Used to identify who can be invited to sign a document. The response follows Administration Settings > LibreSign > Identify method. * * @param string $search search params - * @param string $method filter by method (email, account, sms, signal, telegram, whatsapp, xmpp) + * @param string $method filter by method (email, account, sms, signal, telegram, whatsapp, whatsappbusiness, xmpp) * @param int $page the number of page to return. Default: 1 * @param int $limit Total of elements to return. Default: 25 * @return DataResponse @@ -84,9 +88,10 @@ public function search(string $search = '', string $method = '', int $page = 1, $result = $this->resultFilter->excludeEmpty($result); $return = $this->resultFormatter->formatForNcSelect($result); + $return = $this->resultFormatter->replaceShareTypeWithMethod($return); + $return = $this->resultFilter->excludeNotAllowed($return, $this->resolveAllowedMethods($method)); $return = $this->resultEnricher->addHerselfAccount($return, $search, $method); $return = $this->resultEnricher->addHerselfEmail($return, $search, $method); - $return = $this->resultFormatter->replaceShareTypeWithMethod($return); $return = $this->resultEnricher->addEmailNotificationPreference($return); $return = $this->resultFilter->excludeNotAllowed($return); /** @var LibresignIdentifyAccountsResponse $return */ @@ -95,6 +100,41 @@ public function search(string $search = '', string $method = '', int $page = 1, return new DataResponse($return); } + /** + * @return list + */ + private function resolveAllowedMethods(string $requestedMethod): array { + $resolved = $this->policyService->resolve(IdentifyMethodsPolicy::KEY)->getEffectiveValue(); + $settings = IdentifyMethodsPolicyValue::extractFactors(IdentifyMethodsPolicyValue::normalize($resolved)); + + $enabledMethods = []; + foreach ($settings as $setting) { + if (!is_array($setting)) { + continue; + } + + $name = isset($setting['name']) && is_string($setting['name']) ? trim($setting['name']) : ''; + $enabled = !empty($setting['enabled']); + if ($name === '' || !$enabled) { + continue; + } + + $enabledMethods[] = $name; + } + + $enabledMethods = array_values(array_unique($enabledMethods)); + $requestedMethod = strtolower(trim($requestedMethod)); + if ($requestedMethod === '' || $requestedMethod === 'all') { + return $enabledMethods; + } + + if (in_array($requestedMethod, $enabledMethods, true)) { + return [$requestedMethod]; + } + + return []; + } + private function registerPlugin(): void { $refObject = new \ReflectionObject($this->collaboratorSearch); diff --git a/lib/Controller/LibresignTrait.php b/lib/Controller/LibresignTrait.php index 182914acaa..c371bb71f6 100644 --- a/lib/Controller/LibresignTrait.php +++ b/lib/Controller/LibresignTrait.php @@ -81,7 +81,7 @@ public function loadIdDocApprovalFromResolution(array $resolution): void { if ($resolution['type'] !== 'id_doc') { throw new LibresignException(json_encode([ 'action' => JSActions::ACTION_DO_NOTHING, - 'errors' => [['message' => $this->l10n->t('Invalid id-doc request')]], + 'errors' => [['message' => $this->l10n->t('Invalid identification document request')]], ]), AppFrameworkHttp::STATUS_BAD_REQUEST); } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 82c058ed50..211b84cdb2 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,17 +12,19 @@ use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Helper\JSActions; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Middleware\Attribute\PrivateValidation; use OCA\Libresign\Middleware\Attribute\RequireSetupOk; use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid; use OCA\Libresign\Service\AccountService; -use OCA\Libresign\Service\DocMdp\ConfigService; use OCA\Libresign\Service\File\FileListService; use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\TokenService; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\PolicyAuthorizationService; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SessionService; use OCA\Libresign\Service\SignerElementsService; @@ -39,6 +41,7 @@ use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\IEventDispatcher; @@ -58,8 +61,11 @@ public function __construct( private SessionService $sessionService, private IInitialState $initialState, private AccountService $accountService, + private CertificateEngineFactory $certificateEngineFactory, protected SignFileService $signFileService, protected RequestSignatureService $requestSignatureService, + private PolicyService $policyService, + private PolicyAuthorizationService $policyAuthorizationService, private SignerElementsService $signerElementsService, protected IL10N $l10n, private IdentifyMethodService $identifyMethodService, @@ -72,7 +78,6 @@ public function __construct( private ValidateHelper $validateHelper, private IEventDispatcher $eventDispatcher, private IURLGenerator $urlGenerator, - private ConfigService $docMdpConfigService, ) { parent::__construct( request: $request, @@ -107,10 +112,9 @@ public function index(): TemplateResponse { } $this->provideSignerSignatues(); - $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); - $this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value)); - $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); - $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $this->policyService->resolveKnownPolicyStates(), + ]); Util::addScript(Application::APP_ID, 'libresign-main'); Util::addStyle(Application::APP_ID, 'libresign-main'); @@ -150,35 +154,58 @@ public function indexF(): TemplateResponse { /** * Incomplete page * - * @return TemplateResponse + * @return TemplateResponse|RedirectResponse * * 200: OK + * 303: Redirected when setup is already complete */ #[NoAdminRequired] #[NoCSRFRequired] #[FrontpageRoute(verb: 'GET', url: '/f/incomplete')] - public function incomplete(): TemplateResponse { - Util::addScript(Application::APP_ID, 'libresign-main'); - Util::addStyle(Application::APP_ID, 'libresign-main'); - $response = new TemplateResponse(Application::APP_ID, 'main'); - return $response; + public function incomplete(): TemplateResponse|RedirectResponse { + if ($this->isSetupComplete()) { + return new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexF'), + ); + } + + return $this->renderIncompletePage(); } /** * Incomplete page in full screen * - * @return TemplateResponse + * @return TemplateResponse|RedirectResponse * * 200: OK + * 303: Redirected when setup is already complete */ #[PublicPage] #[NoCSRFRequired] #[FrontpageRoute(verb: 'GET', url: '/p/incomplete')] - public function incompleteP(): TemplateResponse { + public function incompleteP(): TemplateResponse|RedirectResponse { + if ($this->isSetupComplete()) { + return new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute('libresign.page.index'), + ); + } + + return $this->renderIncompletePage(publicPage: true); + } + + private function renderIncompletePage(bool $publicPage = false): TemplateResponse { Util::addScript(Application::APP_ID, 'libresign-main'); Util::addStyle(Application::APP_ID, 'libresign-main'); - $response = new TemplateResponse(Application::APP_ID, 'main', [], TemplateResponse::RENDER_AS_BASE); - return $response; + + if ($publicPage) { + return new TemplateResponse(Application::APP_ID, 'main', [], TemplateResponse::RENDER_AS_BASE); + } + + return new TemplateResponse(Application::APP_ID, 'main'); + } + + private function isSetupComplete(): bool { + return $this->certificateEngineFactory->getEngine()->isSetupOk(); } /** @@ -187,15 +214,22 @@ public function incompleteP(): TemplateResponse { * The path is used only by frontend * * @param string $path The path that was sent from frontend - * @return TemplateResponse + * @return TemplateResponse|RedirectResponse * * 200: OK + * 303: Redirected when the current actor cannot access the requested internal page */ #[NoAdminRequired] #[NoCSRFRequired] #[RequireSetupOk(template: 'main')] #[FrontpageRoute(verb: 'GET', url: '/f/{path}', requirements: ['path' => '.+'])] - public function indexFPath(string $path): TemplateResponse { + public function indexFPath(string $path): TemplateResponse|RedirectResponse { + if ($this->isPoliciesWorkbenchPath($path) && !$this->canAccessPoliciesWorkbench()) { + return new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexF'), + ); + } + if (preg_match('/validation\/(?[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/', $path, $matches)) { $signRequest = null; @@ -262,6 +296,19 @@ public function indexFPath(string $path): TemplateResponse { return $this->index(); } + private function canAccessPoliciesWorkbench(): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + return $this->policyAuthorizationService->canUserManageGroupPolicies($user); + } + + private function isPoliciesWorkbenchPath(string $path): bool { + return preg_match('/^policies(?:\/|$)/', $path) === 1; + } + /** * Sign page to authenticated signer * @@ -270,6 +317,7 @@ public function indexFPath(string $path): TemplateResponse { * * 200: OK */ + #[PrivateValidation(allowValidSignRequestUuid: false)] #[NoAdminRequired] #[NoCSRFRequired] #[RequireSetupOk] @@ -316,6 +364,7 @@ public function signFPath(string $uuid): TemplateResponse { #[NoCSRFRequired] #[RequireSetupOk] #[PublicPage] + #[PrivateValidation(allowValidSignRequestUuid: true)] #[RequireSignRequestUuid(redirectIfSignedToValidation: true, allowIdDocs: true)] #[FrontpageRoute(verb: 'GET', url: '/p/sign/{uuid}/{path}', requirements: ['path' => '.+'])] public function signPPath(string $uuid): TemplateResponse { @@ -332,11 +381,11 @@ public function signPPath(string $uuid): TemplateResponse { * * 200: OK */ - #[PrivateValidation] #[NoAdminRequired] #[NoCSRFRequired] #[RequireSetupOk] #[PublicPage] + #[PrivateValidation(allowValidSignRequestUuid: true)] #[RequireSignRequestUuid(redirectIfSignedToValidation: true, allowIdDocs: true)] #[FrontpageRoute(verb: 'GET', url: '/p/sign/{uuid}')] public function sign(string $uuid): TemplateResponse { @@ -454,7 +503,7 @@ private function getEnvelopeChildFiles(): array { * 401: Validation page not accessible if unauthenticated * 404: File not found */ - #[PrivateValidation] + #[PrivateValidation(allowValidSignRequestUuid: false)] #[NoAdminRequired] #[NoCSRFRequired] #[RequireSetupOk] @@ -478,9 +527,9 @@ public function getPdf($uuid) { * @return FileDisplayResponse * * 200: OK - * 401: Validation page not accessible if unauthenticated + * 401: Validation page not accessible if unauthenticated without a valid sign request UUID */ - #[PrivateValidation] + #[PrivateValidation(allowValidSignRequestUuid: true)] #[NoAdminRequired] #[NoCSRFRequired] #[RequireSignRequestUuid(allowIdDocs: true)] @@ -508,7 +557,7 @@ public function getPdfFile($uuid): FileDisplayResponse { * 200: OK * 401: Validation page not accessible if unauthenticated */ - #[PrivateValidation] + #[PrivateValidation(allowValidSignRequestUuid: false)] #[NoAdminRequired] #[NoCSRFRequired] #[RequireSetupOk(template: 'validation')] @@ -553,7 +602,7 @@ public function validation(): TemplateResponse { * 303: Redirected to validation page * 401: Validation page not accessible if unauthenticated */ - #[PrivateValidation] + #[PrivateValidation(allowValidSignRequestUuid: false)] #[NoAdminRequired] #[NoCSRFRequired] #[RequireSetupOk] @@ -599,10 +648,9 @@ public function resetPassword(): TemplateResponse { * 200: OK * 401: Validation page not accessible if unauthenticated */ - #[PrivateValidation] + #[PrivateValidation(allowValidSignRequestUuid: false)] #[NoAdminRequired] #[NoCSRFRequired] - #[RequireSetupOk(template: 'validation')] #[PublicPage] #[AnonRateLimit(limit: 30, period: 60)] #[FrontpageRoute(verb: 'GET', url: '/p/validation/{uuid}')] @@ -636,19 +684,30 @@ public function validationFilePublic(string $uuid): TemplateResponse { $this->fileService->setSignRequest($signRequest); } - $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); - - $this->initialState->provideInitialState('file_info', - $this->fileService - ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId()) - ->setHost($this->request->getServerHost()) - ->showVisibleElements() - ->showSigners() - ->showSettings() - ->showMessages() - ->showValidateFile() - ->toArray() - ); + $fileInfo = $this->fileService + ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId()) + ->setHost($this->request->getServerHost()) + ->showVisibleElements() + ->showSigners() + ->showSettings() + ->showMessages() + ->showValidateFile() + ->toArray(); + + $requesterUserId = null; + $requestedBy = $fileInfo['requested_by'] ?? null; + if (is_array($requestedBy) && is_string($requestedBy['userId'] ?? null)) { + $requestedByUserId = trim($requestedBy['userId']); + $requesterUserId = $requestedByUserId !== '' ? $requestedByUserId : null; + } + + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $requesterUserId !== null + ? $this->policyService->resolveKnownPolicyStatesForUserIdWithoutUserScope($requesterUserId) + : $this->policyService->resolveKnownPolicyStates(), + ]); + + $this->initialState->provideInitialState('file_info', $fileInfo); Util::addScript(Application::APP_ID, 'libresign-validation'); if (class_exists(LoadViewer::class)) { diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php new file mode 100644 index 0000000000..68dd04f4f5 --- /dev/null +++ b/lib/Controller/PolicyController.php @@ -0,0 +1,590 @@ + + * + * 200: OK + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])] + public function effective(): DataResponse { + $ruleCounts = $this->policyManagementScopeService->resolveVisibleRuleCountsForCurrentActor(); + + /** @var array $policies */ + $policies = $this->policyService->resolveKnownPolicyStatesWithRuleCounts($ruleCounts); + + /** @var LibresignEffectivePoliciesResponse $data */ + $data = [ + 'policies' => $policies, + ]; + + return new DataResponse($data); + } + + /** + * Read explicit system policy configuration + * + * @param string $policyKey Policy identifier to read from the system layer. + * @return DataResponse + * + * 200: OK + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function getSystem(string $policyKey): DataResponse { + $policy = $this->policyService->getSystemPolicy($policyKey); + + /** @var LibresignSystemPolicyResponse $data */ + $data = [ + 'policy' => [ + 'policyKey' => $policyKey, + 'scope' => ($policy?->getScope() === 'global' ? 'global' : 'system'), + 'value' => $policy?->getValue(), + 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true, + 'visibleToChild' => $policy?->isVisibleToChild() ?? true, + 'allowedValues' => $policy?->getAllowedValues() ?? [], + ], + ]; + $rawValue = $data['policy']['value']; + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $data['policy']['value'] = $decoded; + } + } + + return new DataResponse($data); + } + + /** + * Read a group-level policy value + * + * @param string $groupId Group identifier that receives the policy binding. + * @param string $policyKey Policy identifier to read for the selected group. + * @return DataResponse|DataResponse + * + * 200: OK + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function getGroup(string $groupId, string $policyKey): DataResponse { + if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) { + return $this->forbiddenGroupPolicyResponse(); + } + + $policy = $this->policyService->getGroupPolicy($policyKey, $groupId); + if ($policy instanceof PolicyLayer && !$this->policyService->canViewGroupPolicy($policyKey, $groupId, $policy)) { + return $this->forbiddenGroupPolicyResponse(); + } + + /** @var LibresignGroupPolicyResponse $data */ + $data = [ + 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } + + /** + * List all explicit group-level policy values for a policy key + * + * @param string $policyKey Policy identifier to list group rules. + * @return DataResponse}, array{}> + * + * 200: OK + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/by-policy/group/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function listGroupPolicies(string $policyKey): DataResponse { + $manageableGroupIds = $this->policyManagementScopeService->resolveCurrentActorManageableGroupIdsForPolicy($policyKey); + if ($manageableGroupIds === null) { + $records = $this->policyService->listGroupPolicies($policyKey); + } elseif ($manageableGroupIds === []) { + $records = []; + } else { + $records = $this->policyService->listGroupPoliciesForTargets($policyKey, $manageableGroupIds); + } + $policies = []; + + foreach ($records as $record) { + $groupId = (string)($record['targetId'] ?? ''); + $policy = $record['policy'] ?? null; + if ($groupId === '' || !$policy instanceof PolicyLayer) { + continue; + } + + if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) { + continue; + } + + if (!$this->policyService->canViewGroupPolicy($policyKey, $groupId, $policy)) { + continue; + } + + $policies[] = $this->serializeGroupPolicy($groupId, $policyKey, $policy); + } + + return new DataResponse([ + 'policies' => $policies, + ]); + } + + /** + * Read an explicit user-level policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment. + * @param string $policyKey Policy identifier to read for the selected user. + * @return DataResponse|DataResponse + * + * 200: OK + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function getUserPolicyForUser(string $userId, string $policyKey): DataResponse { + if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) { + return $this->forbiddenUserPolicyResponse(); + } + + $policy = $this->policyService->getUserPolicyForUserId($policyKey, $userId); + + /** @var LibresignUserPolicyResponse $data */ + $data = [ + 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } + + /** + * List all explicit user-level policy values for a policy key + * + * @param string $policyKey Policy identifier to list user rules. + * @return DataResponse}, array{}> + * + * 200: OK + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/by-policy/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function listUserPolicies(string $policyKey): DataResponse { + $records = $this->policyService->listUserPolicies($policyKey); + $policies = []; + + foreach ($records as $record) { + $userId = (string)($record['targetId'] ?? ''); + $policy = $record['policy'] ?? null; + if ($userId === '' || !$policy instanceof PolicyLayer) { + continue; + } + + if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) { + continue; + } + + $policies[] = $this->serializeUserPolicy($userId, $policyKey, $policy); + } + + return new DataResponse([ + 'policies' => $policies, + ]); + } + + /** + * Save a system-level policy value + * + * @param string $policyKey Policy identifier to persist at the system layer. + * @param null|bool|int|float|string|array $value Policy value to persist. Null resets the policy to its default system value. + * @param bool $allowChildOverride Whether lower layers may override this system default. + * @return DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + */ + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function setSystem(string $policyKey, null|bool|int|float|string|array $value = null, bool $allowChildOverride = false): DataResponse { + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value, true); + $policy = $this->policyService->saveSystem($policyKey, $value, $allowChildOverride); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Save a group-level policy value + * + * @param string $groupId Group identifier that receives the policy binding. + * @param string $policyKey Policy identifier to persist at the group layer. + * @param null|bool|int|float|string|array $value Policy value to persist for the group. + * @param bool $allowChildOverride Whether users and requests below this group may override the group default. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function setGroup(string $groupId, string $policyKey, null|bool|int|float|string|array $value = null, bool $allowChildOverride = false): DataResponse { + if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) { + return $this->forbiddenGroupPolicyResponse(); + } + + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value, false, $groupId); + $policy = $this->policyService->saveGroupPolicy($policyKey, $groupId, $value, $allowChildOverride); + /** @var LibresignGroupPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeGroupWritePolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } catch (\DomainException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Clear a group-level policy value + * + * @param string $groupId Group identifier that receives the policy binding. + * @param string $policyKey Policy identifier to clear for the selected group. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function clearGroup(string $groupId, string $policyKey): DataResponse { + if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) { + return $this->forbiddenGroupPolicyResponse(); + } + + try { + $policy = $this->policyService->clearGroupPolicy($policyKey, $groupId); + /** @var LibresignGroupPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeGroupWritePolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } catch (\DomainException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } + } + + /** + * Save a user policy preference + * + * @param string $policyKey Policy identifier to persist for the current user. + * @param null|bool|int|float|string|array $value Policy value to persist as the current user's default. + * @return DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function setUserPreference(string $policyKey, null|bool|int|float|string|array $value = null): DataResponse { + $value = $this->readPolicyValueParam('value', $value); + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $policy = $this->policyService->saveUserPreference($policyKey, $value); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Save an explicit user policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment. + * @param string $policyKey Policy identifier to persist for the target user. + * @param null|bool|int|float|string|array $value Policy value to persist as assigned target user policy. + * @param bool $allowChildOverride Whether the target user may still override the assigned value in personal preferences. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function setUserPolicyForUser(string $userId, string $policyKey, null|bool|int|float|string|array $value = null, bool $allowChildOverride = false): DataResponse { + if (!$this->policyManagementScopeService->canCurrentActorManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) { + return $this->forbiddenUserPolicyResponse(); + } + $policy = $this->policyService->saveUserPolicyForUserId($policyKey, $userId, $value, $allowChildOverride); + /** @var LibresignUserPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Clear a user policy preference + * + * @param string $policyKey Policy identifier to clear for the current user. + * @return DataResponse|DataResponse + * + * 200: OK + * 400: User-scope not supported + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function clearUserPreference(string $policyKey): DataResponse { + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $policy = $this->policyService->clearUserPreference($policyKey); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Clear an explicit user policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment removal. + * @param string $policyKey Policy identifier to clear for the target user. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: User-scope not supported + * 403: Forbidden + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function clearUserPolicyForUser(string $userId, string $policyKey): DataResponse { + if (!$this->policyManagementScopeService->canCurrentActorManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) { + return $this->forbiddenUserPolicyResponse(); + } + $policy = $this->policyService->clearUserPolicyForUserId($policyKey, $userId); + /** @var LibresignUserPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** @return LibresignGroupPolicyState */ + private function serializeGroupPolicy(string $groupId, string $policyKey, ?PolicyLayer $policy): array { + return [ + 'policyKey' => $policyKey, + 'scope' => 'group', + 'targetId' => $groupId, + 'value' => $policy?->getValue(), + 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true, + 'visibleToChild' => $policy?->isVisibleToChild() ?? true, + 'allowedValues' => $policy?->getAllowedValues() ?? [], + 'deletableByCurrentActor' => $this->policyService->canDeleteGroupPolicy($policyKey, $groupId, $policy), + ]; + } + + /** @return LibresignGroupPolicyState */ + private function serializeGroupWritePolicy(string $groupId, string $policyKey, ?PolicyLayer $policy): array { + $serialized = $this->serializeGroupPolicy($groupId, $policyKey, $policy); + $serialized['effectiveValue'] = $policy?->getValue(); + + return $serialized; + } + + /** @return LibresignUserPolicyState */ + private function serializeUserPolicy(string $userId, string $policyKey, ?PolicyLayer $policy): array { + return [ + 'policyKey' => $policyKey, + 'scope' => 'user_policy', + 'targetId' => $userId, + 'value' => $policy?->getValue(), + 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true, + ]; + } + + private function readPolicyValueParam(string $key, null|bool|int|float|string|array $default): null|bool|int|float|string|array { + $value = $this->request->getParams()[$key] ?? $default; + if (!is_scalar($value) && !is_array($value) && $value !== null) { + return $default; + } + + return $value; + } + + private function readBoolParam(string $key, bool $default): bool { + $value = $this->request->getParams()[$key] ?? $default; + return is_bool($value) ? $value : $default; + } + + /** @return DataResponse */ + private function forbiddenGroupPolicyResponse(): DataResponse { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t('Not allowed to manage this group policy'), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } + + /** @return DataResponse */ + private function forbiddenUserPolicyResponse(): DataResponse { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t('Not allowed to manage this user policy'), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/Controller/RequestSignatureController.php b/lib/Controller/RequestSignatureController.php index a3bd023cc7..fe4e71a967 100644 --- a/lib/Controller/RequestSignatureController.php +++ b/lib/Controller/RequestSignatureController.php @@ -9,13 +9,12 @@ namespace OCA\Libresign\Controller; use OCA\Libresign\AppInfo\Application; -use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Middleware\Attribute\RequireManager; use OCA\Libresign\Service\File\FileListService; -use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\RequestSignatureService; +use OCA\Libresign\Service\RequestSignatureWorkflowService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -42,11 +41,10 @@ public function __construct( IRequest $request, protected IL10N $l10n, protected IUserSession $userSession, - protected FileService $fileService, protected FileListService $fileListService, protected ValidateHelper $validateHelper, protected RequestSignatureService $requestSignatureService, - protected FileMapper $fileMapper, + private RequestSignatureWorkflowService $requestSignatureWorkflowService, ) { parent::__construct(Application::APP_ID, $request); } @@ -68,7 +66,7 @@ public function __construct( * @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path. * @param string|null $callback URL that will receive a POST after the document is signed * @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending - * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration + * @param array|null $policy Structured policy payload with request-level overrides and active context. * @return DataResponse|DataResponse * * 200: OK @@ -87,11 +85,11 @@ public function requestSignature( array $files = [], ?string $callback = null, ?int $status = 1, - ?string $signatureFlow = null, + ?array $policy = null, ): DataResponse { try { $user = $this->userSession->getUser(); - return $this->createSignatureRequest( + $result = $this->requestSignatureWorkflowService->createRequest( $user, $file, $files, @@ -100,8 +98,11 @@ public function requestSignature( $signers, $status, $callback, - $signatureFlow + $policy, ); + + $fileData = $this->fileListService->formatFileWithChildren($result['file'], $result['children'], $user); + return new DataResponse($fileData, Http::STATUS_OK); } catch (LibresignException $e) { $errorMessage = $e->getMessage(); $decoded = json_decode($errorMessage, true); @@ -135,7 +136,7 @@ public function requestSignature( * @param LibresignVisibleElement[]|null $visibleElements Visible elements on document * @param LibresignNewFile|null $file File object. Supports nodeId, url, base64 or path when creating a new request. * @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending - * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration + * @param array|null $policy Structured policy payload with request-level overrides and active context. * @param string|null $name The name of file to sign * @param LibresignFolderSettings $settings Settings to define how and where the file should be stored * @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path. @@ -155,7 +156,7 @@ public function updateSignatureRequest( ?array $visibleElements = null, ?array $file = null, ?int $status = null, - ?string $signatureFlow = null, + ?array $policy = null, ?string $name = null, array $settings = [], array $files = [], @@ -166,7 +167,7 @@ public function updateSignatureRequest( $file = is_array($file) ? $file : []; if (empty($uuid)) { - return $this->createSignatureRequest( + $result = $this->requestSignatureWorkflowService->createRequest( $user, $file, $files, @@ -175,34 +176,27 @@ public function updateSignatureRequest( $signers, $status, null, - $signatureFlow, + $policy, $visibleElements ); - } - $data = [ - 'uuid' => $uuid, - 'file' => $file, - 'signers' => $signers, - 'userManager' => $user, - 'visibleElements' => $visibleElements, - 'signatureFlow' => $signatureFlow, - 'name' => $name, - 'settings' => $settings, - ]; - if ($status !== null) { - $data['status'] = $status; + $fileData = $this->fileListService->formatFileWithChildren($result['file'], $result['children'], $user); + return new DataResponse($fileData, Http::STATUS_OK); } - $this->validateHelper->validateExistingFile($data); - $this->validateHelper->validateFileStatus($data); - $this->validateHelper->validateIdentifySigners($data); - if (!empty($visibleElements)) { - $this->validateHelper->validateVisibleElements($visibleElements, $this->validateHelper::TYPE_VISIBLE_ELEMENT_PDF); - } - $fileEntity = $this->requestSignatureService->save($data); - $childFiles = $this->loadChildFilesIfEnvelope($fileEntity); - $fileData = $this->fileListService->formatFileWithChildren($fileEntity, $childFiles, $user); + $result = $this->requestSignatureWorkflowService->updateExistingRequest( + $user, + $signers, + $uuid, + $visibleElements, + $file, + $status, + $policy, + $name, + $settings, + ); + + $fileData = $this->fileListService->formatFileWithChildren($result['file'], $result['children'], $user); return new DataResponse($fileData, Http::STATUS_OK); } catch (\Throwable $th) { return new DataResponse( @@ -214,69 +208,6 @@ public function updateSignatureRequest( } } - /** - * Internal method to handle signature request creation logic - * Used by both requestSignature() and updateSignatureRequest() when creating new requests - * - * @return DataResponse - * @throws LibresignException - */ - private function createSignatureRequest( - $user, - array $file, - array $files, - string $name, - array $settings, - array $signers, - ?int $status, - ?string $callback, - ?string $signatureFlow, - ?array $visibleElements = null, - ): DataResponse { - $isEnvelope = !empty($files); - - $filesToSave = $isEnvelope ? $files : null; - - if (empty($file) && empty($files)) { - throw new LibresignException($this->l10n->t('File or files parameter is required')); - } - - $data = [ - 'file' => $file, - 'name' => $name, - 'signers' => $signers, - 'callback' => $callback, - 'userManager' => $user, - 'signatureFlow' => $signatureFlow, - 'settings' => !empty($settings) ? $settings : ($file['settings'] ?? []), - ]; - - if ($status !== null) { - $data['status'] = $status; - } - - if ($isEnvelope) { - $data['files'] = $filesToSave; - } - - if ($visibleElements !== null) { - $data['visibleElements'] = $visibleElements; - } - $this->requestSignatureService->validateNewRequestToFile($data); - - if ($isEnvelope) { - $result = $this->requestSignatureService->saveFiles($data); - $fileEntity = $result['file']; - $childFiles = $result['children'] ?? []; - } else { - $fileEntity = $this->requestSignatureService->save($data); - $childFiles = $this->loadChildFilesIfEnvelope($fileEntity); - } - - $fileData = $this->fileListService->formatFileWithChildren($fileEntity, $childFiles, $user); - return new DataResponse($fileData, Http::STATUS_OK); - } - /** * Delete sign request * @@ -365,9 +296,4 @@ public function deleteSignatureRequest(int $fileId): DataResponse { ); } - private function loadChildFilesIfEnvelope($fileEntity): array { - return $fileEntity->getParentFileId() === null || $fileEntity->isEnvelope() - ? $this->fileMapper->getChildrenFiles($fileEntity->getId()) - : []; - } } diff --git a/lib/Controller/SettingController.php b/lib/Controller/SettingController.php index 4e97849bd4..1740a3648f 100644 --- a/lib/Controller/SettingController.php +++ b/lib/Controller/SettingController.php @@ -43,8 +43,9 @@ public function __construct( #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/setting/has-root-cert', requirements: ['apiVersion' => '(v1)'])] public function hasRootCert(): DataResponse { + $engine = $this->certificateEngineFactory->getEngine(); $checkData = [ - 'hasRootCert' => $this->certificateEngineFactory->getEngine()->isSetupOk() + 'hasRootCert' => $engine->getName() !== 'none' && $engine->isSetupOk() ]; return new DataResponse($checkData); diff --git a/lib/Controller/SignatureStampPreviewController.php b/lib/Controller/SignatureStampPreviewController.php new file mode 100644 index 0000000000..26c0a87afd --- /dev/null +++ b/lib/Controller/SignatureStampPreviewController.php @@ -0,0 +1,101 @@ +|DataResponse + * + * 200: Preview PDF + * 403: Forbidden + * 422: Rendering error + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/signature-stamp/preview-pdf', requirements: ['apiVersion' => '(v1)'])] + public function previewPdf( + string $template = '', + ?float $templateFontSize = null, + ?float $signatureFontSize = null, + ?float $signatureWidth = null, + ?float $signatureHeight = null, + ?string $renderMode = null, + ?string $backgroundType = null, + ): DataDownloadResponse|DataResponse { + $templateFontSize ??= SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE; + $signatureFontSize ??= SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE; + $signatureWidth ??= SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH; + $signatureHeight ??= SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT; + $renderMode ??= SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION; + $backgroundType ??= 'default'; + + if (!$this->canEditSignatureStampPolicy()) { + return new DataResponse([ + 'error' => 'Signature stamp preview is only available for users who can edit policies.', + ], Http::STATUS_FORBIDDEN); + } + + try { + $pdf = $this->signatureStampPreviewNativeService->renderPreviewPdf( + template: $template, + templateFontSize: $templateFontSize, + signatureFontSize: $signatureFontSize, + signatureWidth: $signatureWidth, + signatureHeight: $signatureHeight, + renderMode: $renderMode, + backgroundType: $backgroundType, + ); + } catch (\Exception $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return new DataDownloadResponse($pdf, 'stamp-preview.pdf', 'application/pdf'); + } + + private function canEditSignatureStampPolicy(): bool { + $policy = $this->policyService->resolve(SignatureTextPolicy::KEY); + + return $policy->isVisible() && $policy->isEditableByCurrentActor(); + } +} diff --git a/lib/Controller/Traits/UploadValidator.php b/lib/Controller/Traits/UploadValidator.php new file mode 100644 index 0000000000..640222d994 --- /dev/null +++ b/lib/Controller/Traits/UploadValidator.php @@ -0,0 +1,64 @@ +|null $uploadedFile File array from IRequest::getUploadedFile() + * @param string $context Description for error messages (e.g., 'image', 'pdf') + * @return DataResponse>|null DataResponse with error if invalid, null if valid + */ + private function validateUploadedFile(?array $uploadedFile, string $context): ?DataResponse { + $phpFileUploadErrors = [ + UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), + UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), + UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), + UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), + UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), + UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), + UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), + UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), + ]; + + if (empty($uploadedFile)) { + return new DataResponse( + [ + 'message' => $this->l10n->t('No file uploaded'), + 'status' => 'failure', + ], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + + if (!empty($uploadedFile) && array_key_exists('error', $uploadedFile) && $uploadedFile['error'] !== UPLOAD_ERR_OK) { + return new DataResponse( + [ + 'message' => $phpFileUploadErrors[$uploadedFile['error']], + 'status' => 'failure', + ], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + + return null; + } +} diff --git a/lib/Dashboard/PendingSignaturesWidget.php b/lib/Dashboard/PendingSignaturesWidget.php index f288a867d0..d6a30fa9f5 100644 --- a/lib/Dashboard/PendingSignaturesWidget.php +++ b/lib/Dashboard/PendingSignaturesWidget.php @@ -45,6 +45,7 @@ public function getId(): string { #[Override] public function getTitle(): string { + // TRANSLATORS Dashboard widget title listing documents still waiting for the current user's signature. return $this->l10n->t('Pending signatures'); } @@ -84,6 +85,7 @@ public function isEnabled(): bool { public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems { $user = $this->userSession->getUser(); if (!$user) { + // TRANSLATORS Dashboard error shown when the logged-in account cannot be resolved. return new WidgetItems([], $this->l10n->t('User not found')); } @@ -121,6 +123,7 @@ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7 return new WidgetItems( $items, + // TRANSLATORS Empty dashboard state shown when there are no documents waiting for the current user's signature. empty($items) ? $this->l10n->t('No pending signatures') : '', ); } @@ -130,10 +133,10 @@ private function getSubtitle(SignRequest $signRequest, File $fileEntity): string $createdAt = $fileEntity->getCreatedAt(); if ($createdAt instanceof \DateTime) { $date = $createdAt->format('d/m/Y'); - // TRANSLATORS %s is the sender name, %s is the date - return $this->l10n->t('From: %s • Date: %s', [$displayName, $date]); + // TRANSLATORS Subtitle in pending-signature widget. %1$s is the requester name and %2$s is the request creation date. + return $this->l10n->t('From: %1$s • Date: %2$s', [$displayName, $date]); } - // TRANSLATORS %s is the sender name + // TRANSLATORS Subtitle in pending-signature widget. %s is the requester name. return $this->l10n->t('From: %s', [$displayName]); } @@ -157,6 +160,7 @@ public function getWidgetButtons(string $userId): array { new WidgetButton( WidgetButton::TYPE_MORE, $this->urlGenerator->linkToRouteAbsolute('libresign.page.index'), + // TRANSLATORS Dashboard button that opens the full list of documents related to signatures. $this->l10n->t('View all documents') ), ]; diff --git a/lib/Db/FileMapper.php b/lib/Db/FileMapper.php index c25425c800..ba35ab8824 100644 --- a/lib/Db/FileMapper.php +++ b/lib/Db/FileMapper.php @@ -261,7 +261,7 @@ public function neutralizeDeletedUser(string $userId, string $displayName): void } /** - * @return File[] + * @return list */ public function getChildrenFiles(int $parentId): array { $qb = $this->db->getQueryBuilder(); @@ -282,7 +282,7 @@ public function getChildrenFiles(int $parentId): array { $this->cacheEntity($child); } - return $children; + return array_values($children); } public function getParentEnvelope(int $fileId): ?File { diff --git a/lib/Db/IdentifyMethod.php b/lib/Db/IdentifyMethod.php index 31da06d0c1..a2214647fe 100644 --- a/lib/Db/IdentifyMethod.php +++ b/lib/Db/IdentifyMethod.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Db; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCP\AppFramework\Db\Entity; use OCP\DB\Types; @@ -93,4 +94,26 @@ public function setLastAttemptDate(null|string|\DateTime $lastAttemptDate): void public function getUniqueIdentifier(): string { return $this->getIdentifierKey() . ':' . $this->getIdentifierValue(); } + + public function getRequirement(): string { + $metadata = $this->getMetadata(); + if (is_array($metadata) && isset($metadata['requirement']) && is_string($metadata['requirement'])) { + $requirement = IdentifyMethodRequirement::tryFrom($metadata['requirement']); + if ($requirement !== null) { + return $requirement->value; + } + } + + return $this->mandatory === 1 + ? IdentifyMethodRequirement::REQUIRED->value + : IdentifyMethodRequirement::OPTIONAL->value; + } + + public function setRequirement(string $requirement): void { + $normalized = IdentifyMethodRequirement::tryFrom($requirement) ?? IdentifyMethodRequirement::OPTIONAL; + $metadata = $this->getMetadata() ?? []; + $metadata['requirement'] = $normalized->value; + $this->setMetadata($metadata); + $this->setMandatory($normalized === IdentifyMethodRequirement::REQUIRED ? 1 : 0); + } } diff --git a/lib/Db/PermissionSet.php b/lib/Db/PermissionSet.php new file mode 100644 index 0000000000..556df9c4a8 --- /dev/null +++ b/lib/Db/PermissionSet.php @@ -0,0 +1,107 @@ +addType('id', Types::INTEGER); + $this->addType('name', Types::STRING); + $this->addType('description', Types::TEXT); + $this->addType('scopeType', Types::STRING); + $this->addType('enabled', Types::SMALLINT); + $this->addType('priority', Types::SMALLINT); + $this->addType('policyJson', Types::TEXT); + $this->addType('createdAt', Types::DATETIME); + $this->addType('updatedAt', Types::DATETIME); + } + + public function isEnabled(): bool { + return $this->enabled === 1; + } + + public function setEnabled(bool $enabled): void { + $this->setter('enabled', [$enabled ? 1 : 0]); + } + + /** + * @param array $policyJson + */ + public function setPolicyJson(array $policyJson): void { + $this->setter('policyJson', [json_encode($policyJson, JSON_THROW_ON_ERROR)]); + } + + /** + * @return array + */ + public function getDecodedPolicyJson(): array { + $decoded = json_decode($this->policyJson, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * @param \DateTime|string $createdAt + */ + public function setCreatedAt($createdAt): void { + if (!$createdAt instanceof \DateTime) { + $createdAt = new \DateTime($createdAt, new \DateTimeZone('UTC')); + } + $this->createdAt = $createdAt; + $this->markFieldUpdated('createdAt'); + } + + public function getCreatedAt(): ?\DateTime { + return $this->createdAt; + } + + /** + * @param \DateTime|string $updatedAt + */ + public function setUpdatedAt($updatedAt): void { + if (!$updatedAt instanceof \DateTime) { + $updatedAt = new \DateTime($updatedAt, new \DateTimeZone('UTC')); + } + $this->updatedAt = $updatedAt; + $this->markFieldUpdated('updatedAt'); + } + + public function getUpdatedAt(): ?\DateTime { + return $this->updatedAt; + } +} diff --git a/lib/Db/PermissionSetBinding.php b/lib/Db/PermissionSetBinding.php new file mode 100644 index 0000000000..f760af1b46 --- /dev/null +++ b/lib/Db/PermissionSetBinding.php @@ -0,0 +1,52 @@ +addType('id', Types::INTEGER); + $this->addType('permissionSetId', Types::INTEGER); + $this->addType('targetType', Types::STRING); + $this->addType('targetId', Types::STRING); + $this->addType('createdAt', Types::DATETIME); + } + + /** + * @param \DateTime|string $createdAt + */ + public function setCreatedAt($createdAt): void { + if (!$createdAt instanceof \DateTime) { + $createdAt = new \DateTime($createdAt, new \DateTimeZone('UTC')); + } + $this->createdAt = $createdAt; + $this->markFieldUpdated('createdAt'); + } + + public function getCreatedAt(): ?\DateTime { + return $this->createdAt; + } +} diff --git a/lib/Db/PermissionSetBindingMapper.php b/lib/Db/PermissionSetBindingMapper.php new file mode 100644 index 0000000000..3952835c74 --- /dev/null +++ b/lib/Db/PermissionSetBindingMapper.php @@ -0,0 +1,101 @@ + + */ +class PermissionSetBindingMapper extends CachedQBMapper { + public function __construct(IDBConnection $db, ICacheFactory $cacheFactory) { + parent::__construct($db, $cacheFactory, 'libresign_permission_set_binding'); + } + + /** + * @throws DoesNotExistException + */ + public function getById(int $id): PermissionSetBinding { + $cached = $this->cacheGet('id:' . $id); + if ($cached instanceof PermissionSetBinding) { + return $cached; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + /** @var PermissionSetBinding */ + $entity = $this->findEntity($qb); + $this->cacheEntity($entity); + return $entity; + } + + /** + * @throws DoesNotExistException + */ + public function getByTarget(string $targetType, string $targetId): PermissionSetBinding { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType))) + ->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId))); + + /** @var PermissionSetBinding */ + $entity = $this->findEntity($qb); + $this->cacheEntity($entity); + return $entity; + } + + /** + * @param list $targetIds + * @return list + */ + public function findByTargets(string $targetType, array $targetIds): array { + if ($targetIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType))) + ->andWhere($qb->expr()->in('target_id', $qb->createNamedParameter($targetIds, IQueryBuilder::PARAM_STR_ARRAY))); + + /** @var list */ + $entities = $this->findEntities($qb); + foreach ($entities as $entity) { + $this->cacheEntity($entity); + } + + return $entities; + } + + /** + * @return list + */ + public function findByTargetType(string $targetType): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType))); + + /** @var list */ + $entities = $this->findEntities($qb); + foreach ($entities as $entity) { + $this->cacheEntity($entity); + } + + return $entities; + } +} diff --git a/lib/Db/PermissionSetMapper.php b/lib/Db/PermissionSetMapper.php new file mode 100644 index 0000000000..bafa288eb2 --- /dev/null +++ b/lib/Db/PermissionSetMapper.php @@ -0,0 +1,66 @@ + + */ +class PermissionSetMapper extends CachedQBMapper { + public function __construct(IDBConnection $db, ICacheFactory $cacheFactory) { + parent::__construct($db, $cacheFactory, 'libresign_permission_set'); + } + + /** + * @throws DoesNotExistException + */ + public function getById(int $id): PermissionSet { + $cached = $this->cacheGet('id:' . $id); + if ($cached instanceof PermissionSet) { + return $cached; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + /** @var PermissionSet */ + $entity = $this->findEntity($qb); + $this->cacheEntity($entity); + return $entity; + } + + /** + * @param list $ids + * @return list + */ + public function findByIds(array $ids): array { + if ($ids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + + /** @var list */ + $entities = $this->findEntities($qb); + foreach ($entities as $entity) { + $this->cacheEntity($entity); + } + + return $entities; + } +} diff --git a/lib/Enum/DocMdpLevel.php b/lib/Enum/DocMdpLevel.php index ad75d6bc08..ec93e3dbfd 100644 --- a/lib/Enum/DocMdpLevel.php +++ b/lib/Enum/DocMdpLevel.php @@ -23,18 +23,26 @@ public function isCertifying(): bool { public function getLabel(IL10N $l10n): string { return match($this) { + // TRANSLATORS Short label for DocMDP level meaning the PDF is not certified. DocMDP is a PDF permission mechanism used with digital signatures. self::NOT_CERTIFIED => $l10n->t('No certification'), + // TRANSLATORS Short label for strict DocMDP level where no edits are allowed after certification. self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('No changes allowed'), + // TRANSLATORS Short label for DocMDP level that allows filling form fields after certification. self::CERTIFIED_FORM_FILLING => $l10n->t('Form filling allowed'), + // TRANSLATORS Short label for DocMDP level that allows form filling and comments after certification. self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('Form filling and commenting allowed'), }; } public function getDescription(IL10N $l10n): string { return match($this) { + // TRANSLATORS Description of DocMDP behavior when the PDF is unsigned/un-certified. It warns that later edits will mark signatures as modified. self::NOT_CERTIFIED => $l10n->t('The document is not certified; edits and new signatures are allowed, but any change will mark previous signatures as modified.'), + // TRANSLATORS Description of strict DocMDP behavior: any post-signature edit invalidates certification. self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('After the first signature, no further edits or signatures are allowed; any change invalidates the certification.'), + // TRANSLATORS Description of DocMDP behavior allowing only form filling and extra signatures after the first signature. self::CERTIFIED_FORM_FILLING => $l10n->t('After the first signature, only form filling and additional signatures are allowed; other changes invalidate the certification.'), + // TRANSLATORS Description of DocMDP behavior allowing form filling, comments, and extra signatures after certification. self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('After the first signature, form filling, comments, and additional signatures are allowed; other changes invalidate the certification.'), }; } diff --git a/lib/Enum/FileStatus.php b/lib/Enum/FileStatus.php index 7395a8d747..ae86794cff 100644 --- a/lib/Enum/FileStatus.php +++ b/lib/Enum/FileStatus.php @@ -27,19 +27,19 @@ enum FileStatus: int { public function getLabel(IL10N $l10n): string { return match($this) { - // TRANSLATORS Name of the status when document is not a LibreSign file + // TRANSLATORS File status shown when the file is not part of any LibreSign signing flow. self::NOT_LIBRESIGN_FILE => $l10n->t('Not LibreSign file'), - // TRANSLATORS Name of the status that the document is still as a draft + // TRANSLATORS File status shown while the signature request is still being prepared. self::DRAFT => $l10n->t('Draft'), - // TRANSLATORS Name of the status that the document can be signed + // TRANSLATORS File status shown when at least one signer can sign now. self::ABLE_TO_SIGN => $l10n->t('Ready to sign'), - // TRANSLATORS Name of the status when the document has already been partially signed + // TRANSLATORS File status shown when some required signers have signed, but not all. self::PARTIAL_SIGNED => $l10n->t('Partially signed'), - // TRANSLATORS Name of the status when the document has been completely signed + // TRANSLATORS File status shown when all required signatures are complete. self::SIGNED => $l10n->t('Signed'), - // TRANSLATORS Name of the status when the document was deleted + // TRANSLATORS File status shown when the LibreSign file record was deleted. self::DELETED => $l10n->t('Deleted'), - // TRANSLATORS Name of the status when the document is currently being signed + // TRANSLATORS File status shown during asynchronous background signing operations. self::SIGNING_IN_PROGRESS => $l10n->t('Signing in progress'), }; } diff --git a/lib/Enum/IdentifyMethodRequirement.php b/lib/Enum/IdentifyMethodRequirement.php new file mode 100644 index 0000000000..ed4873c4bf --- /dev/null +++ b/lib/Enum/IdentifyMethodRequirement.php @@ -0,0 +1,15 @@ + $l10n->t('Draft'), - // TRANSLATORS Name of the status when signer can sign the document + // TRANSLATORS Signer workflow status shown when the signer is currently allowed to apply their digital signature. self::ABLE_TO_SIGN => $l10n->t('Ready to sign'), - // TRANSLATORS Name of the status when signer has already signed + // TRANSLATORS Signer workflow status shown after this signer has successfully signed the document. self::SIGNED => $l10n->t('Signed'), }; } diff --git a/lib/Exception/FooterStampUnavailableException.php b/lib/Exception/FooterStampUnavailableException.php new file mode 100644 index 0000000000..9ae56b2311 --- /dev/null +++ b/lib/Exception/FooterStampUnavailableException.php @@ -0,0 +1,14 @@ + @@ -37,9 +35,9 @@ public function __construct( private ValidateHelper $validateHelper, private IdentifyMethodService $identifyMethodService, private CertificateEngineFactory $certificateEngineFactory, - private IAppConfig $appConfig, + private PolicyService $policyService, private IAppManager $appManager, - private ConfigService $docMdpConfigService, + private TemplateLoaderAssets $assets, ) { } @@ -53,32 +51,29 @@ public function handle(Event $event): void { return; } - foreach ($this->getInitialStatePayload() as $key => $value) { + $initialStatePayload = $this->getInitialStatePayload(); + foreach ($initialStatePayload as $key => $value) { $this->initialState->provideInitialState($key, $value); } - Util::addScript(Application::APP_ID, 'libresign-tab'); - Util::addStyle(Application::APP_ID, 'libresign-tab'); + if ($initialStatePayload['certificate_ok'] === true) { + $this->assets->addInitScript(Application::APP_ID, 'libresign-init'); + } + + $this->assets->addScript(Application::APP_ID, 'libresign-tab'); + $this->assets->addStyle(Application::APP_ID, 'libresign-tab'); } protected function getInitialStatePayload(): array { return [ 'certificate_ok' => $this->certificateEngineFactory->getEngine()->isSetupOk(), - 'identify_methods' => $this->identifyMethodService->getIdentifyMethodsSettings(), - 'signature_flow' => $this->getSignatureFlow(), - 'docmdp_config' => $this->docMdpConfigService->getConfig(), + 'effective_policies' => [ + 'policies' => $this->policyService->resolveKnownPolicyStates(), + ], 'can_request_sign' => $this->canRequestSign(), ]; } - private function getSignatureFlow(): string { - return $this->appConfig->getValueString( - Application::APP_ID, - 'signature_flow', - \OCA\Libresign\Enum\SignatureFlow::NONE->value - ); - } - private function canRequestSign(): bool { try { $this->validateHelper->canRequestSign($this->userSession->getUser()); diff --git a/lib/Files/TemplateLoaderAssets.php b/lib/Files/TemplateLoaderAssets.php new file mode 100644 index 0000000000..255944cd01 --- /dev/null +++ b/lib/Files/TemplateLoaderAssets.php @@ -0,0 +1,25 @@ +parseX509($certificate); } + #[\Override] + public function setPolicyUserIdForValidation(?string $userId): self { + $this->policyUserIdForValidation = is_string($userId) && trim($userId) !== '' + ? trim($userId) + : null; + + return $this; + } + private function parseX509(string $x509): array { $parsed = openssl_x509_parse(openssl_x509_read($x509)); @@ -195,7 +209,7 @@ private function addCrlValidationInfo(array &$certData, string $certPem): void { return; } - $crlDetails = $this->crlRevocationChecker->validate($extractedUrls, $certPem); + $crlDetails = $this->crlRevocationChecker->validate($extractedUrls, $certPem, $this->policyUserIdForValidation); $certData['crl_validation'] = $crlDetails['status']; if (!empty($crlDetails['revoked_at'])) { $certData['crl_revoked_at'] = $crlDetails['revoked_at']; @@ -204,10 +218,10 @@ private function addCrlValidationInfo(array &$certData, string $certPem): void { } } - $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true); - $certData['crl_validation'] = $externalValidationEnabled - ? CrlValidationStatus::MISSING - : CrlValidationStatus::DISABLED; + $emptyCrlValidation = $this->crlRevocationChecker->validate([], $certPem, $this->policyUserIdForValidation); + $certData['crl_validation'] = ($emptyCrlValidation['status'] ?? CrlValidationStatus::NO_URLS) === CrlValidationStatus::DISABLED + ? CrlValidationStatus::DISABLED + : CrlValidationStatus::MISSING; $certData['crl_urls'] = []; } @@ -305,7 +319,7 @@ private function configureIdentifyMethodsForEngine(string $engine): void { 'enabled' => true, 'mandatory' => true, ]]; - $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $config); + $this->policyService->saveSystem(IdentifyMethodsPolicy::KEY, $config); } #[\Override] @@ -313,6 +327,16 @@ public function getEngine(): string { if ($this->engine) { return $this->engine; } + $configValues = $this->appConfig->getAllValues(Application::APP_ID); + $configuredEngine = $configValues['certificate_engine'] ?? ''; + if (is_string($configuredEngine) && $configuredEngine !== '') { + $this->engine = $configuredEngine; + return $this->engine; + } + if (is_scalar($configuredEngine) && (string)$configuredEngine !== '') { + $this->engine = (string)$configuredEngine; + return $this->engine; + } $this->engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl'); return $this->engine; } @@ -477,7 +501,7 @@ public function getLeafExpiryInDays(): int { if ($this->leafExpiryOverrideInDays !== null) { return $this->leafExpiryOverrideInDays; } - $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365); + $exp = (int)$this->policyService->resolve(ExpirationRulesPolicy::KEY_EXPIRY_IN_DAYS)->getEffectiveValue(); return $exp > 0 ? $exp : 365; } @@ -764,9 +788,13 @@ public function toArray(): array { ); $names = $this->getNames(); foreach ($names as $name => $value) { + $filteredValue = $this->filterNameValue($name, $value, $generated); + if ($filteredValue === null) { + continue; + } $return['rootCert']['names'][] = [ 'id' => $name, - 'value' => $this->filterNameValue($name, $value, $generated), + 'value' => $filteredValue, ]; } return $return; diff --git a/lib/Handler/CertificateEngine/CertificateEngineFactory.php b/lib/Handler/CertificateEngine/CertificateEngineFactory.php index b1d739a60f..fa8002915e 100644 --- a/lib/Handler/CertificateEngine/CertificateEngineFactory.php +++ b/lib/Handler/CertificateEngine/CertificateEngineFactory.php @@ -14,6 +14,7 @@ class CertificateEngineFactory { private static ?IEngineHandler $engine = null; + public function __construct( private IAppConfig $appConfig, private OpenSslHandler $openSslHandler, @@ -21,20 +22,57 @@ public function __construct( private NoneHandler $noneHandler, ) { } + public function getEngine(string $engineName = '', array $rootCert = []): IEngineHandler { if (self::$engine && !empty($engineName) && self::$engine->getName() === $engineName) { + self::$engine->populateInstance($rootCert); return self::$engine; } + if (!$engineName) { - $engineName = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl'); + $configuredEngineName = $this->getConfiguredEngineName(); + + if (self::$engine && self::$engine->getName() === $configuredEngineName) { + self::$engine->populateInstance($rootCert); + return self::$engine; + } + + $engineName = $configuredEngineName; + } + + self::$engine = $this->resolveHandler($engineName); + self::$engine->populateInstance($rootCert); + return self::$engine; + } + + public function setEngine(string $engineName): IEngineHandler { + $handler = $this->resolveHandler($engineName); + $handler->setEngine($engineName); + self::$engine = $handler; + return self::$engine; + } + + private function getConfiguredEngineName(): string { + $configValues = $this->appConfig->getAllValues(Application::APP_ID); + $configuredEngineName = $configValues['certificate_engine'] ?? ''; + + if (is_string($configuredEngineName) && $configuredEngineName !== '') { + return $configuredEngineName; + } + + if (is_scalar($configuredEngineName) && (string)$configuredEngineName !== '') { + return (string)$configuredEngineName; } - self::$engine = match ($engineName) { + + return $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl'); + } + + private function resolveHandler(string $engineName): IEngineHandler { + return match ($engineName) { 'openssl' => $this->openSslHandler, 'cfssl' => $this->cfsslHandler, 'none' => $this->noneHandler, default => throw new LibresignException("Certificate engine not found: $engineName"), }; - self::$engine->populateInstance($rootCert); - return self::$engine; } } diff --git a/lib/Handler/CertificateEngine/CfsslHandler.php b/lib/Handler/CertificateEngine/CfsslHandler.php index 765b023de0..972a34b72d 100644 --- a/lib/Handler/CertificateEngine/CfsslHandler.php +++ b/lib/Handler/CertificateEngine/CfsslHandler.php @@ -22,6 +22,7 @@ use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\Crl\CrlRevocationChecker; use OCA\Libresign\Service\Install\InstallService; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\Process\ProcessManager; use OCA\Libresign\Vendor\Symfony\Component\Process\Process; use OCP\Files\AppData\IAppDataFactory; @@ -42,6 +43,8 @@ class CfsslHandler extends AEngineHandler implements IEngineHandler { public const CFSSL_URI = 'http://127.0.0.1:8888/api/v1/cfssl/'; private const string PROCESS_SOURCE = 'cfssl'; + private const STARTUP_POLL_INTERVAL_MICROSECONDS = 250000; + private const STARTUP_MAX_POLLS = 40; /** @var Client */ protected $client; @@ -58,6 +61,7 @@ public function __construct( protected CertificatePolicyService $certificatePolicyService, protected IURLGenerator $urlGenerator, protected CaIdentifierService $caIdentifierService, + protected PolicyService $policyService, protected CrlMapper $crlMapper, protected LoggerInterface $logger, CrlRevocationChecker $crlRevocationChecker, @@ -72,6 +76,7 @@ public function __construct( $certificatePolicyService, $urlGenerator, $caIdentifierService, + $policyService, $logger, $crlRevocationChecker, ); @@ -294,9 +299,19 @@ private function isUp(): bool { } catch (ConnectException) { // Port not yet accepting connections — server still starting return false; - } catch (RequestException $exception) { - if ($exception->getCode() === 404) { - throw new \Exception('Endpoint /health of CFSSL server not found. Maybe you are using incompatible version of CFSSL server. Use latests version.', 1); + return false; + } catch (RequestException $th) { + switch ($th->getCode()) { + case 404: + throw new \Exception('Endpoint /health of CFSSL server not found. Maybe you are using incompatible version of CFSSL server. Use latests version.', 1); + default: + if ($th->getHandlerContext() && $th->getHandlerContext()['error']) { + if (str_contains((string)$th->getHandlerContext()['error'], 'connect')) { + return false; + } + throw new \Exception($th->getHandlerContext()['error'], 1); + } + return false; } return false; } @@ -313,6 +328,7 @@ private function wakeUp(): void { if ($this->portOpen()) { return; } + $binary = $this->getBinary(); $configPath = $this->getCurrentConfigPath(); if (!$configPath) { @@ -339,10 +355,10 @@ private function wakeUp(): void { ]); } - $loops = 0; - while (!$this->portOpen() && $loops <= 9) { - sleep(1); - $loops++; + $polls = 0; + while (!$this->portOpen() && $polls < self::STARTUP_MAX_POLLS) { + usleep(self::STARTUP_POLL_INTERVAL_MICROSECONDS); + $polls++; } } diff --git a/lib/Handler/CertificateEngine/IEngineHandler.php b/lib/Handler/CertificateEngine/IEngineHandler.php index 2f9073c31b..4d0c79ce61 100644 --- a/lib/Handler/CertificateEngine/IEngineHandler.php +++ b/lib/Handler/CertificateEngine/IEngineHandler.php @@ -78,6 +78,13 @@ public function toArray(): array; */ public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string; + /** + * Parse an X.509 certificate and return its details with CRL validation + * @param string $certificate PEM-encoded certificate + * @return array Parsed certificate data including CRL validation information + */ + public function setPolicyUserIdForValidation(?string $userId): self; + /** * Parse an X.509 certificate and return its details with CRL validation * @param string $certificate PEM-encoded certificate diff --git a/lib/Handler/CertificateEngine/NoneHandler.php b/lib/Handler/CertificateEngine/NoneHandler.php index e469949d6a..5d66514ec4 100644 --- a/lib/Handler/CertificateEngine/NoneHandler.php +++ b/lib/Handler/CertificateEngine/NoneHandler.php @@ -8,7 +8,18 @@ namespace OCA\Libresign\Handler\CertificateEngine; +use OCA\Libresign\Helper\ConfigureCheckHelper; + class NoneHandler extends AEngineHandler implements IEngineHandler { + #[\Override] + public function configureCheck(): array { + return [ + (new ConfigureCheckHelper()) + ->setSuccessMessage($this->getSetupSuccessMessage()) + ->setResource($this->getConfigureCheckResourceName()), + ]; + } + #[\Override] protected function getConfigureCheckResourceName(): string { return 'none-configure'; diff --git a/lib/Handler/CertificateEngine/OpenSslHandler.php b/lib/Handler/CertificateEngine/OpenSslHandler.php index 7b8a521d60..6bc224e7d4 100644 --- a/lib/Handler/CertificateEngine/OpenSslHandler.php +++ b/lib/Handler/CertificateEngine/OpenSslHandler.php @@ -15,6 +15,7 @@ use OCA\Libresign\Service\CaIdentifierService; use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\Crl\CrlRevocationChecker; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\SerialNumberService; use OCA\Libresign\Service\SubjectAlternativeNameService; use OCP\Files\AppData\IAppDataFactory; @@ -46,6 +47,7 @@ public function __construct( protected IURLGenerator $urlGenerator, protected SerialNumberService $serialNumberService, protected CaIdentifierService $caIdentifierService, + protected PolicyService $policyService, protected LoggerInterface $logger, protected CrlMapper $crlMapper, protected SubjectAlternativeNameService $subjectAlternativeNameService, @@ -60,6 +62,7 @@ public function __construct( $certificatePolicyService, $urlGenerator, $caIdentifierService, + $policyService, $logger, $crlRevocationChecker, ); diff --git a/lib/Handler/DocMdpHandler.php b/lib/Handler/DocMdpHandler.php index 52f20a19bf..8f6c254be9 100644 --- a/lib/Handler/DocMdpHandler.php +++ b/lib/Handler/DocMdpHandler.php @@ -55,6 +55,7 @@ public function extractDocMdpData($resource): array { if ($docmdpLevel->isCertifying() && $this->isOnlySingleDocMdpViolation($isoStatus)) { $result['docmdp_validation'] = [ 'valid' => false, + // TRANSLATORS: Validation message shown for a PDF with more than one DocMDP (document modification policy) certifying signature. Only the first certifying signature defines the document permission level. 'message' => $this->l10n->t('Multiple DocMDP signatures detected. The first certifying signature determines the document\'s permission level.'), ]; } @@ -283,18 +284,20 @@ private function detectModifications($pdfResource): array { */ private function validateModifications(DocMdpLevel $docmdpLevel, array $modificationInfo, $pdfResource): array { if (!$modificationInfo['modified']) { + // TRANSLATORS: Validation result for a signed PDF that was not changed after the signature was applied. return $this->buildValidationResult( true, File::MODIFICATION_UNMODIFIED, - 'Document has not been modified after signing' + $this->l10n->t('Document has not been modified after signing') ); } if ($docmdpLevel === DocMdpLevel::NOT_CERTIFIED) { + // TRANSLATORS: Validation result for a signed PDF that was modified, but the document has no DocMDP certification restrictions. return $this->buildValidationResult( true, File::MODIFICATION_ALLOWED, - 'Document was modified after signing' + $this->l10n->t('Document was modified after signing') ); } @@ -302,10 +305,11 @@ private function validateModifications(DocMdpLevel $docmdpLevel, array $modifica $allowedTypes = self::ALLOWED_MODIFICATIONS[$docmdpLevel->name] ?? null; if ($allowedTypes === null) { + // TRANSLATORS: Validation error for a signed PDF modified in a way that violates its DocMDP (document modification policy) certification rules. return $this->buildValidationResult( false, File::MODIFICATION_VIOLATION, - 'Invalid: Document was modified after signing (DocMDP violation)' + $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation)') ); } @@ -329,14 +333,14 @@ private function validateModifications(DocMdpLevel $docmdpLevel, array $modifica * * @param bool $valid Whether modification is valid * @param int $status Status constant from File class - * @param string $messageKey Translation key + * @param string $message Translated message * @return array Validation result */ - private function buildValidationResult(bool $valid, int $status, string $messageKey): array { + private function buildValidationResult(bool $valid, int $status, string $message): array { return [ 'valid' => $valid, 'status' => $status, - 'message' => $this->l10n->t($messageKey), + 'message' => $message, ]; } @@ -348,10 +352,14 @@ private function buildValidationResult(bool $valid, int $status, string $message */ private function getAllowedModificationMessage(DocMdpLevel $level): string { return match ($level) { - DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => 'Invalid: Document was modified after signing (DocMDP violation - no changes allowed)', - DocMdpLevel::CERTIFIED_FORM_FILLING => 'Document form fields were modified (allowed by DocMDP P=2)', - DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => 'Document form fields or annotations were modified (allowed by DocMDP P=3)', - default => 'Document was modified after signing', + // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=1, which does not allow any change after signing. + DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation - no changes allowed)'), + // TRANSLATORS: Validation result for a certified PDF with DocMDP level P=2. Only form field completion is allowed after signing. + DocMdpLevel::CERTIFIED_FORM_FILLING => $this->l10n->t('Document form fields were modified (allowed by DocMDP P=2)'), + // TRANSLATORS: Validation result for a certified PDF with DocMDP level P=3. Form field completion and annotations are allowed after signing. + DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $this->l10n->t('Document form fields or annotations were modified (allowed by DocMDP P=3)'), + // TRANSLATORS: Generic validation result for a signed PDF that was modified after signing. + default => $this->l10n->t('Document was modified after signing'), }; } @@ -363,10 +371,14 @@ private function getAllowedModificationMessage(DocMdpLevel $level): string { */ private function getViolationMessage(DocMdpLevel $level): string { return match ($level) { - DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => 'Invalid: Document was modified after signing (DocMDP violation - no changes allowed)', - DocMdpLevel::CERTIFIED_FORM_FILLING => 'Invalid: Document was modified after signing (DocMDP P=2 only allows form field changes)', - DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => 'Invalid: Document was modified after signing (DocMDP P=3 only allows form fields and annotations)', - default => 'Invalid: Document was modified after signing (DocMDP violation)', + // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=1, which does not allow any change after signing. + DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation - no changes allowed)'), + // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=2. Only form field completion is allowed after signing. + DocMdpLevel::CERTIFIED_FORM_FILLING => $this->l10n->t('Invalid: Document was modified after signing (DocMDP P=2 only allows form field changes)'), + // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=3. Only form field completion and annotations are allowed after signing. + DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $this->l10n->t('Invalid: Document was modified after signing (DocMDP P=3 only allows form fields and annotations)'), + // TRANSLATORS: Generic validation error for a signed PDF modified in a way that violates DocMDP (document modification policy) certification rules. + default => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation)'), }; } diff --git a/lib/Handler/FooterHandler.php b/lib/Handler/FooterHandler.php index 71307aafeb..8034ae7aaf 100644 --- a/lib/Handler/FooterHandler.php +++ b/lib/Handler/FooterHandler.php @@ -12,6 +12,9 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color; use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding; use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel; @@ -30,7 +33,13 @@ use OCP\L10N\IFactory; class FooterHandler { + public const DEFAULT_TEMPLATE_PATH = __DIR__ . '/Templates/footer.twig'; + private QrCode $qrCode; + /** @var array */ + private array $requestPolicyOverrides = []; + private ?string $templateOverride = null; + private ?bool $writeQrcodeOnFooterOverride = null; private const int MIN_QRCODE_SIZE = 100; private const float POINT_TO_MILIMETER = 0.3527777778; @@ -41,17 +50,17 @@ public function __construct( private IL10N $l10n, private IFactory $l10nFactory, private ITempManager $tempManager, + private PolicyService $policyService, private TemplateVariables $templateVars, ) { } - public function getFooter(array $dimensions): string { - $add_footer = (bool)$this->appConfig->getValueBool(Application::APP_ID, 'add_footer', true); - if (!$add_footer) { + public function getFooter(array $dimensions, bool $forceEnabled = false): string { + if (!$forceEnabled && !$this->isFooterEnabled()) { return ''; } - $htmlFooter = $this->getRenderedHtmlFooter(); + $htmlFooter = $this->getRenderedHtmlFooter($forceEnabled); foreach ($dimensions as $dimension) { if (!isset($pdf)) { $pdf = new Mpdf([ @@ -94,14 +103,14 @@ public function getMetadata(File $file, FileEntity $fileEntity): array { return $metadata; } - private function getRenderedHtmlFooter(): string { + private function getRenderedHtmlFooter(bool $forceEnabled = false): string { try { $twigEnvironment = new Environment( new FilesystemLoader(), ); return $twigEnvironment ->createTemplate($this->getTemplate()) - ->render($this->prepareTemplateVars()); + ->render($this->prepareTemplateVars($forceEnabled)); } catch (SyntaxError $e) { throw new LibresignException($e->getMessage()); } @@ -112,7 +121,36 @@ public function setTemplateVar(string $name, mixed $value): self { return $this; } - private function prepareTemplateVars(): array { + /** @param array $requestPolicyOverrides */ + public function setRequestPolicyOverrides(array $requestPolicyOverrides): self { + $this->requestPolicyOverrides = $requestPolicyOverrides; + return $this; + } + + public function setWriteQrcodeOnFooterOverride(?bool $value): self { + $this->writeQrcodeOnFooterOverride = $value; + return $this; + } + + public function setTemplateOverride(?string $template): self { + $this->templateOverride = $template; + return $this; + } + + public function getEffectiveFooterPolicyAsJson(): string { + return (string)$this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue(); + } + + /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ + private function resolveFooterPolicy(): array { + return FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue() + ); + } + + private function prepareTemplateVars(bool $forceEnabled = false): array { + $footerPolicy = $this->resolveFooterPolicy(); + if (!$this->templateVars->getSignedBy()) { $this->templateVars->setSignedBy( $this->appConfig->getValueString(Application::APP_ID, 'footer_signed_by', $this->l10n->t('Digitally signed by LibreSign.')) @@ -132,7 +170,7 @@ private function prepareTemplateVars(): array { } if (!$this->templateVars->getValidationSite() && $this->templateVars->getUuid()) { - $validationSite = $this->appConfig->getValueString(Application::APP_ID, 'validation_site'); + $validationSite = $footerPolicy['validationSite']; if ($validationSite) { $this->templateVars->setValidationSite( rtrim($validationSite, '/') . '/' . $this->templateVars->getUuid() @@ -155,7 +193,8 @@ private function prepareTemplateVars(): array { } } - if ($this->appConfig->getValueBool(Application::APP_ID, 'write_qrcode_on_footer', true) && $this->templateVars->getValidationSite()) { + $shouldWriteQrcode = $this->writeQrcodeOnFooterOverride ?? $footerPolicy['writeQrcodeOnFooter']; + if ($shouldWriteQrcode && $this->templateVars->getValidationSite()) { $this->templateVars->setQrcode($this->getQrCodeImageBase64($this->templateVars->getValidationSite())); } @@ -170,15 +209,23 @@ private function prepareTemplateVars(): array { } public function getTemplate(): string { - $footerTemplate = $this->appConfig->getValueString(Application::APP_ID, 'footer_template', ''); - if ($footerTemplate) { - return $footerTemplate; + if ($this->templateOverride !== null) { + return trim($this->templateOverride) !== '' ? $this->templateOverride : $this->getDefaultTemplate(); + } + + $footerPolicy = $this->resolveFooterPolicy(); + + if ($footerPolicy['customizeFooterTemplate']) { + $policyTemplate = trim((string)($footerPolicy['footerTemplate'] ?? '')); + if ($policyTemplate !== '') { + return $policyTemplate; + } } return $this->getDefaultTemplate(); } public function getDefaultTemplate(): string { - return (string)file_get_contents(__DIR__ . '/Templates/footer.twig'); + return (string)file_get_contents(self::DEFAULT_TEMPLATE_PATH); } private function getQrCodeImageBase64(string $text): string { @@ -204,4 +251,10 @@ private function getQrCodeImageBase64(string $text): string { public function getTemplateVariablesMetadata(): array { return $this->templateVars->getVariablesMetadata(); } + + private function isFooterEnabled(): bool { + return FooterPolicyValue::isEnabled( + $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue() + ); + } } diff --git a/lib/Handler/PdfTk/Pdf.php b/lib/Handler/PdfTk/Pdf.php index 1061f3496b..c7aac6aee0 100644 --- a/lib/Handler/PdfTk/Pdf.php +++ b/lib/Handler/PdfTk/Pdf.php @@ -9,7 +9,7 @@ namespace OCA\Libresign\Handler\PdfTk; use OCA\Libresign\AppInfo\Application; -use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Exception\FooterStampUnavailableException; use OCA\Libresign\Helper\JavaHelper; use OCA\Libresign\Vendor\mikehaertl\pdftk\Command; use OCA\Libresign\Vendor\mikehaertl\pdftk\Pdf as BasePdf; @@ -45,16 +45,16 @@ public function applyStamp(string $input, string $stamp): string { protected function configureCommand(): void { $this->javaPath = $this->javaHelper->getJavaPath(); if ($this->javaPath === '') { - throw new RuntimeException('Java path not set.'); + throw new FooterStampUnavailableException('Java path not set.'); } $this->pdftkPath = $this->appConfig->getValueString(Application::APP_ID, 'pdftk_path'); if ($this->pdftkPath === '') { - throw new RuntimeException('PDFtk path not set.'); + throw new FooterStampUnavailableException('PDFtk path not set.'); } if (!file_exists($this->javaPath) || !file_exists($this->pdftkPath)) { - throw new LibresignException($this->l10n->t('The admin hasn\'t set up LibreSign yet, please wait.')); + throw new FooterStampUnavailableException($this->l10n->t('The admin hasn\'t set up LibreSign yet, please wait.')); } $cmd = sprintf('%s -jar %s', escapeshellcmd($this->javaPath), escapeshellarg($this->pdftkPath)); diff --git a/lib/Handler/SignEngine/JSignPdfHandler.php b/lib/Handler/SignEngine/JSignPdfHandler.php index dec5eee5bc..1d32b15216 100644 --- a/lib/Handler/SignEngine/JSignPdfHandler.php +++ b/lib/Handler/SignEngine/JSignPdfHandler.php @@ -16,6 +16,11 @@ use OCA\Libresign\Helper\JavaHelper; use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; use OCA\Libresign\Service\Install\InstallService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\SignatureHashAlgorithm\SignatureHashAlgorithmPolicy; +use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; @@ -32,7 +37,6 @@ class JSignPdfHandler extends Pkcs12Handler { private const float MIN_PDF_VERSION_SHA256 = 1.6; private const string TARGET_PDF_VERSION_SHA256 = '1.6'; private const float MIN_PDF_VERSION_SHA1_REJECT = 1.7; - private const float SIGNATURE_DEFAULT_FONT_SIZE = 10.0; private const int PAGE_FIRST = 1; private const int SCALE_FACTOR_MIN = 5; @@ -48,6 +52,7 @@ public function __construct( private SignatureTextService $signatureTextService, private ITempManager $tempManager, private SignatureBackgroundService $signatureBackgroundService, + private PolicyService $policyService, protected CertificateEngineFactory $certificateEngineFactory, protected JavaHelper $javaHelper, private DocMdpConfigService $docMdpConfigService, @@ -153,7 +158,7 @@ private function createEmptyFile(string $path): void { } private function getHashAlgorithm(string $pdfContent): string { - $configuredAlgorithm = $this->appConfig->getValueString(Application::APP_ID, 'signature_hash_algorithm', 'SHA256'); + $configuredAlgorithm = (string)$this->policyService->resolve(SignatureHashAlgorithmPolicy::KEY)->getEffectiveValue(); /** * Need to respect the follow code: * https://github.com/intoolswetrust/jsignpdf/blob/JSignPdf_2_2_2/jsignpdf/src/main/java/net/sf/jsignpdf/types/HashAlgorithm.java#L46-L47 @@ -175,6 +180,9 @@ private function extractPdfVersion(string $content): ?float { } private function getHashAlgorithmForPdfVersion(float $pdfVersion, string $configuredAlgorithm): string { + // Legacy compatibility: JSignPdf still requires SHA1 for very old PDFs (< 1.6). + // The policy still exposes SHA1 for supported legacy workflows, and the runtime + // must continue enforcing this fallback for ancient PDFs that JSignPdf cannot sign otherwise. if ($pdfVersion < 1.6) { return 'SHA1'; } @@ -226,7 +234,7 @@ private function requiresPdfVersionUpgradeForSha256(float $version): bool { if ($version >= self::MIN_PDF_VERSION_SHA256) { return false; } - $hashAlgorithm = $this->appConfig->getValueString(Application::APP_ID, 'signature_hash_algorithm', 'SHA256'); + $hashAlgorithm = (string)$this->policyService->resolve(SignatureHashAlgorithmPolicy::KEY)->getEffectiveValue(); return $hashAlgorithm === 'SHA256'; } @@ -294,6 +302,7 @@ public function getSignedContent(): string { private function signUsingVisibleElements(string $normalizedPdf, string $hashAlgorithm): string { $visibleElements = $this->getVisibleElements(); if ($visibleElements) { + $signed = ''; $jSignPdf = $this->getJSignPdf(); $renderMode = $this->signatureTextService->getRenderMode(); @@ -313,7 +322,7 @@ private function signUsingVisibleElements(string $normalizedPdf, string $hashAlg } $fontSize = $this->parseSignatureText()['templateFontSize']; - if ($fontSize === self::SIGNATURE_DEFAULT_FONT_SIZE || !$fontSize || $params['--l2-text'] === '""') { + if ($fontSize === SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE || !$fontSize || $params['--l2-text'] === '""') { $fontSize = 0; } @@ -640,24 +649,25 @@ private function listParamsToString(array $params): string { } private function getTsaParameters(): array { - $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + $tsaSettings = $this->getTsaSettings(); + $tsaUrl = $tsaSettings['url']; if (empty($tsaUrl)) { return []; } $params = [ '--tsa-server-url' => $tsaUrl, - '--tsa-policy-oid' => $this->appConfig->getValueString(Application::APP_ID, 'tsa_policy_oid', ''), + '--tsa-policy-oid' => $tsaSettings['policy_oid'], ]; if (!$params['--tsa-policy-oid']) { unset($params['--tsa-policy-oid']); } - $tsaAuthType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none'); + $tsaAuthType = $tsaSettings['auth_type']; if ($tsaAuthType === 'basic') { - $tsaUsername = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', ''); - $tsaPassword = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', ''); + $tsaUsername = $tsaSettings['username']; + $tsaPassword = $this->appConfig->getValueString(Application::APP_ID, TsaPolicy::PASSWORD_APP_CONFIG_KEY, ''); if (!empty($tsaUsername) && !empty($tsaPassword)) { $params['--tsa-authentication'] = 'PASSWORD'; @@ -669,6 +679,15 @@ private function getTsaParameters(): array { return $params; } + /** + * @return array{url: string, policy_oid: string, auth_type: string, username: string} + */ + private function getTsaSettings(): array { + $resolved = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue(); + $settings = TsaPolicyValue::decode($resolved); + return $settings; + } + private function signWrapper(JSignPDF $jSignPDF): string { try { return $jSignPDF->sign(); diff --git a/lib/Handler/SignEngine/PhpNativeHandler.php b/lib/Handler/SignEngine/PhpNativeHandler.php index 488559131a..d8dfda4b63 100644 --- a/lib/Handler/SignEngine/PhpNativeHandler.php +++ b/lib/Handler/SignEngine/PhpNativeHandler.php @@ -11,7 +11,11 @@ use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; use OCA\Libresign\Service\SignatureBackgroundService; +use OCA\Libresign\Service\SignatureStampPreview\SignatureStampAppearanceBuilder; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\Files\File; @@ -35,7 +39,9 @@ public function __construct( private IAppConfig $appConfig, private DocMdpConfigService $docMdpConfigService, private SignatureTextService $signatureTextService, + private SignatureStampAppearanceBuilder $signatureStampAppearanceBuilder, private SignatureBackgroundService $signatureBackgroundService, + private PolicyService $policyService, protected CertificateEngineFactory $certificateEngineFactory, ) { } @@ -211,17 +217,18 @@ private function resolvePageHeight(array $pageDimensions, int $pageIndex): float } private function buildTimestampOptions(): ?TimestampOptionsDto { - $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + $tsaSettings = $this->getTsaSettings(); + $tsaUrl = $tsaSettings['url']; if (empty($tsaUrl)) { return null; } $username = null; $password = null; - $authType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none'); + $authType = $tsaSettings['auth_type']; if ($authType === 'basic') { - $username = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '') ?: null; - $password = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', '') ?: null; + $username = $tsaSettings['username'] ?: null; + $password = $this->appConfig->getValueString(Application::APP_ID, TsaPolicy::PASSWORD_APP_CONFIG_KEY, '') ?: null; } return new TimestampOptionsDto( @@ -231,6 +238,15 @@ private function buildTimestampOptions(): ?TimestampOptionsDto { ); } + /** + * @return array{url: string, policy_oid: string, auth_type: string, username: string} + */ + private function getTsaSettings(): array { + $resolved = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue(); + $settings = TsaPolicyValue::decode($resolved); + return $settings; + } + private function resolveCertificationLevel(bool $noVisibleElements): ?CertificationLevel { if (!$this->docMdpConfigService->isEnabled()) { return null; @@ -261,89 +277,15 @@ private function hasExistingSignatures(string $pdfContent): bool { * No image generation: pure PDF text operators. */ private function buildXObject(int $width, int $height, string $renderMode): SignatureAppearanceXObjectDto { - // GRAPHIC_ONLY: only the background/signature image is shown; no text in n2. - if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) { - return new SignatureAppearanceXObjectDto(stream: '', resources: []); - } - $params = $this->getSignatureParams(); - $params['ServerSignatureDate'] = (new \DateTimeImmutable('now', new \DateTimeZone('UTC'))) - ->format(\DateTimeInterface::ATOM); - - $textData = $this->signatureTextService->parse(context: $params); - $parsed = trim((string)($textData['parsed'] ?? '')); - - $descFontSize = (float)($textData['templateFontSize'] ?? $this->signatureTextService->getTemplateFontSize()); - $descLineHeight = $descFontSize * 1.0; - $leftPadding = max(2.0, $descFontSize * 0.15); - - $isDescriptionOnly = $renderMode === SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY; - $textStartX = $isDescriptionOnly ? $leftPadding : ((float)$width / 2.0) + $leftPadding; - $availableWidth = $isDescriptionOnly ? (float)$width : (float)$width / 2.0; - - $stream = ''; - - // Left half: signer name as large text operators (SIGNAME_AND_DESCRIPTION only). - // No image generation — the name is drawn directly with PDF text commands. - if ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) { - $commonName = !empty($params['SignerCommonName']) - ? (string)$params['SignerCommonName'] - : ($this->readCertificate()['subject']['CN'] ?? ''); - if ($commonName !== '') { - $nameFontSize = $this->signatureTextService->getSignatureFontSize(); - $leftHalfW = (float)$width / 2.0; - $nameLines = $this->wrapTextForPdf($commonName, $leftHalfW - $leftPadding * 2, $nameFontSize); - $nameLineCount = count($nameLines); - $totalNameHeight = $nameLineCount * $nameFontSize * 1.0; - $nameStartY = ((float)$height + $totalNameHeight) / 2.0 - $nameFontSize; - $nameStartY = max(0.0, $nameStartY); - $nameY = $nameStartY; - $estimatedCharWidth = $nameFontSize * 0.52; - foreach ($nameLines as $nameLine) { - $lineWidth = strlen($nameLine) * $estimatedCharWidth; - $nameX = max($leftPadding, ($leftHalfW - $lineWidth) / 2.0); - $escaped = $this->escapePdfText($nameLine); - $stream .= "BT\n"; - $stream .= sprintf("/F1 %.2F Tf\n", $nameFontSize); - $stream .= "0 0 0 rg\n"; - $stream .= sprintf("%.2F %.2F Td\n", $nameX, $nameY); - $stream .= sprintf("(%s) Tj\n", $escaped); - $stream .= "ET\n"; - $nameY -= $nameFontSize * 1.0; - } - } - } - - // Right half (or full width): description text. - $currentY = (float)$height - $descFontSize - 2.0; - foreach (explode(PHP_EOL, $parsed) as $line) { - $wrappedLines = $this->wrapTextForPdf($line, $availableWidth, $descFontSize); - foreach ($wrappedLines as $wrappedLine) { - if ($currentY < 0) { - break 2; - } - $escaped = $this->escapePdfText($wrappedLine); - $stream .= "BT\n"; - $stream .= sprintf("/F1 %.2F Tf\n", $descFontSize); - $stream .= "0 0 0 rg\n"; - $stream .= sprintf("%.2F %.2F Td\n", $textStartX, $currentY); - $stream .= sprintf("(%s) Tj\n", $escaped); - $stream .= "ET\n"; - $currentY -= $descLineHeight; - } - } - - return new SignatureAppearanceXObjectDto( - stream: $stream, - resources: [ - 'Font' => [ - 'F1' => [ - 'Type' => '/Font', - 'Subtype' => '/Type1', - 'BaseFont' => '/Helvetica', - ], - ], - ], + $fallbackCommonName = $this->readCertificate()['subject']['CN'] ?? null; + + return $this->signatureStampAppearanceBuilder->buildXObject( + width: $width, + height: $height, + renderMode: $renderMode, + context: $params, + fallbackCommonName: is_string($fallbackCommonName) ? $fallbackCommonName : null, ); } @@ -351,55 +293,10 @@ private function buildXObject(int $width, int $height, string $renderMode): Sign * @return string[] */ private function wrapTextForPdf(string $line, float $availableWidth, float $fontSize): array { - $trimmed = trim($line); - if ($trimmed === '') { - return ['']; - } - - $estimatedCharWidth = max(1.0, $fontSize * 0.52); - $maxChars = max(1, (int)floor($availableWidth / $estimatedCharWidth)); - if (strlen($trimmed) <= $maxChars) { - return [$trimmed]; - } - - $result = []; - $current = ''; - foreach (preg_split('/\s+/', $trimmed) ?: [] as $word) { - if ($word === '') { - continue; - } - - $candidate = $current === '' ? $word : $current . ' ' . $word; - if (strlen($candidate) <= $maxChars) { - $current = $candidate; - continue; - } - - if ($current !== '') { - $result[] = $current; - $current = ''; - } - - while (strlen($word) > $maxChars) { - $result[] = substr($word, 0, $maxChars); - $word = substr($word, $maxChars); - } - - $current = $word; - } - - if ($current !== '') { - $result[] = $current; - } - - return $result; + return $this->signatureStampAppearanceBuilder->wrapTextForPdf($line, $availableWidth, $fontSize); } private function escapePdfText(string $value): string { - $value = str_replace('\\', '\\\\', $value); - $value = str_replace('(', '\\(', $value); - $value = str_replace(')', '\\)', $value); - - return $value; + return $this->signatureStampAppearanceBuilder->escapePdfText($value); } } diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index df79acb39e..19df46f98c 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -33,6 +33,7 @@ class Pkcs12Handler extends SignEngineHandler { private ?PhpNativeHandler $phpNativeHandler = null; private string $rootCertificatePem = ''; private bool $isLibreSignFile = false; + private ?string $policyUserIdForValidation = null; public function __construct( private FolderService $folderService, @@ -49,6 +50,11 @@ public function __construct( parent::__construct($l10n, $folderService, $logger); } + #[\Override] + protected function getCertificateEngineFactory(): CertificateEngineFactory { + return $this->certificateEngineFactory; + } + /** * @throws LibresignException When is not a signed file */ @@ -84,6 +90,14 @@ public function setIsLibreSignFile(): void { $this->isLibreSignFile = true; } + public function setPolicyUserIdForValidation(?string $userId): self { + $this->policyUserIdForValidation = is_string($userId) && trim($userId) !== '' + ? trim($userId) + : null; + + return $this; + } + /** * @param resource $resource * @throws LibresignException When is not a signed file @@ -92,20 +106,28 @@ public function setIsLibreSignFile(): void { #[\Override] public function getCertificateChain($resource): array { $certificates = []; + $certificateEngine = $this->getCertificateEngine(); + $certificateEngine->setPolicyUserIdForValidation($this->policyUserIdForValidation); - foreach ($this->getSignatures($resource) as $signature) { - if (!$signature) { - continue; - } + try { + foreach ($this->getSignatures($resource) as $signature) { + if (!$signature) { + continue; + } - $result = $this->processSignature($resource, $signature); + $result = $this->processSignature($resource, $signature); - if (empty($result['chain'])) { - continue; - } + if (empty($result['chain'])) { + continue; + } - $certificates[] = $result; + $certificates[] = $result; + } + } finally { + $certificateEngine->setPolicyUserIdForValidation(null); + $this->policyUserIdForValidation = null; } + return $certificates; } diff --git a/lib/Handler/SignEngine/SignEngineHandler.php b/lib/Handler/SignEngine/SignEngineHandler.php index c3b3d249a9..500625fba4 100644 --- a/lib/Handler/SignEngine/SignEngineHandler.php +++ b/lib/Handler/SignEngine/SignEngineHandler.php @@ -262,9 +262,12 @@ protected function getFileStream() { return $stream; } + protected function getCertificateEngineFactory(): CertificateEngineFactory { + return \OCP\Server::get(CertificateEngineFactory::class); + } + protected function getCertificateEngine(): IEngineHandler { - return \OCP\Server::get(CertificateEngineFactory::class) - ->getEngine(); + return $this->getCertificateEngineFactory()->getEngine(); } protected function beforeSign(): void { diff --git a/lib/Handler/SigningErrorHandler.php b/lib/Handler/SigningErrorHandler.php index f3cd539c52..b97f3508c3 100644 --- a/lib/Handler/SigningErrorHandler.php +++ b/lib/Handler/SigningErrorHandler.php @@ -60,7 +60,7 @@ private function handleGenericException(\Throwable $exception): array { return [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => $this->isKnownError($message) - ? [['message' => $this->l10n->t($message)]] + ? [['message' => $this->translateKnownError($message)]] : $this->formatUnknownError($message, $exception), ]; } @@ -73,6 +73,18 @@ private function isKnownError(string $message): bool { ], true); } + private function translateKnownError(string $message): string { + return match ($message) { + // TRANSLATORS: Error returned when the signing service rejects a request to a local/private host for security reasons. + 'Host violates local access rules.' => $this->l10n->t('Host violates local access rules.'), + // TRANSLATORS: Error shown when the password provided to unlock the signing certificate is incorrect. + 'Certificate Password Invalid.' => $this->l10n->t('Certificate Password Invalid.'), + // TRANSLATORS: Error shown when no password was provided to unlock the signing certificate. + 'Certificate Password is Empty.' => $this->l10n->t('Certificate Password is Empty.'), + default => $message, + }; + } + /** * @return list */ @@ -80,16 +92,19 @@ private function formatUnknownError(string $message, \Throwable $exception): arr $this->logger->error($message, ['exception' => $exception]); return [[ - 'message' => sprintf( + // TRANSLATORS: Multi-line error shown when an unexpected server error happens during signing. %1$s is the client IP address, %2$s is the request ID, and %3$s is the technical error message. Keep the Markdown formatting and line breaks. + 'message' => $this->l10n->t( "The server was unable to complete your request.\n" . "If this happens again, please send the technical details below to the server administrator.\n" . "## Technical details:\n" - . "**Remote Address**: %s\n" - . "**Request ID**: %s\n" - . '**Message**: %s', - $this->request->getRemoteAddress(), - $this->request->getId(), - $message, + . "**Remote Address**: %1\$s\n" + . "**Request ID**: %2\$s\n" + . '**Message**: %3$s', + [ + $this->request->getRemoteAddress(), + $this->request->getId(), + $message, + ], ), 'title' => $this->l10n->t('Internal Server Error'), ]]; diff --git a/lib/Helper/FileUploadHelper.php b/lib/Helper/FileUploadHelper.php index ccf54fbb1f..675d7deda2 100644 --- a/lib/Helper/FileUploadHelper.php +++ b/lib/Helper/FileUploadHelper.php @@ -31,22 +31,26 @@ public function __construct( public function validateUploadedFile(array $uploadedFile): void { if ($uploadedFile['error'] !== 0) { @unlink($uploadedFile['tmp_name']); - throw new InvalidArgumentException($this->l10n->t('Invalid file provided')); + // TRANSLATORS Validation error shown when the browser upload did not produce a usable temporary file. + throw new InvalidArgumentException($this->l10n->t('The uploaded file is invalid')); } if (!is_uploaded_file($uploadedFile['tmp_name'])) { @unlink($uploadedFile['tmp_name']); - throw new InvalidArgumentException($this->l10n->t('Invalid file provided')); + // TRANSLATORS Validation error shown when the received file was not uploaded through the expected HTTP upload flow. + throw new InvalidArgumentException($this->l10n->t('The uploaded file is invalid')); } if ($uploadedFile['size'] > \OCP\Util::uploadLimit()) { @unlink($uploadedFile['tmp_name']); - throw new InvalidArgumentException($this->l10n->t('File is too big')); + // TRANSLATORS Validation error shown when an uploaded file is larger than the server's configured upload limit. + throw new InvalidArgumentException($this->l10n->t('The uploaded file is too large')); } if (!$this->filenameValidator->isFilenameValid(basename((string)$uploadedFile['tmp_name']))) { @unlink($uploadedFile['tmp_name']); - throw new InvalidArgumentException($this->l10n->t('Invalid file provided')); + // TRANSLATORS Validation error shown when the temporary upload filename fails security validation. + throw new InvalidArgumentException($this->l10n->t('The uploaded file is invalid')); } } @@ -60,7 +64,8 @@ public function validateUploadedFile(array $uploadedFile): void { public function readUploadedFile(array $uploadedFile): string { $content = @file_get_contents($uploadedFile['tmp_name']); if ($content === false) { - throw new InvalidArgumentException($this->l10n->t('Cannot read file')); + // TRANSLATORS Error shown when LibreSign cannot read the temporary file contents after upload. + throw new InvalidArgumentException($this->l10n->t('Cannot read the uploaded file')); } return $content; } diff --git a/lib/Helper/JavaHelper.php b/lib/Helper/JavaHelper.php index 18b870db9e..17166e707b 100644 --- a/lib/Helper/JavaHelper.php +++ b/lib/Helper/JavaHelper.php @@ -28,14 +28,66 @@ public function init(): void { return; } if ($this->isNonUTF8Locale()) { - $locale = $this->l10n->getLocaleCode() . '.UTF-8'; + $this->initializeUtf8Locale(); + } + $this->isInitialized = true; + } + + private function initializeUtf8Locale(): void { + $originalLang = getenv('LANG'); + $originalLocale = setlocale(LC_CTYPE, '0'); + $attemptedLocales = $this->getUtf8LocaleCandidates(); + + foreach ($attemptedLocales as $locale) { putenv('LANG=' . $locale); setlocale(LC_CTYPE, $locale, 'UTF-8'); - if ($this->isNonUTF8Locale()) { - $this->logger->warning("JavaHelper: setlocale did not work properly after attempting locale '{$locale}'"); + if (!$this->isNonUTF8Locale()) { + return; } } - $this->isInitialized = true; + + $this->restoreOriginalLocaleEnvironment($originalLang, $originalLocale); + $this->logger->warning(sprintf( + 'JavaHelper: setlocale did not work properly after attempting locales: %s', + implode(', ', $attemptedLocales) + )); + } + + /** + * @return list + */ + private function getUtf8LocaleCandidates(): array { + $localeCode = trim(str_replace('-', '_', $this->l10n->getLocaleCode())); + $candidates = []; + + if ($localeCode !== '') { + if (preg_match('/^[A-Za-z]{2,3}_[A-Za-z]{2}$/', $localeCode) === 1) { + [$language, $region] = explode('_', $localeCode, 2); + $candidates[] = sprintf('%s_%s.UTF-8', strtolower($language), strtoupper($region)); + $candidates[] = sprintf('%s_%s.utf8', strtolower($language), strtoupper($region)); + } elseif (preg_match('/^[A-Za-z]{2,3}$/', $localeCode) === 1) { + $language = strtolower($localeCode); + $candidates[] = sprintf('%s_%s.UTF-8', $language, strtoupper($language)); + $candidates[] = $language . '.UTF-8'; + } + } + + $candidates[] = 'C.UTF-8'; + $candidates[] = 'UTF-8'; + + return array_values(array_unique($candidates)); + } + + private function restoreOriginalLocaleEnvironment(string|false $originalLang, string|false $originalLocale): void { + if ($originalLang === false) { + putenv('LANG'); + } else { + putenv('LANG=' . $originalLang); + } + + if (is_string($originalLocale) && $originalLocale !== '') { + setlocale(LC_CTYPE, $originalLocale); + } } /** @@ -52,7 +104,7 @@ protected function isNonUTF8Locale(): bool { if (function_exists('escapeshellarg')) { return escapeshellarg('§') === '\'\''; } - return preg_match('/utf-?8/i', setlocale(LC_CTYPE, 0)) === 0; + return preg_match('/utf-?8/i', setlocale(LC_CTYPE, '0')) === 0; } /** diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index c58327cd1d..f8969a7d47 100644 --- a/lib/Helper/ValidateHelper.php +++ b/lib/Helper/ValidateHelper.php @@ -11,14 +11,12 @@ use InvalidArgumentException; use OC\AppFramework\Http; use OC\User\NoUserException; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File; use OCA\Libresign\Db\FileElement; use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\FileTypeMapper; use OCA\Libresign\Db\IdDocsMapper; -use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Db\UserElementMapper; @@ -26,8 +24,11 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\DocMdp\Validator as DocMdpValidator; use OCA\Libresign\Service\FileService; +use OCA\Libresign\Service\IdDocsPolicyService; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\IdentifyMethod\RuntimeRequirementValidator; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignerElementsService; use OCP\AppFramework\Db\DoesNotExistException; @@ -35,12 +36,9 @@ use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; -use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IL10N; use OCP\IUser; use OCP\IUserManager; -use OCP\Security\IHasher; class ValidateHelper { /** @var \OCP\Files\File[] */ @@ -63,17 +61,16 @@ public function __construct( private FileElementMapper $fileElementMapper, private IdDocsMapper $idDocsMapper, private UserElementMapper $userElementMapper, - private IdentifyMethodMapper $identifyMethodMapper, private IdentifyMethodService $identifyMethodService, private SequentialSigningService $sequentialSigningService, private SignerElementsService $signerElementsService, private IMimeTypeDetector $mimeTypeDetector, - private IHasher $hasher, - private IAppConfig $appConfig, - private IGroupManager $groupManager, + private IdDocsPolicyService $idDocsPolicyService, private IUserManager $userManager, private IRootFolder $root, private DocMdpValidator $docMdpValidator, + private RequestSignAuthorizationService $requestSignAuthorizationService, + private RuntimeRequirementValidator $runtimeRequirementValidator, ) { } @@ -109,10 +106,12 @@ public function validateFile(array $data, int $type = self::TYPE_TO_SIGN, ?IUser return; } if ($type === self::TYPE_TO_SIGN) { + // TRANSLATORS Validation error shown when the API expected a document to be signed but received an empty file payload. %s is the localized file-role label such as "document to sign". throw new LibresignException($this->l10n->t('File type: %s. Empty file.', [$this->getTypeOfFile($type)])); } if ($type === self::TYPE_VISIBLE_ELEMENT_USER) { if ($this->elementNeedFile($data)) { + // TRANSLATORS Validation error shown when a visible signature element (for example a handwritten signature image) requires an uploaded file. %s is the requested visible element type. throw new LibresignException($this->l10n->t('Elements of type %s need file.', [$data['type']])); } } @@ -120,6 +119,7 @@ public function validateFile(array $data, int $type = self::TYPE_TO_SIGN, ?IUser } if (!empty($data['file']['url'])) { if (!filter_var($data['file']['url'], FILTER_VALIDATE_URL)) { + // TRANSLATORS Validation error shown when the caller must provide a valid URL, Base64 payload, or file identifier. %s is the localized file-role label. throw new LibresignException($this->l10n->t('File type: %s. Specify a URL, a Base64 string or a fileID.', [$this->getTypeOfFile($type)])); } } elseif (!empty($data['file']['nodeId'])) { @@ -163,9 +163,11 @@ private function elementNeedFile(array $data): bool { private function getTypeOfFile(int $type): string { if ($type === self::TYPE_TO_SIGN) { + // TRANSLATORS File-role label used inside validation errors to mean the PDF document that will receive signatures. return $this->l10n->t('document to sign'); } - return $this->l10n->t('visible element'); + // TRANSLATORS File-role label used inside validation errors for a visible signature asset such as a signature image or initials image. + return $this->l10n->t('visible signature element'); } public function validateBase64(string $base64, int $type = self::TYPE_TO_SIGN): void { @@ -189,7 +191,7 @@ public function validateBase64(string $base64, int $type = self::TYPE_TO_SIGN): $string = base64_decode($base64); if (in_array($type, [self::TYPE_VISIBLE_ELEMENT_USER, self::TYPE_VISIBLE_ELEMENT_PDF])) { if (strlen($string) > 5000 * 1024) { // 5Mb - // TRANSLATORS Error when the visible element to add to document, like a signature or initial is bigger than normal + // TRANSLATORS Error shown when a visible signature asset (for example a signature or initials image) exceeds the allowed upload size. throw new InvalidArgumentException($this->l10n->t('File is too big')); } } @@ -242,7 +244,7 @@ public function validateElementSignRequestId(array $element, int $type): void { return; } if (!array_key_exists('signRequestId', $element) && !array_key_exists('uuid', $element)) { - // TRANSLATION The element can be an image or text. It has to be associated with an user. The element will be added to the document. + // TRANSLATORS Validation error shown when a visible element (image or text placed on the document) is missing the signer association it belongs to. throw new LibresignException($this->l10n->t('Element must be associated with a user')); } @@ -407,7 +409,6 @@ public function validateAuthenticatedUserIsOwnerOfPdfVisibleElement(int $documen throw new LibresignException($this->l10n->t('Field %s does not belong to user', (string)$documentElementId)); } } catch (\Throwable) { - ($signRequest->getFileId()); throw new LibresignException($this->l10n->t('Field %s does not belong to user', (string)$documentElementId)); } } @@ -505,25 +506,11 @@ private function getLibreSignFileByNodeId(int $nodeId): ?\OCP\Files\File { } public function canRequestSign(IUser $user): void { - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'groups_request_sign', ['admin']); - if (empty($authorized)) { - $authorized = ['admin']; - } - if (!is_array($authorized)) { + if (!$this->requestSignAuthorizationService->canRequestSign($user)) { throw new LibresignException( json_encode([ 'action' => JSActions::ACTION_DO_NOTHING, - 'errors' => [['message' => $this->l10n->t('You are not allowed to request signing')]], - ]), - Http::STATUS_UNPROCESSABLE_ENTITY, - ); - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - throw new LibresignException( - json_encode([ - 'action' => JSActions::ACTION_DO_NOTHING, - 'errors' => [['message' => $this->l10n->t('You are not allowed to request signing')]], + 'errors' => [['message' => $this->l10n->t('You are not allowed to create signature requests')]], ]), Http::STATUS_UNPROCESSABLE_ENTITY, ); @@ -879,6 +866,7 @@ public function validateCredentials(SignRequest $signRequest, string $identifyMe $identifyMethod = $this->resolveIdentifyMethod($signRequest, $identifyMethodName, $identifyValue); $identifyMethod->setCodeSentByUser($token); $identifyMethod->validateToSign(); + $this->runtimeRequirementValidator->validate($signRequest); } private function resolveIdentifyMethod(SignRequest $signRequest, string $methodName, ?string $identifyValue): IIdentifyMethod { @@ -950,7 +938,7 @@ private function getFirstAvailableMethod(array $methods): IIdentifyMethod { } public function validateIfIdentifyMethodExists(string $identifyMethod): void { - if (!in_array($identifyMethod, IdentifyMethodService::IDENTIFY_METHODS)) { + if (!$this->identifyMethodService->exists($identifyMethod)) { // TRANSLATORS When is requested to a person to sign a file, is // necessary identify what is the identification method. The // identification method is used to define how will be the sign @@ -967,22 +955,7 @@ public function validateFileTypeExists(string $type): void { } public function userCanApproveValidationDocuments(?IUser $user, bool $throw = true): bool { - if ($user == null) { - return false; - } - - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if (!$authorized || !is_array($authorized) || empty($authorized)) { - $authorized = ['admin']; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - if ($throw) { - throw new LibresignException($this->l10n->t('You are not allowed to approve user profile documents.')); - } - return false; - } - return true; + return $this->idDocsPolicyService->userCanApproveValidationDocuments($user, $throw); } private function validateDocMdpPdfRestrictions(array $data): void { diff --git a/lib/Listener/TwofactorGatewayListener.php b/lib/Listener/TwofactorGatewayListener.php index 8ae6162d2d..aec5713cd2 100644 --- a/lib/Listener/TwofactorGatewayListener.php +++ b/lib/Listener/TwofactorGatewayListener.php @@ -16,6 +16,7 @@ use OCA\Libresign\Events\SignedEvent; use OCA\Libresign\Service\IdentifyMethod\IdentifyService; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\IdentifyMethodService; use OCA\TwoFactorGateway\Provider\Gateway\Factory; use OCP\App\IAppManager; use OCP\EventDispatcher\Event; @@ -68,7 +69,7 @@ protected function sendSignNotification( if ($entity->isDeletedAccount()) { return; } - if (!in_array($entity->getIdentifierKey(), ['sms', 'signal', 'telegram', 'whatsapp', 'xmpp'], true)) { + if (!in_array($entity->getIdentifierKey(), IdentifyMethodService::IDENTIFY_TWOFACTOR_GATEWAY_METHODS, true)) { return; } if (!$this->appManager->isEnabledForAnyone('twofactor_gateway')) { @@ -98,7 +99,7 @@ protected function sendSignNotification( /** @var Factory */ $gatewayFactory = Server::get(Factory::class); - $gatewayName = $this->getGatewayName($entity->getIdentifierKey()); + $gatewayName = IdentifyMethodService::resolveTwofactorGatewayName($entity->getIdentifierKey()); $gateway = $gatewayFactory->get($gatewayName); try { $gateway->send($identifier, $message); @@ -115,16 +116,6 @@ protected function sendSignNotification( } } - /** - * @todo Make compatible with GoWhatsapp and WhatsApp gateways - */ - private function getGatewayName(string $identifierKey): string { - return match ($identifierKey) { - 'whatsapp' => 'gowhatsapp', - default => strtolower($identifierKey), - }; - } - protected function sendSignedNotification( SignRequest $signRequest, IIdentifyMethod $identifyMethod, @@ -135,7 +126,7 @@ protected function sendSignedNotification( if ($entity->isDeletedAccount()) { return; } - if (!in_array($entity->getIdentifierKey(), ['sms', 'signal', 'telegram', 'whatsapp', 'xmpp'], true)) { + if (!in_array($entity->getIdentifierKey(), IdentifyMethodService::IDENTIFY_TWOFACTOR_GATEWAY_METHODS, true)) { return; } if (!$this->appManager->isEnabledForAnyone('twofactor_gateway')) { @@ -159,7 +150,7 @@ protected function sendSignedNotification( /** @var Factory */ $gatewayFactory = Server::get(Factory::class); - $gatewayName = $this->getGatewayName($entity->getIdentifierKey()); + $gatewayName = IdentifyMethodService::resolveTwofactorGatewayName($entity->getIdentifierKey()); $gateway = $gatewayFactory->get($gatewayName); try { $gateway->send($identifier, $message); diff --git a/lib/Middleware/Attribute/PrivateValidation.php b/lib/Middleware/Attribute/PrivateValidation.php index c98bc36692..586f35cfd9 100644 --- a/lib/Middleware/Attribute/PrivateValidation.php +++ b/lib/Middleware/Attribute/PrivateValidation.php @@ -12,4 +12,12 @@ #[Attribute] class PrivateValidation { + public function __construct( + private bool $allowValidSignRequestUuid = false, + ) { + } + + public function allowValidSignRequestUuid(): bool { + return $this->allowValidSignRequestUuid; + } } diff --git a/lib/Middleware/InjectionMiddleware.php b/lib/Middleware/InjectionMiddleware.php index 1402a32e42..9cb632cce9 100644 --- a/lib/Middleware/InjectionMiddleware.php +++ b/lib/Middleware/InjectionMiddleware.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Middleware; -use OC\AppFramework\Http as AppFrameworkHttp; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Controller\AEnvironmentAwareController; use OCA\Libresign\Controller\AEnvironmentPageAwareController; @@ -29,6 +28,8 @@ use OCA\Libresign\Middleware\Attribute\RequireSignerUuid; use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid; use OCA\Libresign\Service\FileAccessService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ValidationAccess\ValidationAccessPolicy; use OCA\Libresign\Service\SignFileService; use OCA\Libresign\Service\UuidResolverService; use OCP\AppFramework\Controller; @@ -41,7 +42,6 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Middleware; use OCP\AppFramework\Services\IInitialState; -use OCP\IAppConfig; use OCP\IL10N; use OCP\IRequest; use OCP\ISession; @@ -52,6 +52,9 @@ use Psr\Log\LoggerInterface; class InjectionMiddleware extends Middleware { + private ?bool $isLoggedIn = null; + private ?bool $isValidationUrlPrivate = null; + public function __construct( private IRequest $request, private ISession $session, @@ -64,8 +67,8 @@ public function __construct( private FileAccessService $fileAccessService, private SignFileService $signFileService, private UuidResolverService $uuidResolverService, + private PolicyService $policyService, private IL10N $l10n, - private IAppConfig $appConfig, private IURLGenerator $urlGenerator, private LoggerInterface $logger, protected ?string $userId, @@ -80,6 +83,9 @@ public function __construct( */ #[\Override] public function beforeController(Controller $controller, string $methodName) { + $this->isLoggedIn = null; + $this->isValidationUrlPrivate = null; + if ($controller instanceof AEnvironmentAwareController) { $apiVersion = $this->request->getParam('apiVersion'); /** @var AEnvironmentAwareController $controller */ @@ -103,27 +109,69 @@ public function beforeController(Controller $controller, string $methodName) { } $this->requireSetupOk($reflectionMethod); - $this->privateValidation($reflectionMethod); $this->handleUuid($controller, $reflectionMethod); } private function privateValidation(\ReflectionMethod $reflectionMethod): void { - if (empty($reflectionMethod->getAttributes(PrivateValidation::class))) { + $attributes = $reflectionMethod->getAttributes(PrivateValidation::class); + if (empty($attributes)) { return; } - if ($this->userSession->isLoggedIn()) { - return; + + if ($this->shouldForcePrivateValidationRedirect($reflectionMethod)) { + $this->throwPrivateValidationRedirect($this->request->getRawPathInfo()); } - $isValidationUrlPrivate = (bool)$this->appConfig->getValueBool(Application::APP_ID, 'make_validation_url_private', false); - if (!$isValidationUrlPrivate) { + + /** @var PrivateValidation $privateValidation */ + $privateValidation = current($attributes)->newInstance(); + if ($this->isLoggedIn() || !$this->isValidationUrlPrivate()) { return; } - $redirectUrl = $this->request->getRawPathInfo(); + if (!$privateValidation->allowValidSignRequestUuid()) { + $this->throwPrivateValidationRedirect($this->request->getRawPathInfo()); + } + + $uuid = $this->getUuidFromRequest(); + if ($uuid) { + try { + $this->signRequestMapper->getByUuid($uuid); + return; // sign-request UUID is valid, allow access + } catch (\Throwable) { + } + + try { + $this->fileMapper->getByUuid($uuid); + return; // file UUID is valid, allow access + } catch (\Throwable) { + } + } + $this->throwPrivateValidationRedirect($this->request->getRawPathInfo()); + } + + private function isLoggedIn(): bool { + if ($this->isLoggedIn === null) { + $this->isLoggedIn = $this->userSession->isLoggedIn(); + } + return $this->isLoggedIn; + } + + private function isValidationUrlPrivate(): bool { + if ($this->isValidationUrlPrivate === null) { + $this->isValidationUrlPrivate = $this->policyService + ->resolve(ValidationAccessPolicy::KEY) + ->getEffectiveValueAsBool(); + } + + return $this->isValidationUrlPrivate; + } + + private function throwPrivateValidationRedirect(?string $redirectUrl): void { throw new LibresignException(json_encode([ 'action' => JSActions::ACTION_REDIRECT, + // TRANSLATORS: Error shown to anonymous users when a private validation URL requires authentication before continuing. 'errors' => [$this->l10n->t('You are not logged in. Please log in.')], 'redirect' => $this->urlGenerator->linkToRoute('core.login.showLoginForm', [ 'redirect_url' => $redirectUrl, @@ -151,6 +199,10 @@ private function handleUuid(Controller $controller, \ReflectionMethod $reflectio } if (!empty($attribute = $reflectionMethod->getAttributes(RequireSignRequestUuid::class))) { + if ($this->shouldForcePrivateValidationRedirect($reflectionMethod)) { + $this->throwPrivateValidationRedirect($this->request->getRawPathInfo()); + } + $attribute = $reflectionMethod->getAttributes(RequireSignRequestUuid::class); $attribute = current($attribute); /** @var RequireSignRequestUuid $intance */ @@ -183,6 +235,21 @@ private function handleUuid(Controller $controller, \ReflectionMethod $reflectio } } + private function shouldForcePrivateValidationRedirect(\ReflectionMethod $reflectionMethod): bool { + $attributes = $reflectionMethod->getAttributes(PrivateValidation::class); + if (empty($attributes)) { + return false; + } + + if ($this->isLoggedIn() || !$this->isValidationUrlPrivate()) { + return false; + } + + /** @var PrivateValidation $privateValidation */ + $privateValidation = current($attributes)->newInstance(); + return !$privateValidation->allowValidSignRequestUuid(); + } + private function getUuidFromRequest(): ?string { return $this->request->getParam('uuid', $this->request->getHeader('libresign-sign-request-uuid')); } @@ -208,7 +275,8 @@ private function maintainSessionConsistencyWithUuid(?string $uuid): void { private function getLoggedIn(): void { $user = $this->userSession->getUser(); if (!$user instanceof IUser) { - throw new \Exception($this->l10n->t('You are not allowed to request signing'), Http::STATUS_UNPROCESSABLE_ENTITY); + // TRANSLATORS: Error shown when an anonymous user tries to create a signature request, an action allowed only for authenticated users with permission. + throw new \Exception($this->l10n->t('You are not allowed to create signature requests'), Http::STATUS_UNPROCESSABLE_ENTITY); } $this->validateHelper->canRequestSign($user); } @@ -412,7 +480,7 @@ private function getRenderAsFromTemplate(string $template): string { private function getStatusCodeFromException(\Exception $exception): int { if ($exception->getCode() === 0) { - return AppFrameworkHttp::STATUS_UNPROCESSABLE_ENTITY; + return Http::STATUS_UNPROCESSABLE_ENTITY; } return (int)$exception->getCode(); } diff --git a/lib/Migration/Version17003Date20260404000000.php b/lib/Migration/Version17003Date20260404000000.php index 768f3f3694..af88fdd86a 100644 --- a/lib/Migration/Version17003Date20260404000000.php +++ b/lib/Migration/Version17003Date20260404000000.php @@ -11,6 +11,7 @@ use Closure; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue; use OCA\Libresign\Service\SignatureTextService; use OCP\DB\ISchemaWrapper; use OCP\IAppConfig; @@ -33,12 +34,12 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $this->sanitizeDimension( $output, 'signature_width', - SignatureTextService::DEFAULT_SIGNATURE_WIDTH, + SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH, ); $this->sanitizeDimension( $output, 'signature_height', - SignatureTextService::DEFAULT_SIGNATURE_HEIGHT, + SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT, ); } diff --git a/lib/Migration/Version18000Date20260317000000.php b/lib/Migration/Version18000Date20260317000000.php new file mode 100644 index 0000000000..a06a974ee0 --- /dev/null +++ b/lib/Migration/Version18000Date20260317000000.php @@ -0,0 +1,100 @@ +hasTable('libresign_permission_set')) { + $permissionSetTable = $schema->getTable('libresign_permission_set'); + } else { + $permissionSetTable = $schema->createTable('libresign_permission_set'); + $permissionSetTable->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $permissionSetTable->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $permissionSetTable->addColumn('description', Types::TEXT, [ + 'notnull' => false, + ]); + $permissionSetTable->addColumn('scope_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $permissionSetTable->addColumn('enabled', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 1, + ]); + $permissionSetTable->addColumn('priority', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + ]); + $permissionSetTable->addColumn('policy_json', Types::TEXT, [ + 'notnull' => true, + 'default' => '{}', + ]); + $permissionSetTable->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $permissionSetTable->addColumn('updated_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $permissionSetTable->setPrimaryKey(['id']); + $permissionSetTable->addIndex(['scope_type'], 'ls_perm_set_scope_idx'); + } + + if (!$schema->hasTable('libresign_permission_set_binding')) { + $table = $schema->createTable('libresign_permission_set_binding'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('permission_set_id', Types::INTEGER, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('target_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('target_id', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['permission_set_id'], 'ls_perm_bind_set_idx'); + $table->addUniqueIndex(['target_type', 'target_id'], 'ls_perm_bind_target_uidx'); + $table->addForeignKeyConstraint($permissionSetTable, ['permission_set_id'], ['id'], [ + 'onDelete' => 'CASCADE', + ]); + } + + return $schema; + } +} diff --git a/lib/Migration/Version18003Date20260517000000.php b/lib/Migration/Version18003Date20260517000000.php new file mode 100644 index 0000000000..ef22c0c4c7 --- /dev/null +++ b/lib/Migration/Version18003Date20260517000000.php @@ -0,0 +1,778 @@ +migrateLegacyFooterSettings(); + $this->migrateCollectMetadataType(); + $this->migrateIdentificationDocumentsType(); + $this->migrateEnvelopeType(); + $this->migrateCrlValidationType(); + $this->migrateConfettiType(); + $this->migrateDocMdpLevelType(); + $this->migrateGroupsRequestSignType(); + $this->migrateSignatureFlowSettings(); + $this->migrateSignatureTextSettingsType(); + $this->migrateReminderSettings(); + $this->migrateExpirationRulesType(); + $this->migrateIdentifyMethodsType(); + $this->migrateTsaSettings(); + $this->migrateWorkerConfig(); + } + + private function migrateTsaSettings(): void { + $existingConsolidated = $this->readLegacyString(TsaPolicy::SYSTEM_APP_CONFIG_KEY); + $legacyTsaPassword = $this->readLegacyString('tsa_password'); + $existingPolicyPassword = $this->readLegacyString(TsaPolicy::PASSWORD_APP_CONFIG_KEY); + if ($existingConsolidated !== null && trim($existingConsolidated) !== '') { + $this->migrateTsaPassword($legacyTsaPassword, $existingPolicyPassword); + $this->deleteLegacyTsaNonSensitiveKeys(); + return; + } + + $tsaUrl = $this->readLegacyString('tsa_url'); + $tsaPolicyOid = $this->readLegacyString('tsa_policy_oid'); + $tsaAuthType = $this->readLegacyString('tsa_auth_type'); + $tsaUsername = $this->readLegacyString('tsa_username'); + + if ($tsaUrl === null && $tsaPolicyOid === null && $tsaAuthType === null && $tsaUsername === null) { + return; + } + + $encoded = TsaPolicyValue::encode([ + 'url' => $tsaUrl ?? '', + 'policy_oid' => $tsaPolicyOid ?? '', + 'auth_type' => $tsaAuthType ?? 'none', + 'username' => $tsaUsername ?? '', + ]); + + $this->migrateTsaPassword($legacyTsaPassword, $existingPolicyPassword); + $this->deleteLegacyTsaNonSensitiveKeys(); + $this->appConfig->setValueString(Application::APP_ID, TsaPolicy::SYSTEM_APP_CONFIG_KEY, $encoded); + } + + private function migrateTsaPassword(?string $legacyTsaPassword, ?string $existingPolicyPassword): void { + if (($existingPolicyPassword === null || trim($existingPolicyPassword) === '') + && $legacyTsaPassword !== null + && trim($legacyTsaPassword) !== '') { + $this->appConfig->setValueString( + Application::APP_ID, + TsaPolicy::PASSWORD_APP_CONFIG_KEY, + $legacyTsaPassword, + sensitive: true, + ); + } + + if ($legacyTsaPassword !== null) { + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_password'); + } + } + + private function deleteLegacyTsaNonSensitiveKeys(): void { + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_url'); + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_policy_oid'); + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_auth_type'); + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_username'); + } + + private function migrateExpirationRulesType(): void { + $this->migrateIntType(ExpirationRulesPolicy::KEY_MAXIMUM_VALIDITY, ExpirationRulesPolicy::DEFAULT_MAXIMUM_VALIDITY, false); + $this->migrateIntType(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, ExpirationRulesPolicy::DEFAULT_RENEWAL_INTERVAL, false); + $this->migrateIntType(ExpirationRulesPolicy::KEY_EXPIRY_IN_DAYS, ExpirationRulesPolicy::DEFAULT_EXPIRY_IN_DAYS, true); + } + + private function migrateIntType(string $key, int $default, bool $enforcePositive): void { + $legacyValue = $this->readLegacyString($key); + if ($legacyValue === null || trim($legacyValue) === '' || !is_numeric($legacyValue)) { + return; + } + + $parsed = (int)$legacyValue; + $normalized = $enforcePositive + ? ($parsed > 0 ? $parsed : $default) + : max(0, $parsed); + + $this->appConfig->deleteKey(Application::APP_ID, $key); + $this->appConfig->setValueInt(Application::APP_ID, $key, $normalized); + } + + private function migrateReminderSettings(): void { + $existingConsolidated = $this->readLegacyString(ReminderPolicy::SYSTEM_APP_CONFIG_KEY); + if ($existingConsolidated !== null && trim($existingConsolidated) !== '') { + $this->deleteLegacyReminderKeys(); + return; + } + + $daysBefore = $this->readLegacyString('reminder_days_before'); + $daysBetween = $this->readLegacyString('reminder_days_between'); + $max = $this->readLegacyString('reminder_max'); + $sendTimer = $this->readLegacyString('reminder_send_timer'); + + if ($daysBefore === null && $daysBetween === null && $max === null && $sendTimer === null) { + return; + } + + $encoded = ReminderPolicyValue::encode([ + 'days_before' => $daysBefore, + 'days_between' => $daysBetween, + 'max' => $max, + 'send_timer' => $sendTimer, + ]); + + $this->deleteLegacyReminderKeys(); + $this->appConfig->setValueString(Application::APP_ID, ReminderPolicy::SYSTEM_APP_CONFIG_KEY, $encoded); + } + + private function deleteLegacyReminderKeys(): void { + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_before'); + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_between'); + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_max'); + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_send_timer'); + } + + private function migrateCollectMetadataType(): void { + $this->migrateBoolType(CollectMetadataPolicy::SYSTEM_APP_CONFIG_KEY, false); + } + + private function migrateIdentificationDocumentsType(): void { + /** + * Consolidate legacy identification_documents (bool) and approval_group (array) + * into unified payload {enabled: bool, approvers: string[]} + */ + $existingConsolidated = $this->readLegacyString(IdentificationDocumentsPolicy::SYSTEM_APP_CONFIG_KEY); + + // Try to parse existing consolidated value + if ($existingConsolidated !== null && trim($existingConsolidated) !== '') { + $decoded = json_decode($existingConsolidated, true); + if (is_array($decoded) && isset($decoded['enabled'], $decoded['approvers'])) { + // Already consolidated, just clean up legacy approval_group + $this->appConfig->deleteKey(Application::APP_ID, 'approval_group'); + return; + } + } + + // Read legacy values + $legacyIdDocs = $this->readLegacyBool(IdentificationDocumentsPolicy::SYSTEM_APP_CONFIG_KEY, false); + $legacyApprovalGroup = $this->readLegacyApprovalGroup(); + + // Build unified payload + $consolidatedValue = [ + 'enabled' => $legacyIdDocs, + 'approvers' => !empty($legacyApprovalGroup) ? $legacyApprovalGroup : ['admin'], + ]; + + // Save unified payload + $this->appConfig->setValueArray( + Application::APP_ID, + IdentificationDocumentsPolicy::SYSTEM_APP_CONFIG_KEY, + $consolidatedValue + ); + + // Clean up legacy approval_group + $this->appConfig->deleteKey(Application::APP_ID, 'approval_group'); + } + + private function readLegacyApprovalGroup(): array { + try { + $rawValue = $this->appConfig->getValueString(Application::APP_ID, 'approval_group', ''); + if ($rawValue === '' || $rawValue === '[]') { + return []; + } + + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + return array_filter( + array_map('strval', $decoded), + static fn (string $v): bool => $v !== '' + ) ?: []; + } + + return []; + } catch (AppConfigTypeConflictException) { + // Try as array directly + return $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', []); + } + } + + private function migrateEnvelopeType(): void { + $this->migrateBoolType(EnvelopePolicy::SYSTEM_APP_CONFIG_KEY, true); + } + + private function migrateCrlValidationType(): void { + $this->migrateBoolType(CrlValidationPolicy::SYSTEM_APP_CONFIG_KEY, true); + } + + private function migrateConfettiType(): void { + $this->migrateBoolType(ConfettiPolicy::SYSTEM_APP_CONFIG_KEY, true); + } + + private function migrateBoolType(string $key, bool $default): void { + $legacyValue = $this->readLegacyString($key); + if ($legacyValue === null || trim($legacyValue) === '') { + return; + } + + $normalized = filter_var($legacyValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($normalized === null) { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, $key); + $this->appConfig->setValueBool(Application::APP_ID, $key, $normalized ?? $default); + } + + private function migrateSignatureFlowSettings(): void { + $currentSystemValue = $this->readLegacyString(SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY); + if ($currentSystemValue !== null && trim($currentSystemValue) !== '') { + $normalizedSystemValue = $this->normalizeSignatureFlowValue($currentSystemValue); + if ($normalizedSystemValue !== $currentSystemValue) { + $this->appConfig->deleteKey(Application::APP_ID, SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString(Application::APP_ID, SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY, $normalizedSystemValue); + } + + return; + } + + $legacyValue = $this->readLegacyString(SignatureFlowPolicy::KEY); + if ($legacyValue === null || trim($legacyValue) === '') { + return; + } + + $this->appConfig->setValueString( + Application::APP_ID, + SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY, + $this->normalizeSignatureFlowValue($legacyValue), + ); + $this->appConfig->deleteKey(Application::APP_ID, SignatureFlowPolicy::KEY); + } + + private function normalizeSignatureFlowValue(string $value): string { + $normalized = strtolower(trim($value)); + + return match ($normalized) { + SignatureFlow::NONE->value, + '0' => SignatureFlow::NONE->value, + SignatureFlow::PARALLEL->value, + '1' => SignatureFlow::PARALLEL->value, + SignatureFlow::ORDERED_NUMERIC->value, + '2' => SignatureFlow::ORDERED_NUMERIC->value, + default => SignatureFlow::NONE->value, + }; + } + + private function migrateSignatureTextSettingsType(): void { + $legacyTemplate = $this->readLegacyString(self::LEGACY_SIGNATURE_TEXT_TEMPLATE_KEY); + // These keys may be stored as float (type 16) by a previous migration (Version17003); + // readLegacyFloat falls back to getValueFloat when getValueString throws AppConfigTypeConflictException. + $legacyTemplateFontSize = $this->readLegacyFloat(self::LEGACY_SIGNATURE_TEXT_TEMPLATE_FONT_SIZE_KEY); + $legacySignatureFontSize = $this->readLegacyFloat(self::LEGACY_SIGNATURE_TEXT_SIGNATURE_FONT_SIZE_KEY); + $legacySignatureWidth = $this->readLegacyFloat(self::LEGACY_SIGNATURE_TEXT_SIGNATURE_WIDTH_KEY); + $legacySignatureHeight = $this->readLegacyFloat(self::LEGACY_SIGNATURE_TEXT_SIGNATURE_HEIGHT_KEY); + $legacyBackgroundType = $this->readLegacyString(self::LEGACY_SIGNATURE_TEXT_BACKGROUND_TYPE_KEY); + $legacyRenderMode = $this->readLegacyString(self::LEGACY_SIGNATURE_TEXT_RENDER_MODE_KEY); + + $hasLegacyValues = ($legacyTemplate !== null && trim($legacyTemplate) !== '') + || $legacyTemplateFontSize !== null + || $legacySignatureFontSize !== null + || $legacySignatureWidth !== null + || $legacySignatureHeight !== null + || ($legacyBackgroundType !== null && trim($legacyBackgroundType) !== '') + || ($legacyRenderMode !== null && trim($legacyRenderMode) !== ''); + + if (!$hasLegacyValues) { + return; + } + + // First, consolidate individual keys into a JSON payload + $consolidatedValue = [ + 'template' => $legacyTemplate ?? '', + 'template_font_size' => $legacyTemplateFontSize ?? SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE, + 'signature_font_size' => $legacySignatureFontSize ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE, + 'signature_width' => $legacySignatureWidth ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH, + 'signature_height' => $legacySignatureHeight ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT, + 'background_type' => $legacyBackgroundType ?? 'default', + 'render_mode' => $legacyRenderMode ?? 'default', + ]; + + // Normalize and encode the consolidated value + $encodedValue = $this->encodeSignatureTextPolicyValue($consolidatedValue); + + // Check if there's an existing consolidated value + $existingValue = $this->appConfig->getValueString( + Application::APP_ID, + self::LEGACY_SIGNATURE_TEXT_SYSTEM_APP_CONFIG_KEY, + '', + ); + + // Only update if we have legacy values or no existing consolidated value + if (!empty($existingValue) && $existingValue !== '') { + // Already consolidated, just clean up legacy keys + $this->deleteLegacySignatureTextKeys(); + return; + } + + // Delete all individual legacy keys + $this->deleteLegacySignatureTextKeys(); + + // Save the consolidated JSON value + $this->appConfig->setValueString( + Application::APP_ID, + self::LEGACY_SIGNATURE_TEXT_SYSTEM_APP_CONFIG_KEY, + $encodedValue, + ); + } + + private function deleteLegacySignatureTextKeys(): void { + $legacyKeys = [ + self::LEGACY_SIGNATURE_TEXT_TEMPLATE_KEY, + self::LEGACY_SIGNATURE_TEXT_TEMPLATE_FONT_SIZE_KEY, + self::LEGACY_SIGNATURE_TEXT_SIGNATURE_WIDTH_KEY, + self::LEGACY_SIGNATURE_TEXT_SIGNATURE_HEIGHT_KEY, + self::LEGACY_SIGNATURE_TEXT_SIGNATURE_FONT_SIZE_KEY, + self::LEGACY_SIGNATURE_TEXT_RENDER_MODE_KEY, + self::LEGACY_SIGNATURE_TEXT_BACKGROUND_TYPE_KEY, + ]; + + foreach ($legacyKeys as $key) { + $this->appConfig->deleteKey(Application::APP_ID, $key); + } + } + + /** + * @param array $rawValue + */ + private function encodeSignatureTextPolicyValue(array $rawValue): string { + $renderMode = strtolower(trim((string)($rawValue['render_mode'] ?? 'default'))); + if (!in_array($renderMode, ['default', 'graphic', 'text'], true)) { + $renderMode = 'default'; + } + + $backgroundType = strtolower(trim((string)($rawValue['background_type'] ?? 'default'))); + if (!in_array($backgroundType, ['default', 'custom', 'deleted'], true)) { + $backgroundType = 'default'; + } + + $normalized = [ + 'template' => (string)($rawValue['template'] ?? ''), + 'template_font_size' => max(0.1, (float)($rawValue['template_font_size'] ?? SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE)), + 'signature_font_size' => max(0.1, (float)($rawValue['signature_font_size'] ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE)), + 'signature_width' => max(0.1, (float)($rawValue['signature_width'] ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH)), + 'signature_height' => max(0.1, (float)($rawValue['signature_height'] ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT)), + 'background_type' => $backgroundType, + 'render_mode' => $renderMode, + ]; + + return json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private function migrateGroupsRequestSignType(): void { + $legacyValue = $this->readLegacyString(RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + if ($legacyValue !== null) { + if ($legacyValue === '') { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::encode($legacyValue), + ); + return; + } + + $typedValue = $this->appConfig->getValueArray( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::DEFAULT_ALLOW_GROUPS, + ); + + $this->appConfig->deleteKey(Application::APP_ID, RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::encode($typedValue), + ); + } + + private function migrateLegacyFooterSettings(): void { + $legacyAddFooter = $this->readLegacyValue(FooterPolicy::KEY); + $legacyWriteQrCodeOnFooter = $this->readLegacyBool('write_qrcode_on_footer', true); + $legacyValidationSite = $this->readLegacyString('validation_site') ?? ''; + $legacyFooterTemplateIsDefault = $this->readLegacyBool('footer_template_is_default', true); + + $rawFooterPolicyValue = $legacyAddFooter; + if (!$this->isStructuredFooterPayload($legacyAddFooter)) { + $rawFooterPolicyValue = [ + 'enabled' => $this->toBool($legacyAddFooter, true), + 'writeQrcodeOnFooter' => $legacyWriteQrCodeOnFooter, + 'validationSite' => $legacyValidationSite, + 'customizeFooterTemplate' => !$legacyFooterTemplateIsDefault, + ]; + } + + $encodedFooterPolicyValue = FooterPolicyValue::encode( + FooterPolicyValue::normalize($rawFooterPolicyValue), + ); + + $this->appConfig->deleteKey(Application::APP_ID, FooterPolicy::KEY); + $this->appConfig->setValueString(Application::APP_ID, FooterPolicy::KEY, $encodedFooterPolicyValue); + } + + private function migrateDocMdpLevelType(): void { + $legacyValue = $this->readLegacyString(DocMdpPolicy::SYSTEM_APP_CONFIG_KEY); + if ($legacyValue === null || $legacyValue === '' || !is_numeric($legacyValue)) { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueInt(Application::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY, (int)$legacyValue); + } + + private function migrateIdentifyMethodsType(): void { + $legacyValue = $this->readLegacyString('identify_methods'); + if ($legacyValue === null || $legacyValue === '') { + return; + } + + $normalized = $this->normalizeIdentifyMethodsLegacyPayload($legacyValue); + if ($normalized === null) { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, 'identify_methods'); + $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $normalized); + } + + /** + * @return array|null + */ + private function normalizeIdentifyMethodsLegacyPayload(mixed $rawValue): ?array { + $decoded = $this->decodeIdentifyMethodsLegacyPayload($rawValue); + if ($decoded === null) { + return null; + } + + if (array_is_list($decoded)) { + $decoded = $this->wrapLegacyIdentifyMethodsListPayload($decoded); + } + + $prepared = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($decoded); + + return IdentifyMethodsPolicyValue::normalize($prepared); + } + + /** + * @param list $payload + * @return array{factors: list>} + */ + private function wrapLegacyIdentifyMethodsListPayload(array $payload): array { + $factors = []; + + foreach ($payload as $entry) { + if (is_string($entry) && trim($entry) !== '') { + $factors[] = [ + 'name' => trim($entry), + ]; + continue; + } + + if (is_array($entry)) { + $factors[] = $entry; + } + } + + return [ + 'factors' => $factors, + ]; + } + + private function normalizeLegacyIdentifyMethodsSignatureMethodEnabled(mixed $payload): mixed { + if (!is_array($payload)) { + return $payload; + } + + if (array_is_list($payload)) { + $normalized = []; + foreach ($payload as $entry) { + $normalized[] = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($entry); + } + return $normalized; + } + + if (isset($payload['signatureMethodEnabled']) && is_array($payload['signatureMethodEnabled'])) { + $payload['signatureMethodEnabled'] = $this->normalizeLegacySignatureMethodEnabled($payload['signatureMethodEnabled']); + } + + if (isset($payload['signatureMethods']) && is_array($payload['signatureMethods']) && array_is_list($payload['signatureMethods'])) { + $payload['signatureMethods'] = $this->normalizeLegacySignatureMethods($payload['signatureMethods']); + } + + if (isset($payload['factors']) && is_array($payload['factors'])) { + $payload['factors'] = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($payload['factors']); + } + + return $payload; + } + + /** + * @param list $value + * @return array + */ + private function normalizeLegacySignatureMethods(array $value): array { + $normalized = []; + + foreach ($value as $signatureMethodName) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + + $normalized[trim($signatureMethodName)] = ['enabled' => false]; + } + + return $normalized; + } + + /** + * @return array|null + */ + private function decodeIdentifyMethodsLegacyPayload(mixed $rawValue): ?array { + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + return is_array($decoded) ? $decoded : null; + } + + return is_array($rawValue) ? $rawValue : null; + } + + private function normalizeLegacySignatureMethodEnabled(array $value): ?string { + foreach ($value as $signatureMethodName) { + if (is_string($signatureMethodName) && trim($signatureMethodName) !== '') { + return $signatureMethodName; + } + } + + return null; + } + + private function readLegacyString(string $key): ?string { + try { + return $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } catch (AppConfigTypeConflictException) { + // The key is already stored in the target typed format + return null; + } + } + + private function readLegacyFloat(string $key): ?float { + try { + $rawValue = $this->appConfig->getValueString(Application::APP_ID, $key, ''); + if ($rawValue === '') { + return null; + } + + return is_numeric($rawValue) ? (float)$rawValue : null; + } catch (AppConfigTypeConflictException) { + // Already stored as typed float (e.g. after Version17003 sanitised the value) + try { + return $this->appConfig->getValueFloat(Application::APP_ID, $key, -1.0) >= 0.0 + ? $this->appConfig->getValueFloat(Application::APP_ID, $key, -1.0) + : null; + } catch (AppConfigTypeConflictException) { + return null; + } + } + } + + private function readLegacyValue(string $key): mixed { + try { + return $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getValueBool(Application::APP_ID, $key, true); + } + } + + private function readLegacyBool(string $key, bool $default): bool { + try { + $rawValue = $this->appConfig->getValueString(Application::APP_ID, $key, ''); + if ($rawValue === '') { + return $default; + } + + return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getValueBool(Application::APP_ID, $key, $default); + } + } + + private function isStructuredFooterPayload(mixed $value): bool { + if (!is_string($value)) { + return false; + } + + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return false; + } + + return array_key_exists('enabled', $decoded) + || array_key_exists('writeQrcodeOnFooter', $decoded) + || array_key_exists('validationSite', $decoded) + || array_key_exists('customizeFooterTemplate', $decoded); + } + + private function toBool(mixed $value, bool $default): bool { + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return $value === 1; + } + + if (is_string($value)) { + $trimmed = trim($value); + if ($trimmed === '') { + return $default; + } + + return in_array(strtolower($trimmed), ['1', 'true', 'yes', 'on'], true); + } + + return $default; + } + + private function migrateWorkerConfig(): void { + $legacyConsolidatedKey = 'policy.worker_config.system'; + + // If canonical key already exists, just clean up stale legacy keys. + $existingCanonical = $this->readLegacyString(WorkerConfigPolicy::SYSTEM_APP_CONFIG_KEY); + if ($existingCanonical !== null && trim($existingCanonical) !== '') { + $this->deleteLegacyWorkerKeys(); + $this->appConfig->deleteKey(Application::APP_ID, $legacyConsolidatedKey); + return; + } + + // Backward compatibility for environments where a previous migration wrote + // to a non-canonical key. + $existingLegacyConsolidated = $this->readLegacyString($legacyConsolidatedKey); + if ($existingLegacyConsolidated !== null && trim($existingLegacyConsolidated) !== '') { + $this->deleteLegacyWorkerKeys(); + $this->appConfig->setValueString( + Application::APP_ID, + WorkerConfigPolicy::SYSTEM_APP_CONFIG_KEY, + $existingLegacyConsolidated, + ); + $this->appConfig->deleteKey(Application::APP_ID, $legacyConsolidatedKey); + return; + } + + if ($existingLegacyConsolidated !== null) { + $this->appConfig->deleteKey(Application::APP_ID, $legacyConsolidatedKey); + } + + $workerType = $this->readLegacyString('worker_type'); + $parallelWorkers = $this->readLegacyString('parallel_workers'); + + // Nothing to migrate + if ($workerType === null && $parallelWorkers === null) { + return; + } + + // Normalize values according to WorkerConfigPolicy logic + $normalizedWorkerType = $this->normalizeWorkerType($workerType); + $normalizedParallelWorkers = $this->normalizeParallelWorkers($parallelWorkers); + + $value = [ + 'worker_type' => $normalizedWorkerType, + 'parallel_workers' => $normalizedParallelWorkers, + ]; + + $encoded = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + $this->deleteLegacyWorkerKeys(); + $this->appConfig->setValueString(Application::APP_ID, WorkerConfigPolicy::SYSTEM_APP_CONFIG_KEY, $encoded); + } + + private function normalizeWorkerType(?string $value): string { + if ($value === null) { + return 'local'; + } + + $trimmed = strtolower(trim($value)); + return in_array($trimmed, ['local', 'external'], true) ? $trimmed : 'local'; + } + + private function normalizeParallelWorkers(?string $value): int { + if ($value === null) { + return 4; + } + + if (!is_numeric($value)) { + return 4; + } + + $parsed = (int)$value; + return max(1, min(32, $parsed)); + } + + private function deleteLegacyWorkerKeys(): void { + $this->appConfig->deleteKey(Application::APP_ID, 'worker_type'); + $this->appConfig->deleteKey(Application::APP_ID, 'parallel_workers'); + } + + #[\Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + return null; + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 9905666b37..f41dcb1748 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -37,6 +37,7 @@ public function getID(): string { #[\Override] public function getName(): string { + // TRANSLATORS Notification app category label shown in Nextcloud notification settings. return $this->factory->get(Application::APP_ID)->t('File sharing'); } @@ -87,6 +88,7 @@ private function parseSignRequest( if (isset($parameters['file'])) { $notification->setLink($parameters['file']['link']); $signAction = $notification->createAction() + // TRANSLATORS Action button opening the related file from a LibreSign notification. ->setParsedLabel($l->t('View')) ->setPrimary(true) ->setLink( @@ -95,6 +97,7 @@ private function parseSignRequest( ); $notification->addParsedAction($signAction); if (isset($parameters['from'])) { + // TRANSLATORS Notification subject. {from} is the user who requested a signature, {file} is the document name. $subject = $l->t('{from} requested your signature on {file}'); $notification->setParsedSubject( str_replace( @@ -109,11 +112,13 @@ private function parseSignRequest( } } if ($update) { - $notification->setParsedMessage($l->t('Changes have been made in a file that you have to sign.')); + // TRANSLATORS Notification message informing the signer that a pending document changed and should be reviewed again. + $notification->setParsedMessage($l->t('Changes were made to a document you need to sign.')); } if (isset($parameters['signRequest']) && isset($parameters['signRequest']['id'])) { $dismissAction = $notification->createAction() + // TRANSLATORS Action button that dismisses this notification from the notification list. ->setParsedLabel($l->t('Dismiss notification')) ->setLink( $this->url->linkToOCSRouteAbsolute( @@ -163,6 +168,7 @@ private function parseSigned( if (isset($parameters['file'])) { $notification->setLink($parameters['file']['link']); $signAction = $notification->createAction() + // TRANSLATORS Action button opening the signed file from a notification. ->setParsedLabel($l->t('View')) ->setPrimary(true) ->setLink( @@ -171,6 +177,7 @@ private function parseSigned( ); $notification->addParsedAction($signAction); if (isset($parameters['from'])) { + // TRANSLATORS Notification subject. {from} is the signer name and {file} is the document name that was signed. $subject = $l->t('{from} signed {file}'); $notification->setParsedSubject( str_replace( @@ -187,6 +194,7 @@ private function parseSigned( if (isset($parameters['signedFile']) && isset($parameters['signedFile']['id'])) { $dismissAction = $notification->createAction() + // TRANSLATORS Action button that dismisses this notification from the notification list. ->setParsedLabel($l->t('Dismiss notification')) ->setLink( $this->url->linkToOCSRouteAbsolute( @@ -235,6 +243,7 @@ private function parseCanceled( $notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'))); if (isset($parameters['from']) && isset($parameters['file'])) { + // TRANSLATORS Notification subject. {from} is the actor who canceled, {file} is the document name whose signature request was canceled. $subject = $l->t('{from} canceled the signature request for {file}'); $notification->setParsedSubject( str_replace( @@ -250,6 +259,7 @@ private function parseCanceled( if (isset($parameters['signRequest']) && isset($parameters['signRequest']['id'])) { $dismissAction = $notification->createAction() + // TRANSLATORS Action button that dismisses this cancellation notification. ->setParsedLabel($l->t('Dismiss notification')) ->setLink( $this->url->linkToOCSRouteAbsolute( diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index bc4ddaaedb..9d84593f6a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -28,6 +28,12 @@ * needIdentificationDocuments: bool, * identificationDocumentsWaitingApproval: bool, * } + * @psalm-type LibresignAccountCapabilitySettings = array{ + * canRequestSign: bool, + * hasSignatureFile: bool, + * isApprover: bool, + * } + * @psalm-type LibresignIdentifyMethodRequirement = 'required'|'optional' * * Request input contracts * @@ -45,7 +51,7 @@ * identifyMethods: list, * displayName?: string, * description?: string, @@ -68,9 +74,9 @@ * Identity and signer contracts * * @psalm-type LibresignIdentifyMethod = array{ - * method: 'account'|'email'|'signal'|'sms'|'telegram'|'whatsapp'|'xmpp', + * method: 'account'|'email'|'signal'|'sms'|'telegram'|'whatsapp'|'whatsappbusiness'|'xmpp', * value: string, - * mandatory: non-negative-int, + * requirement: LibresignIdentifyMethodRequirement, * } * @psalm-type LibresignCoordinate = array{ * page?: int, @@ -108,16 +114,42 @@ * name: string, * hasSignatureFile: bool, * } + * @psalm-type LibresignAdminSignatureMethodSetting = array{ + * enabled: bool, + * label: string, + * name: string, + * } + * @psalm-type LibresignPolicySnapshotSignatureMethodSetting = LibresignAdminSignatureMethodSetting&array{ + * identifyMethod?: string|null, + * needCode?: bool, + * hasConfirmCode?: bool, + * blurredEmail?: string, + * hashOfEmail?: string, + * blurredIdentifier?: string, + * hashOfIdentifier?: string, + * hasSignatureFile?: bool, + * } * @psalm-type LibresignSignatureMethods = array{ * clickToSign?: LibresignSignatureMethod, * emailToken?: LibresignSignatureMethodEmailToken, * password?: LibresignSignatureMethodPassword, * } + * @psalm-type LibresignIdentifyMethodAdminSetting = array{ + * name: string, + * friendly_name: string, + * enabled: bool, + * requirement: LibresignIdentifyMethodRequirement, + * minimumTotalVerifiedFactors?: positive-int, + * can_create_account?: bool, + * test_url?: string, + * signatureMethods?: array, + * } * @psalm-type LibresignIdentifyMethodSetting = array{ * name: string, * friendly_name: string, * enabled: bool, - * mandatory: bool, + * requirement: LibresignIdentifyMethodRequirement, + * minimumTotalVerifiedFactors?: positive-int, * signatureMethods?: LibresignSignatureMethods, * } * @psalm-type LibresignIdentifyAccount = array{ @@ -126,7 +158,7 @@ * displayName: string, * subname: string, * shareType: 0|4, - * method?: 'account'|'email'|'signal'|'sms'|'telegram'|'whatsapp'|'xmpp', + * method?: 'account'|'email'|'signal'|'sms'|'telegram'|'whatsapp'|'whatsappbusiness'|'xmpp', * iconName?: 'account'|'email'|'signal'|'sms'|'telegram'|'whatsapp'|'xmpp', * acceptsEmailNotifications?: boolean, * } @@ -141,8 +173,8 @@ * displayName: ?string, * } * @psalm-type LibresignDynamicMetadataScalar = string|int|float|bool|null - * @psalm-type LibresignDynamicMetadataRecord = array - * @psalm-type LibresignDynamicMetadataValue = LibresignDynamicMetadataScalar|list|LibresignDynamicMetadataRecord|list + * @psalm-type LibresignDynamicMetadataRecord = array + * @psalm-type LibresignDynamicMetadataValue = mixed * @psalm-type LibresignSignerCertificateInfo = array{ * serialNumber?: string, * serialNumberHex?: string, @@ -284,7 +316,7 @@ * } * @psalm-type LibresignRootCertificateName = array{ * id: string, - * value: string, + * value: string|list|null, * } * @psalm-type LibresignRootCertificate = array{ * commonName: string, @@ -307,8 +339,8 @@ * data: LibresignEngineHandler, * } * @psalm-type LibresignCertificateEngineConfigResponse = array{ - * engine: string, - * identify_methods: list, + * engine: 'cfssl'|'none'|'openssl', + * identify_methods: list, * } * @psalm-type LibresignHasRootCertResponse = array{ * hasRootCert: bool, @@ -320,8 +352,6 @@ * send_timer: string, * next_run?: string, * } - * @psalm-type LibresignAdminSigningMode = 'sync'|'async' - * @psalm-type LibresignAdminWorkerType = 'local'|'external' * @psalm-type LibresignAdminSignatureEngine = 'JSignPdf'|'PhpNative' * @psalm-type LibresignDocMdpLevelOption = array{ * value: int, @@ -333,19 +363,6 @@ * defaultLevel: int, * availableLevels: list, * } - * @psalm-type LibresignSignatureTextSettingsResponse = array{ - * template: string, - * parsed: string, - * templateFontSize: float, - * signatureFontSize: float, - * signatureWidth: float, - * signatureHeight: float, - * renderMode: string, - * } - * @psalm-type LibresignSignatureTemplateSettingsResponse = array{ - * default_signature_text_template: string, - * signature_available_variables: array, - * } * @psalm-type LibresignCertificatePolicyResponse = array{ * status: 'success', * CPS: string, @@ -353,8 +370,15 @@ * @psalm-type LibresignFooterTemplateResponse = array{ * template: string, * isDefault: bool, + * template_variables: array, * preview_width: int, * preview_height: int, + * preview_zoom: int, * } * @psalm-type LibresignActiveSigningItem = array{ * id: int, @@ -370,11 +394,136 @@ * * Validation and progress contracts * + * @psalm-type LibresignPolicyScope = 'system'|'group'|'user' + * @psalm-type LibresignEffectivePolicyValue = null|bool|int|float|string|array + * @psalm-type LibresignEffectivePolicyMeta = array{ + * defaultSystemValue?: LibresignEffectivePolicyValue, + * appConfigKey?: string, + * userPreferenceKey?: string, + * resolutionMode?: string, + * supportsGroupAdminDelegation?: bool, + * canCreateDescendantRules?: bool, + * supportsUserPreference?: bool, + * supportedScopes?: list, + * backendOnly?: bool, + * helper?: bool, + * parentPolicyKey?: string, + * compositeChildren?: list, + * } + * @psalm-type LibresignEffectivePolicyState = array{ + * policyKey: string, + * effectiveValue: LibresignEffectivePolicyValue, + * meta?: LibresignEffectivePolicyMeta, + * sourceScope: string, + * visible: bool, + * editableByCurrentActor: bool, + * allowedValues: list, + * canSaveAsUserDefault: bool, + * canUseAsRequestOverride: bool, + * preferenceWasCleared: bool, + * blockedBy: ?string, + * groupCount: non-negative-int, + * userCount: non-negative-int, + * everyoneCount: non-negative-int, + * } + * @psalm-type LibresignEffectivePolicyResponse = array{ + * policy: LibresignEffectivePolicyState, + * } + * @psalm-type LibresignEffectivePoliciesResponse = array{ + * policies: array, + * } + * @psalm-type LibresignSystemPolicyWriteRequest = array{ + * value: LibresignEffectivePolicyValue, + * } + * @psalm-type LibresignGroupPolicyState = array{ + * policyKey: string, + * scope: 'group', + * targetId: string, + * value: null|LibresignEffectivePolicyValue, + * effectiveValue?: null|LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * visibleToChild: bool, + * allowedValues: list, + * deletableByCurrentActor: bool, + * } + * @psalm-type LibresignGroupPolicyResponse = array{ + * policy: LibresignGroupPolicyState, + * } + * @psalm-type LibresignGroupPolicyWriteRequest = array{ + * value: LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * } + * @psalm-type LibresignSystemPolicyState = array{ + * policyKey: string, + * scope: 'system'|'global', + * value: null|LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * visibleToChild: bool, + * allowedValues: list, + * } + * @psalm-type LibresignSystemPolicyResponse = array{ + * policy: LibresignSystemPolicyState, + * } + * @psalm-type LibresignUserPolicyState = array{ + * policyKey: string, + * scope: 'user_policy', + * targetId: string, + * value: null|LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * } + * @psalm-type LibresignUserPolicyResponse = array{ + * policy: LibresignUserPolicyState, + * } + * @psalm-type LibresignGroupPolicyWriteResponse = LibresignMessageResponse&LibresignGroupPolicyResponse + * @psalm-type LibresignSystemPolicyWriteResponse = LibresignMessageResponse&LibresignEffectivePolicyResponse + * @psalm-type LibresignUserPolicyWriteResponse = LibresignMessageResponse&LibresignUserPolicyResponse + * @psalm-type LibresignPolicySnapshotEntry = array{ + * effectiveValue: string, + * sourceScope: string, + * } + * @psalm-type LibresignPolicySnapshotLegalInformationEntry = array{ + * effectiveValue: string, + * sourceScope: string, + * } + * @psalm-type LibresignPolicySnapshotNumericEntry = array{ + * effectiveValue: int, + * sourceScope: string, + * } + * @psalm-type LibresignPolicySnapshotIdentificationDocumentsValue = array{ + * enabled: bool, + * approvers: list, + * } + * @psalm-type LibresignPolicySnapshotIdentificationDocumentsEntry = array{ + * effectiveValue: LibresignPolicySnapshotIdentificationDocumentsValue, + * sourceScope: string, + * } + * @psalm-type LibresignPolicySnapshotIdentifyMethodFactor = array{ + * name: string, + * enabled: bool, + * signatureMethods?: array, + * signatureMethodEnabled?: string, + * requirement?: LibresignIdentifyMethodRequirement, + * minimumTotalVerifiedFactors?: positive-int, + * friendly_name?: string, + * } + * @psalm-type LibresignPolicySnapshotIdentifyMethodsEntry = array{ + * effectiveValue: list, + * sourceScope: string, + * } + * @psalm-type LibresignValidatePolicySnapshot = array{ + * docmdp?: LibresignPolicySnapshotNumericEntry, + * signature_flow?: LibresignPolicySnapshotEntry, + * add_footer?: LibresignPolicySnapshotEntry, + * legal_information?: LibresignPolicySnapshotLegalInformationEntry, + * identification_documents?: LibresignPolicySnapshotIdentificationDocumentsEntry, + * identify_methods?: LibresignPolicySnapshotIdentifyMethodsEntry, + * } * @psalm-type LibresignValidateMetadata = array{ * extension: string, * p: int, * d?: list, * original_file_deleted?: bool, + * policy_snapshot?: LibresignValidatePolicySnapshot, * pdfVersion?: string, * status_changed_at?: string, * } @@ -430,6 +579,9 @@ * messages?: list, * visibleElements?: LibresignVisibleElement[], * } + * @psalm-type LibresignValidatedFileResponse = LibresignValidatedFile&array{ + * effective_policies?: LibresignEffectivePoliciesResponse, + * } * @psalm-type LibresignProgressError = array{ * message: string, * code?: int, diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index 13f2974e55..00827959be 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -26,6 +26,8 @@ use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\Crl\CrlService; +use OCA\Libresign\Service\Policy\PolicyAuthorizationService; +use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Settings\Mailer\NewUserMailHelper; use OCP\Accounts\IAccountManager; use OCP\AppFramework\Db\DoesNotExistException; @@ -73,6 +75,8 @@ public function __construct( private IURLGenerator $urlGenerator, private Pkcs12Handler $pkcs12Handler, private IGroupManager $groupManager, + private PolicyAuthorizationService $policyAuthorizationService, + private IdDocsPolicyService $idDocsPolicyService, private IdDocsService $idDocsService, private SignerElementsService $signerElementsService, private UserElementMapper $userElementMapper, @@ -81,6 +85,7 @@ public function __construct( private ITimeFactory $timeFactory, private FileUploadHelper $uploadHelper, private CrlService $crlService, + private RequestSignAuthorizationService $requestSignAuthorizationService, ) { } @@ -190,12 +195,15 @@ public function getCertificateEngineName(): string { return $this->certificateEngineFactory->getEngine()->getName(); } + public function isSetupOk(): bool { + return $this->certificateEngineFactory->getEngine()->isSetupOk(); + } + /** * @return array */ public function getConfig(?IUser $user = null): array { - - $info['identificationDocumentsFlow'] = $this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false); + $info['identificationDocumentsFlow'] = $this->idDocsPolicyService->isIdentificationDocumentsEnabled($user); $info['hasSignatureFile'] = $this->hasSignatureFile($user); $info['phoneNumber'] = $this->getPhoneNumber($user); $info['isApprover'] = $this->validateHelper->userCanApproveValidationDocuments($user, false); @@ -207,8 +215,13 @@ public function getConfig(?IUser $user = null): array { $info['files_list_signer_identify_tab'] = $this->getUserConfigByKey('files_list_signer_identify_tab', $user); $info['files_list_sorting_mode'] = $this->getUserConfigByKey('files_list_sorting_mode', $user) ?: 'name'; $info['files_list_sorting_direction'] = $this->getUserConfigByKey('files_list_sorting_direction', $user) ?: 'asc'; + $info['policy_workbench_catalog_compact_view'] = $this->getUserConfigByKey('policy_workbench_catalog_compact_view', $user) === '1'; + $info['policy_workbench_catalog_collapsed'] = $this->getUserConfigByKey('policy_workbench_catalog_collapsed', $user) === '1'; + $info['policy_workbench_category_collapsed_state'] = $this->getUserConfigJsonByKey('policy_workbench_category_collapsed_state', $user); + $info['can_manage_group_policies'] = $this->policyAuthorizationService->canUserManageGroupPolicies($user); + $info['manageable_policy_group_ids'] = $this->policyAuthorizationService->getManageablePolicyGroupIds($user); - return array_filter($info); + return array_filter($info, static fn (mixed $value): bool => $value !== null && $value !== ''); } public function getConfigFilters(?IUser $user = null): array { @@ -268,6 +281,23 @@ private function getUserConfigByKey(string $key, ?IUser $user = null): string { return $this->userConfig->getValueString($user->getUID(), Application::APP_ID, $key); } + /** + * @return array|null + */ + private function getUserConfigJsonByKey(string $key, ?IUser $user = null): ?array { + if (!$user) { + return null; + } + + $value = $this->userConfig->getValueString($user->getUID(), Application::APP_ID, $key, ''); + if (empty($value)) { + return null; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : null; + } + private function getUserConfigIdDocsFilters(?IUser $user = null): array { if (!$user) { return []; @@ -358,18 +388,7 @@ public function getFileByNodeId(int $nodeId): File { } public function canRequestSign(?IUser $user = null): bool { - if (!$user) { - return false; - } - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'groups_request_sign', ['admin']); - if (empty($authorized)) { - return false; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - return false; - } - return true; + return $this->requestSignAuthorizationService->canRequestSign($user); } public function getSettings(?IUser $user = null): array { diff --git a/lib/Service/Crl/CrlRevocationChecker.php b/lib/Service/Crl/CrlRevocationChecker.php index 727ad962d7..6782be7587 100644 --- a/lib/Service/Crl/CrlRevocationChecker.php +++ b/lib/Service/Crl/CrlRevocationChecker.php @@ -9,10 +9,10 @@ namespace OCA\Libresign\Service\Crl; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Enum\CrlValidationStatus; use OCA\Libresign\Service\Crl\Ldap\LdapCrlDownloader; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\CrlValidation\CrlValidationPolicy; use OCP\ICache; use OCP\ICacheFactory; use OCP\IConfig; @@ -39,7 +39,7 @@ class CrlRevocationChecker { public function __construct( private IConfig $config, - private IAppConfig $appConfig, + private PolicyService $policyService, private IURLGenerator $urlGenerator, private ITempManager $tempManager, private LoggerInterface $logger, @@ -54,10 +54,11 @@ public function __construct( * data. Returns an array with a 'status' key (always {@see CrlValidationStatus}) * and optionally 'revoked_at' (ISO 8601) when the certificate is revoked. * + * @param string|null $policyUserId Resolve the CRL policy in the context of this user when provided. * @return array{status: CrlValidationStatus, revoked_at?: string} */ - public function validate(array $crlUrls, string $certPem): array { - return $this->validateFromUrlsWithDetails($crlUrls, $certPem); + public function validate(array $crlUrls, string $certPem, ?string $policyUserId = null): array { + return $this->validateFromUrlsWithDetails($crlUrls, $certPem, $policyUserId); } /** @@ -66,8 +67,11 @@ public function validate(array $crlUrls, string $certPem): array { * * @return array{status: CrlValidationStatus, revoked_at?: string} */ - private function validateFromUrlsWithDetails(array $crlUrls, string $certPem): array { - $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true); + private function validateFromUrlsWithDetails(array $crlUrls, string $certPem, ?string $policyUserId = null): array { + $normalizedPolicyUserId = is_string($policyUserId) ? trim($policyUserId) : ''; + $externalValidationEnabled = $normalizedPolicyUserId !== '' + ? $this->policyService->resolveForUserId(CrlValidationPolicy::KEY, $normalizedPolicyUserId)->getEffectiveValueAsBool(true) + : $this->policyService->resolve(CrlValidationPolicy::KEY)->getEffectiveValueAsBool(true); if (empty($crlUrls)) { // When external validation is disabled, treat an empty distribution-point diff --git a/lib/Service/Crl/CrlUrlParserService.php b/lib/Service/Crl/CrlUrlParserService.php index 2da5e04edb..296e9aa853 100644 --- a/lib/Service/Crl/CrlUrlParserService.php +++ b/lib/Service/Crl/CrlUrlParserService.php @@ -18,28 +18,20 @@ public function __construct( } public function parseUrl(string $crlUrl): ?array { - $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [ - 'instanceId' => 'INSTANCEID', - 'generation' => 999999, - 'engineType' => 'ENGINETYPE', - ]); - - $patternUrl = str_replace('INSTANCEID', '([a-z0-9]+)', $templateUrl); - $patternUrl = str_replace('999999', '(\d+)', $patternUrl); - $patternUrl = str_replace('ENGINETYPE', '([a-z])', $patternUrl); - $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl); - $escapedPattern = str_replace('\/index\.php', '', $escapedPattern); - $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern); - $pattern = '/^' . $escapedPattern . '$/i'; - - if (!preg_match($pattern, $crlUrl, $matches)) { + $path = parse_url($crlUrl, PHP_URL_PATH); + if (!is_string($path)) { + return null; + } + + $pattern = '#^/(?:index\.php/)?apps/libresign/crl/libresign_(?P[A-Za-z0-9]+)_(?P\d+)_(?P[a-z])\.crl$#'; + if (!preg_match($pattern, $path, $matches)) { return null; } return [ - 'instanceId' => $matches[1], - 'generation' => (int)$matches[2], - 'engineType' => $matches[3], + 'instanceId' => $matches['instanceId'], + 'generation' => (int)$matches['generation'], + 'engineType' => $matches['engineType'], ]; } diff --git a/lib/Service/DocMdp/ConfigService.php b/lib/Service/DocMdp/ConfigService.php index 9414efabb4..6865f990d8 100644 --- a/lib/Service/DocMdp/ConfigService.php +++ b/lib/Service/DocMdp/ConfigService.php @@ -9,9 +9,9 @@ namespace OCA\Libresign\Service\DocMdp; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Enum\DocMdpLevel; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\DocMdp\DocMdpPolicy; use OCP\IL10N; /** @@ -19,10 +19,10 @@ * @psalm-import-type LibresignDocMdpLevelOption from \OCA\Libresign\ResponseDefinitions */ class ConfigService { - private const string CONFIG_KEY_LEVEL = 'docmdp_level'; + private const DEFAULT_LEVEL = DocMdpLevel::NOT_CERTIFIED; public function __construct( - private IAppConfig $appConfig, + private PolicyService $policyService, private IL10N $l10n, ) { } @@ -43,12 +43,26 @@ public function setEnabled(bool $enabled): void { } public function getLevel(): DocMdpLevel { - $level = $this->appConfig->getValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, DocMdpLevel::CERTIFIED_FORM_FILLING->value); - return DocMdpLevel::tryFrom($level) ?? DocMdpLevel::CERTIFIED_FORM_FILLING; + $storedValue = $this->policyService->getSystemPolicy(DocMdpPolicy::KEY)?->getValue(); + + if ($storedValue instanceof DocMdpLevel) { + return $storedValue; + } + + if (is_string($storedValue) && preg_match('/^\d+$/', $storedValue) === 1) { + $storedValue = (int)$storedValue; + } + + if (is_int($storedValue)) { + return DocMdpLevel::tryFrom($storedValue) ?? self::DEFAULT_LEVEL; + } + + return self::DEFAULT_LEVEL; } public function setLevel(DocMdpLevel $level): void { - $this->appConfig->setValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, $level->value); + $allowChildOverride = $this->policyService->getSystemPolicy(DocMdpPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem(DocMdpPolicy::KEY, $level->value, $allowChildOverride); } /** @return LibresignDocMdpConfig */ @@ -71,4 +85,5 @@ private function getAvailableLevels(): array { DocMdpLevel::cases() ); } + } diff --git a/lib/Service/Envelope/EnvelopeService.php b/lib/Service/Envelope/EnvelopeService.php index 72f24796ca..59c2a4830b 100644 --- a/lib/Service/Envelope/EnvelopeService.php +++ b/lib/Service/Envelope/EnvelopeService.php @@ -16,6 +16,8 @@ use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\FolderService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Envelope\EnvelopePolicy; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; use OCP\IL10N; @@ -25,13 +27,14 @@ class EnvelopeService { public function __construct( protected FileMapper $fileMapper, protected IL10N $l10n, + protected PolicyService $policyService, protected IAppConfig $appConfig, protected FolderService $folderService, ) { } public function isEnabled(): bool { - return $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true); + return $this->policyService->resolve(EnvelopePolicy::KEY)->getEffectiveValueAsBool(true); } /** diff --git a/lib/Service/EnvelopeService.php b/lib/Service/EnvelopeService.php index 4ef4af2957..422b9749f8 100644 --- a/lib/Service/EnvelopeService.php +++ b/lib/Service/EnvelopeService.php @@ -15,6 +15,8 @@ use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Envelope\EnvelopePolicy; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; use OCP\IL10N; @@ -24,13 +26,14 @@ class EnvelopeService { public function __construct( protected FileMapper $fileMapper, protected IL10N $l10n, + protected PolicyService $policyService, protected IAppConfig $appConfig, protected FolderService $folderService, ) { } public function isEnabled(): bool { - return $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true); + return $this->policyService->resolve(EnvelopePolicy::KEY)->getEffectiveValueAsBool(true); } /** diff --git a/lib/Service/File/AccountSettingsProvider.php b/lib/Service/File/AccountSettingsProvider.php index b2397e615c..6bf8c318a2 100644 --- a/lib/Service/File/AccountSettingsProvider.php +++ b/lib/Service/File/AccountSettingsProvider.php @@ -9,28 +9,30 @@ namespace OCA\Libresign\Service\File; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; +use OCA\Libresign\Service\IdDocsPolicyService; use OCP\Accounts\IAccountManager; -use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IUser; +/** @psalm-import-type LibresignAccountCapabilitySettings from \OCA\Libresign\ResponseDefinitions */ class AccountSettingsProvider { public function __construct( private IAccountManager $accountManager, - private IAppConfig $appConfig, - private IGroupManager $groupManager, + private IdDocsPolicyService $idDocsPolicyService, private Pkcs12Handler $pkcs12Handler, ) { } + /** @psalm-return LibresignAccountCapabilitySettings */ public function getSettings(?IUser $user = null): array { - $return['canRequestSign'] = $this->canRequestSign($user); - $return['hasSignatureFile'] = $this->hasSignatureFile($user); - $return['isApprover'] = $this->isApprover($user); - return $return; + $canApproveIdDocs = $this->idDocsPolicyService->userCanApproveValidationDocuments($user, false); + + return [ + 'canRequestSign' => $canApproveIdDocs, + 'hasSignatureFile' => $this->hasSignatureFile($user), + 'isApprover' => $canApproveIdDocs, + ]; } public function getPhoneNumber(IUser $user): string { @@ -38,21 +40,6 @@ public function getPhoneNumber(IUser $user): string { return $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue(); } - private function canRequestSign(?IUser $user = null): bool { - if (!$user) { - return false; - } - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if (empty($authorized)) { - return false; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - return false; - } - return true; - } - private function hasSignatureFile(?IUser $user = null): bool { if (!$user) { return false; @@ -64,16 +51,4 @@ private function hasSignatureFile(?IUser $user = null): bool { return false; } } - - private function isApprover(?IUser $user = null): bool { - if (!$user) { - return false; - } - $approvalGroups = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if (empty($approvalGroups)) { - return false; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - return (bool)array_intersect($userGroups, $approvalGroups); - } } diff --git a/lib/Service/File/CertificateChainService.php b/lib/Service/File/CertificateChainService.php index d13f71d7fa..9eb603ae49 100644 --- a/lib/Service/File/CertificateChainService.php +++ b/lib/Service/File/CertificateChainService.php @@ -36,6 +36,7 @@ public function getCertificateChain($fileNode, File $libreSignFile, $options): a if ($sha256 === $libreSignFile->getSignedHash()) { $this->pkcs12Handler->setIsLibreSignFile(); } + $this->pkcs12Handler->setPolicyUserIdForValidation($libreSignFile->getUserId()); $certData = $this->pkcs12Handler->getCertificateChain($resource); fclose($resource); return $certData; diff --git a/lib/Service/File/EnvelopeAssembler.php b/lib/Service/File/EnvelopeAssembler.php index cb6c304da3..811e683cdb 100644 --- a/lib/Service/File/EnvelopeAssembler.php +++ b/lib/Service/File/EnvelopeAssembler.php @@ -84,7 +84,7 @@ public function buildEnvelopeChildData(File $childFile, \OCA\Libresign\Service\F $identifyMethodsArray[] = [ 'method' => $entity->getIdentifierKey(), 'value' => $entity->getIdentifierValue(), - 'mandatory' => $entity->getMandatory(), + 'requirement' => $entity->getRequirement(), ]; $signerUid ??= $entity->getUniqueIdentifier(); } @@ -151,6 +151,7 @@ public function buildEnvelopeChildData(File $childFile, \OCA\Libresign\Service\F if ($sha256 === $childFile->getSignedHash()) { $this->pkcs12Handler->setIsLibreSignFile(); } + $this->pkcs12Handler->setPolicyUserIdForValidation($childFile->getUserId()); $certData = $this->pkcs12Handler->getCertificateChain($resource); fclose($resource); } diff --git a/lib/Service/File/FileListService.php b/lib/Service/File/FileListService.php index 707c75e246..40543c0617 100644 --- a/lib/Service/File/FileListService.php +++ b/lib/Service/File/FileListService.php @@ -16,6 +16,7 @@ use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\FileElementService; @@ -428,7 +429,7 @@ private function formatSignerData( 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ 'method' => $identifyMethod->getIdentifierKey(), 'value' => $identifyMethod->getIdentifierValue(), - 'mandatory' => $identifyMethod->getMandatory(), + 'requirement' => $identifyMethod->getRequirement(), ], array_values($identifyMethodsOfSigner)), ]; @@ -510,7 +511,7 @@ private function formatSignerDataBasic( 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ 'method' => $identifyMethod->getIdentifierKey(), 'value' => $identifyMethod->getIdentifierValue(), - 'mandatory' => $identifyMethod->getMandatory(), + 'requirement' => $identifyMethod->getRequirement(), ], array_values($identifyMethodsOfSigner)), ]; @@ -549,7 +550,7 @@ private function resolveSignerDisplayName(SignRequest $signer, array $identifyMe } foreach ($identifyMethodsOfSigner as $identifyMethod) { - if (!$identifyMethod->getMandatory()) { + if ($identifyMethod->getRequirement() !== IdentifyMethodRequirement::REQUIRED->value) { continue; } if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) { @@ -865,7 +866,7 @@ private function formatChildFilesResponse( return $carry; }, ''); $displayName = array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string { - if (!$carry && $identifyMethod->getMandatory()) { + if (!$carry && $identifyMethod->getRequirement() === IdentifyMethodRequirement::REQUIRED->value) { return $identifyMethod->getIdentifierValue(); } return $carry; @@ -879,7 +880,7 @@ private function formatChildFilesResponse( 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ 'method' => $identifyMethod->getIdentifierKey(), 'value' => $identifyMethod->getIdentifierValue(), - 'mandatory' => $identifyMethod->getMandatory(), + 'requirement' => $identifyMethod->getRequirement(), ], array_values($identifyMethodsOfSigner)), 'signed' => $signer->getSigned()?->format(\DateTimeInterface::ATOM), 'status' => $signer->getSigned() ? 1 : 0, @@ -910,7 +911,6 @@ private function getFileSize(File $file): int { if ($nodeId === null || $file->getUserId() === '') { return 0; } - try { $fileNode = $this->root->getUserFolder($file->getUserId())->getFirstNodeById($nodeId); if ($fileNode instanceof NodeFile && method_exists($fileNode, 'getSize')) { diff --git a/lib/Service/File/MessagesLoader.php b/lib/Service/File/MessagesLoader.php index 987f2d570c..0e9362a742 100644 --- a/lib/Service/File/MessagesLoader.php +++ b/lib/Service/File/MessagesLoader.php @@ -35,6 +35,7 @@ public function loadMessages( if (isset($fileData->settings['canSign']) && $fileData->settings['canSign']) { $messages[] = [ 'type' => 'info', + // TRANSLATORS: Informational message shown in the file details UI when the current user is one of the required signers and still needs to sign the document. 'message' => $this->l10n->t('You need to sign this document') ]; } @@ -45,7 +46,8 @@ public function loadMessages( if (empty($fileData->signers)) { $messages[] = [ 'type' => 'info', - 'message' => $this->l10n->t('You cannot request signature for this document, please contact your administrator') + // TRANSLATORS: Informational message shown when the current user can request signatures in general, but no eligible signer can be added for this specific document. + 'message' => $this->l10n->t('You cannot create a signature request for this document. Please contact your administrator.') ]; } } diff --git a/lib/Service/File/SettingsLoader.php b/lib/Service/File/SettingsLoader.php index 552bdb0cbc..99f954b58e 100644 --- a/lib/Service/File/SettingsLoader.php +++ b/lib/Service/File/SettingsLoader.php @@ -9,22 +9,15 @@ namespace OCA\Libresign\Service\File; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File; use OCA\Libresign\Db\IdDocsMapper; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\IdDocsPolicyService; use OCA\Libresign\Service\IdentifyMethodService; -use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IUser; -use stdClass; -/** - * @psalm-import-type LibresignSettings from ResponseDefinitions - */ +/** @psalm-import-type LibresignSettings from \OCA\Libresign\ResponseDefinitions */ class SettingsLoader { public const IDENTIFICATION_DOCUMENTS_DISABLED = 0; public const IDENTIFICATION_DOCUMENTS_NEED_SEND = 1; @@ -34,15 +27,13 @@ class SettingsLoader { public function __construct( private AccountSettingsProvider $accountSettingsProvider, private IdDocsPolicyService $idDocsPolicyService, - private IAppConfig $appConfig, - private IGroupManager $groupManager, private IdDocsMapper $idDocsMapper, private IdentifyMethodService $identifyMethodService, ) { } public function loadSettings( - stdClass $fileData, + \stdClass $fileData, FileResponseOptions $options, ): void { if (!$options->isShowSettings()) { @@ -69,7 +60,7 @@ public function loadSettings( } } - private function loadApproverSignatureMethods(stdClass $fileData): void { + private function loadApproverSignatureMethods(\stdClass $fileData): void { try { $idDocs = $this->idDocsMapper->getByFileId($fileData->id); $signRequestId = $idDocs->getSignRequestId(); @@ -84,16 +75,12 @@ private function loadApproverSignatureMethods(stdClass $fileData): void { } public function getIdentificationDocumentsStatus(?IUser $user = null, ?SignRequest $signRequest = null): int { - if (!$this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)) { + if (!$this->idDocsPolicyService->isIdentificationDocumentsEnabled($user, $signRequest)) { return self::IDENTIFICATION_DOCUMENTS_DISABLED; } - $approvalGroups = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if ($user && !empty($approvalGroups) && is_array($approvalGroups)) { - $userGroups = $this->groupManager->getUserGroupIds($user); - if (array_intersect($userGroups, $approvalGroups)) { - return self::IDENTIFICATION_DOCUMENTS_APPROVED; - } + if ($user && $this->idDocsPolicyService->userCanApproveValidationDocuments($user, false)) { + return self::IDENTIFICATION_DOCUMENTS_APPROVED; } $files = $this->getIdDocFiles($user, $signRequest); @@ -101,6 +88,7 @@ public function getIdentificationDocumentsStatus(?IUser $user = null, ?SignReque return $this->calculateStatusFromFiles($files); } + /** @return array|null */ private function getIdDocFiles(?IUser $user, ?SignRequest $signRequest): ?array { if ($user) { return $this->idDocsMapper->getFilesOfAccount($user->getUID()); @@ -113,6 +101,7 @@ private function getIdDocFiles(?IUser $user, ?SignRequest $signRequest): ?array return null; } + /** @param array|null $files */ private function calculateStatusFromFiles(?array $files): int { if (empty($files)) { return self::IDENTIFICATION_DOCUMENTS_NEED_SEND; @@ -134,7 +123,8 @@ private function calculateStatusFromFiles(?array $files): int { /** * Get user identification documents settings * These are user-specific settings, not file-specific - * Always returns complete LibresignSettings with defaults + * Always returns complete settings payload with defaults. + * Canonical API shape is documented as LibresignSettings in ResponseDefinitions. * * @psalm-return LibresignSettings */ diff --git a/lib/Service/File/SignersLoader.php b/lib/Service/File/SignersLoader.php index caf935c374..7d370633de 100644 --- a/lib/Service/File/SignersLoader.php +++ b/lib/Service/File/SignersLoader.php @@ -98,7 +98,7 @@ public function loadLibreSignSigners( $fileData->signers[$index]->identifyMethods[] = [ 'method' => $entity->getIdentifierKey(), 'value' => $entity->getIdentifierValue(), - 'mandatory' => $entity->getMandatory(), + 'requirement' => $entity->getRequirement(), ]; switch ($type) { @@ -199,7 +199,30 @@ public function loadLibreSignSigners( continue; } $signatureMethod->setEntity($identifyMethodInstance->getEntity()); - $fileData->signers[$index]->signatureMethods[$signatureMethod->getName()] = $signatureMethod->toArray(); + $signatureMethodData = $signatureMethod->toArray(); + if ($signatureMethod->getName() === 'emailToken') { + $entity = $identifyMethodInstance->getEntity(); + $email = match ($entity->getIdentifierKey()) { + 'email', 'emailToken' => $entity->getIdentifierValue(), + 'account' => $this->userManager->get($entity->getIdentifierValue())?->getEMailAddress() ?? '', + default => '', + }; + $emailLowercase = strtolower($email); + $code = $entity->getCode(); + $identifiedAt = $entity->getIdentifiedAtDate(); + $signatureMethodData = [ + 'label' => $signatureMethodData['label'] ?? $signatureMethod->getFriendlyName(), + 'identifyMethod' => match ($entity->getIdentifierKey()) { + 'emailToken' => 'email', + default => $entity->getIdentifierKey(), + }, + 'needCode' => empty($code) || empty($identifiedAt), + 'hasConfirmCode' => !empty($code), + 'blurredEmail' => $emailLowercase ? (new \OCA\Libresign\Vendor\Wobeto\EmailBlur\Blur($emailLowercase))->make() : '', + 'hashOfEmail' => $emailLowercase ? md5($emailLowercase) : '', + ]; + } + $fileData->signers[$index]->signatureMethods[$signatureMethod->getName()] = $signatureMethodData; } } } diff --git a/lib/Service/FileAccessService.php b/lib/Service/FileAccessService.php index cdbb1bfcb6..696a09ecf3 100644 --- a/lib/Service/FileAccessService.php +++ b/lib/Service/FileAccessService.php @@ -10,12 +10,18 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\Db\IdentifyMethod; +use OCA\Libresign\Db\SignRequest; +use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\SignRequestStatus; use OCP\IUser; use OCP\IUserSession; class FileAccessService { public function __construct( private FileMapper $fileMapper, + private SignRequestMapper $signRequestMapper, private SignFileService $signFileService, private IUserSession $userSession, ) { @@ -48,9 +54,51 @@ private function userCanAccessFile(FileEntity $file, IUser $user): bool { return true; } + if ($this->userIsAssociatedSigner($file, $user)) { + return true; + } + return $this->userCanSignFile($file, $user); } + private function userIsAssociatedSigner(FileEntity $file, IUser $user): bool { + if ($file->getStatus() === FileStatus::DRAFT->value) { + return false; + } + + $signRequests = array_values(array_filter( + $this->signRequestMapper->getByFileId($file->getId()), + static fn (SignRequest $signRequest): bool => $signRequest->getStatus() !== SignRequestStatus::DRAFT->value, + )); + if ($signRequests === []) { + return false; + } + + $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signRequests); + foreach ($signRequests as $signRequest) { + if ($this->signRequestMatchesUser($identifyMethods[$signRequest->getId()] ?? [], $user)) { + return true; + } + } + + return false; + } + + /** + * @param array $identifyMethods + */ + private function signRequestMatchesUser(array $identifyMethods, IUser $user): bool { + return array_reduce($identifyMethods, function (bool $carry, IdentifyMethod $identifyMethod) use ($user): bool { + if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) { + return $user->getUID() === $identifyMethod->getIdentifierValue(); + } + if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL && $user->getEMailAddress()) { + return $user->getEMailAddress() === $identifyMethod->getIdentifierValue(); + } + return $carry; + }, false); + } + private function userCanSignFile(FileEntity $file, IUser $user): bool { try { $this->signFileService->getSignRequestToSign($file, null, $user); diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index d5c9bf7d1e..698f74b90f 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -16,6 +16,7 @@ use OCA\Libresign\Db\IdDocsMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Enum\SignRequestStatus; use OCA\Libresign\Exception\LibresignException; @@ -51,12 +52,11 @@ /** * @psalm-import-type LibresignValidatedFile from ResponseDefinitions - * @psalm-import-type LibresignIdentifyMethod from ResponseDefinitions * @psalm-import-type LibresignSignerDetail from ResponseDefinitions * @psalm-import-type LibresignSignerSummary from ResponseDefinitions + * @psalm-import-type LibresignIdentifyMethod from ResponseDefinitions */ class FileService { - private string $fileContent = ''; private ?File $file = null; private array $certData = []; @@ -587,8 +587,7 @@ private function loadMessages(): void { } /** - * @return array - * @psalm-return LibresignValidatedFile + * @return LibresignValidatedFile */ public function toArray(): array { $this->loadLibreSignData(); @@ -630,8 +629,8 @@ private function syncSingleFileCollection(): void { } /** - * @param list $signers - * @return list + * @param LibresignSignerDetail[] $signers + * @return LibresignSignerSummary[] */ private function mapSignerDetailsToSummary(array $signers): array { $summaries = []; @@ -683,6 +682,7 @@ private function normalizeSignerSummaryStatus(mixed $status): int { } /** + * @return LibresignIdentifyMethod[]|null * @psalm-return list|null */ private function normalizeSignerIdentifyMethods(mixed $identifyMethods): ?array { @@ -690,6 +690,7 @@ private function normalizeSignerIdentifyMethods(mixed $identifyMethods): ?array return null; } + /** @var list $normalizedMethods */ $normalizedMethods = []; foreach ($identifyMethods as $identifyMethod) { @@ -699,27 +700,50 @@ private function normalizeSignerIdentifyMethods(mixed $identifyMethods): ?array } $method = match ($identifyMethodData['method'] ?? null) { - 'account', 'email', 'signal', 'sms', 'telegram', 'whatsapp', 'xmpp' => $identifyMethodData['method'], + 'account', 'email', 'signal', 'sms', 'telegram', 'whatsapp', 'whatsappbusiness', 'xmpp' => $identifyMethodData['method'], default => null, }; $value = $identifyMethodData['value'] ?? null; - $mandatory = $this->normalizeNonNegativeInt($identifyMethodData['mandatory'] ?? null); + $requirement = $this->normalizeIdentifyMethodRequirement( + $identifyMethodData['requirement'] ?? null, + $identifyMethodData['mandatory'] ?? null, + ); - if ($method === null || !is_string($value) || $mandatory === null) { + if ($method === null || !is_string($value) || $requirement === null) { continue; } - $normalizedMethods[] = [ + /** @var LibresignIdentifyMethod $normalizedMethod */ + $normalizedMethod = [ 'method' => $method, 'value' => $value, - 'mandatory' => $mandatory, + 'requirement' => $requirement, ]; + + $normalizedMethods[] = $normalizedMethod; } return $normalizedMethods === [] ? null : $normalizedMethods; } + private function normalizeIdentifyMethodRequirement(mixed $requirement, mixed $mandatory): ?string { + if (is_string($requirement)) { + $normalizedRequirement = IdentifyMethodRequirement::tryFrom(trim($requirement)); + if ($normalizedRequirement !== null) { + return $normalizedRequirement->value; + } + } + + $mandatoryValue = $this->normalizeNonNegativeInt($mandatory); + + return match ($mandatoryValue) { + 0 => IdentifyMethodRequirement::OPTIONAL->value, + 1 => IdentifyMethodRequirement::REQUIRED->value, + default => null, + }; + } + /** * @psalm-return non-negative-int|null */ diff --git a/lib/Service/FolderService.php b/lib/Service/FolderService.php index 626193bdaf..17231ace4a 100644 --- a/lib/Service/FolderService.php +++ b/lib/Service/FolderService.php @@ -15,22 +15,27 @@ use OCP\Files\Folder; use OCP\Files\IAppData; use OCP\Files\IRootFolder; +use OCP\Files\ISetupManager; use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IGroupManager; use OCP\IL10N; use OCP\IUser; +use OCP\IUserManager; use OCP\Lock\LockedException; class FolderService { protected IAppData $appData; + private ?string $initializedFilesystemUser = null; public function __construct( private IRootFolder $root, protected IAppDataFactory $appDataFactory, protected IGroupManager $groupManager, private IAppConfig $appConfig, private IL10N $l10n, + private ISetupManager $setupManager, + private IUserManager $userManager, private ?string $userId, ) { $this->userId = $userId; @@ -38,6 +43,9 @@ public function __construct( } public function setUserId(?string $userId): void { + if ($this->userId !== $userId) { + $this->initializedFilesystemUser = null; + } $this->userId = $userId; } @@ -55,6 +63,7 @@ public function getUserRootFolder(): Folder { throw new LibresignException('Invalid user to resolve folder'); } + $this->initializeUserFilesystem($this->userId); return $this->root->getUserFolder($this->userId); } @@ -69,14 +78,12 @@ public function getUserRootFolder(): Folder { public function getFolder(): Folder { $path = $this->getLibreSignDefaultPath(); $containerFolder = $this->getContainerFolder(); + try { - /** @var Folder $folder */ - $folder = $containerFolder->get($path); + return $this->ensureFolderPathExists($containerFolder, $path); } catch (NotFoundException) { - /** @var Folder $folder */ - $folder = $containerFolder->newFolder($path); + return $this->ensureFolderPathExists($this->getAppDataContainerFolder(), $path); } - return $folder; } /** @@ -86,6 +93,7 @@ public function getFileByNodeId(int $nodeId): File { // For guests, files are stored in appdata, not in user folder // Skip getUserFolder search for guests to avoid false positives if ($this->getUserId() && !$this->groupManager->isInGroup($this->getUserId(), 'guest_app')) { + $this->initializeUserFilesystem($this->getUserId()); $file = $this->root->getUserFolder($this->getUserId())->getFirstNodeById($nodeId); if ($file instanceof File) { return $file; @@ -108,17 +116,43 @@ public function getFileByNodeId(int $nodeId): File { protected function getContainerFolder(): Folder { if ($this->getUserId() && !$this->groupManager->isInGroup($this->getUserId(), 'guest_app')) { - $containerFolder = $this->root->getUserFolder($this->getUserId()); - if ($containerFolder->isUpdateable()) { - return $containerFolder; + $this->initializeUserFilesystem($this->getUserId()); + try { + $containerFolder = $this->root->getUserFolder($this->getUserId()); + if ($containerFolder->isUpdateable()) { + return $containerFolder; + } + } catch (NotFoundException) { + // Users provisioned in tests may not have a home folder yet. } } + return $this->getAppDataContainerFolder(); + } + + private function getAppDataContainerFolder(): Folder { $containerFolder = $this->appData->getFolder('/'); $reflection = new \ReflectionClass($containerFolder); $reflectionProperty = $reflection->getProperty('folder'); return $reflectionProperty->getValue($containerFolder); } + private function ensureFolderPathExists(Folder $folder, string $path): Folder { + $cleanPath = trim($path, '/'); + + if ($cleanPath === '') { + return $folder; + } + + $segments = array_filter(explode('/', $cleanPath), static fn (string $segment): bool => $segment !== ''); + $currentFolder = $folder; + + foreach ($segments as $segment) { + $currentFolder = $currentFolder->getOrCreateFolder($segment); + } + + return $currentFolder; + } + private function getLibreSignDefaultPath(): string { if (!$this->userId) { return 'unauthenticated'; @@ -207,6 +241,7 @@ public function getFolderName(array $data, $identifier): string { } public function getFileByPath(string $path): Node { + $this->initializeUserFilesystem($this->getUserId()); $userFolder = $this->root->getUserFolder($this->getUserId()); try { return $userFolder->get($path); @@ -227,6 +262,7 @@ public function getOrCreateFolderByAbsolutePath(string $path): Folder { } $cleanPath = ltrim($path, '/'); + $this->initializeUserFilesystem($this->userId); $userFolder = $this->root->getUserFolder($this->userId); if ($cleanPath === '') { @@ -260,4 +296,19 @@ public function getOrCreateFolderByAbsolutePath(string $path): Folder { return $folder; } + + protected function initializeUserFilesystem(string $userId): void { + if ($this->initializedFilesystemUser === $userId) { + return; + } + + $this->setupManager->tearDown(); + $user = $this->userManager->get($userId); + if (!$user instanceof IUser) { + return; + } + + $this->setupManager->setupForUser($user); + $this->initializedFilesystemUser = $userId; + } } diff --git a/lib/Service/FooterService.php b/lib/Service/FooterService.php index abbe7055d0..c295842beb 100644 --- a/lib/Service/FooterService.php +++ b/lib/Service/FooterService.php @@ -8,46 +8,91 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Handler\FooterHandler; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; class FooterService { public function __construct( - private IAppConfig $appConfig, + private PolicyService $policyService, private FooterHandler $footerHandler, ) { } public function isDefaultTemplate(): bool { - $customTemplate = $this->appConfig->getValueString(Application::APP_ID, 'footer_template', ''); - return empty($customTemplate); + $footerPolicy = $this->getEffectiveFooterPolicy(); + return !$footerPolicy['customizeFooterTemplate']; } public function getTemplate(): string { return $this->footerHandler->getTemplate(); } - public function saveTemplate(string $template = ''): void { + public function getDefaultTemplate(): string { + return $this->footerHandler->getDefaultTemplate(); + } + + /** @return array{preview_width: int, preview_height: int, preview_zoom: int} */ + public function getPreviewSettings(): array { + $footerPolicy = $this->getEffectiveFooterPolicy(); + + return [ + 'preview_width' => (int)$footerPolicy['previewWidth'], + 'preview_height' => (int)$footerPolicy['previewHeight'], + 'preview_zoom' => (int)$footerPolicy['previewZoom'], + ]; + } + + public function saveTemplate(string $template = '', ?int $previewWidth = null, ?int $previewHeight = null): void { + $defaultTemplate = $this->footerHandler->getDefaultTemplate(); + $currentPolicy = $this->getEffectiveFooterPolicy(); + $normalizedPolicy = FooterPolicyValue::normalize($currentPolicy); + + if ($previewWidth !== null) { + $normalizedPolicy['previewWidth'] = $previewWidth; + } + + if ($previewHeight !== null) { + $normalizedPolicy['previewHeight'] = $previewHeight; + } + if (empty($template)) { - $this->appConfig->deleteKey(Application::APP_ID, 'footer_template'); + $normalizedPolicy['customizeFooterTemplate'] = false; + $normalizedPolicy['footerTemplate'] = ''; + $this->saveSystemFooterPolicy($normalizedPolicy); return; } - if ($template === $this->footerHandler->getDefaultTemplate()) { - $this->appConfig->deleteKey(Application::APP_ID, 'footer_template'); + $isProvidedTemplateEqualsDefault = $template === $defaultTemplate; + + if ($isProvidedTemplateEqualsDefault) { + $normalizedPolicy['customizeFooterTemplate'] = false; + $normalizedPolicy['footerTemplate'] = ''; } else { - $this->appConfig->setValueString(Application::APP_ID, 'footer_template', $template); + $normalizedPolicy['customizeFooterTemplate'] = true; + $normalizedPolicy['footerTemplate'] = $template; } + + $this->saveSystemFooterPolicy($normalizedPolicy); } - public function renderPreviewPdf(string $template = '', int $width = 595, int $height = 50): string { - if (!empty($template)) { - $this->saveTemplate($template); - } + /** @param array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} $normalizedPolicy */ + private function saveSystemFooterPolicy(array $normalizedPolicy): void { + $allowChildOverride = $this->policyService->getSystemPolicy(FooterPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem( + FooterPolicy::KEY, + FooterPolicyValue::encode($normalizedPolicy), + $allowChildOverride, + ); + } + + private function getEffectiveFooterPolicy(): array { + $policyJson = $this->footerHandler->getEffectiveFooterPolicyAsJson(); + return FooterPolicyValue::normalize($policyJson, ''); + } - // Generate a realistic UUID format for preview (36 chars with hyphens, same as real UUIDs) - // This ensures QR code size matches the final document + public function renderPreviewPdf(string $template = '', int $width = 595, int $height = 50, ?bool $writeQrcodeOnFooter = null): string { $previewUuid = sprintf( 'preview-%04x-%04x-%04x-%012x', random_int(0, 0xffff), @@ -56,18 +101,29 @@ public function renderPreviewPdf(string $template = '', int $width = 595, int $h random_int(0, 0xffffffffffff) ); - return $this->footerHandler + $handler = $this->footerHandler + ->setTemplateOverride($template !== '' ? $template : null) ->setTemplateVar('uuid', $previewUuid) ->setTemplateVar('signers', [ [ 'displayName' => 'Preview Signer', 'signed' => date('c'), ], - ]) - ->getFooter([['w' => $width, 'h' => $height]]); + ]); + + if ($writeQrcodeOnFooter !== null) { + $handler->setWriteQrcodeOnFooterOverride($writeQrcodeOnFooter); + } + + return $handler->getFooter([['w' => $width, 'h' => $height]], true); } public function getTemplateVariablesMetadata(): array { return $this->footerHandler->getTemplateVariablesMetadata(); } + + public function isPreviewAllowed(): bool { + $footerPolicy = $this->getEffectiveFooterPolicy(); + return (bool)$footerPolicy['enabled']; + } } diff --git a/lib/Service/IdDocsPolicyService.php b/lib/Service/IdDocsPolicyService.php index 694218247f..d3285ef01d 100644 --- a/lib/Service/IdDocsPolicyService.php +++ b/lib/Service/IdDocsPolicyService.php @@ -8,38 +8,165 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Db\File as FileEntity; +use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\Db\IdDocs; use OCA\Libresign\Db\IdDocsMapper; +use OCA\Libresign\Db\SignRequest; +use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\Helper\ValidateHelper; +use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ApprovalGroups\ApprovalGroupsPolicy; +use OCA\Libresign\Service\Policy\Provider\ApprovalGroups\ApprovalGroupsPolicyValue; +use OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicy; +use OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicyValue; use OCP\AppFramework\Db\DoesNotExistException; -use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IL10N; use OCP\IUser; class IdDocsPolicyService { public function __construct( - private IAppConfig $appConfig, - private ValidateHelper $validateHelper, + private PolicyService $policyService, + private IGroupManager $groupManager, + private IL10N $l10n, private IdDocsMapper $idDocsMapper, + private FileMapper $fileMapper, + private SignRequestMapper $signRequestMapper, ) { } public function canApproverSignIdDoc(IUser $user, int $fileId, int $status): bool { - if (!$this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)) { + try { + $idDocs = $this->idDocsMapper->getByFileId($fileId); + } catch (DoesNotExistException) { return false; } - if (!$this->validateHelper->userCanApproveValidationDocuments($user, false)) { + + if (!$this->isIdentificationDocumentsEnabled($user, $this->getRelatedSignRequest($idDocs))) { + return false; + } + if (!$this->userCanApproveValidationDocuments($user, false)) { return false; } $readyStatuses = [FileStatus::ABLE_TO_SIGN->value, FileStatus::PARTIAL_SIGNED->value]; if (!in_array($status, $readyStatuses, true)) { return false; } - try { - $this->idDocsMapper->getByFileId($fileId); - return true; - } catch (DoesNotExistException) { + + return true; + } + + public function isIdentificationDocumentsEnabled(?IUser $user = null, ?SignRequest $signRequest = null): bool { + $snapshotValue = $this->getSnapshotValue($signRequest); + if ($snapshotValue !== null) { + return IdentificationDocumentsPolicyValue::isEnabled($snapshotValue, false); + } + + $resolved = $user + ? $this->policyService->resolveForUser(IdentificationDocumentsPolicy::KEY, $user) + : $this->policyService->resolve(IdentificationDocumentsPolicy::KEY); + $value = $resolved->getEffectiveValue(); + return IdentificationDocumentsPolicyValue::isEnabled($value, false); + } + + /** + * Get approver group IDs for identification documents flow. + * + * @return list + */ + public function getApproverGroups(?IUser $user = null, ?SignRequest $signRequest = null): array { + $snapshotValue = $this->getSnapshotValue($signRequest); + if ($snapshotValue !== null) { + return IdentificationDocumentsPolicyValue::getApprovers($snapshotValue); + } + + $resolved = $user + ? $this->policyService->resolveForUser(IdentificationDocumentsPolicy::KEY, $user) + : $this->policyService->resolve(IdentificationDocumentsPolicy::KEY); + $value = $resolved->getEffectiveValue(); + return IdentificationDocumentsPolicyValue::getApprovers($value); + } + + public function userCanApproveValidationDocuments(?IUser $user, bool $throw = true): bool { + if ($user === null) { + return false; + } + + $authorized = $this->getApprovalGroups($user); + if (empty($authorized)) { + $authorized = ApprovalGroupsPolicyValue::DEFAULT_GROUPS; + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + if (!array_intersect($userGroups, $authorized)) { + if ($throw) { + throw new LibresignException($this->l10n->t('You are not allowed to approve user profile documents.')); + } return false; } + + return true; + } + + /** @return list */ + public function getApprovalGroups(?IUser $user = null): array { + $resolved = $user + ? $this->policyService->resolveForUser(ApprovalGroupsPolicy::KEY, $user) + : $this->policyService->resolve(ApprovalGroupsPolicy::KEY); + + return ApprovalGroupsPolicyValue::decode($resolved->getEffectiveValue()); + } + + /** @return array{enabled: bool, approvers: list}|null */ + private function getSnapshotValue(?SignRequest $signRequest = null): ?array { + $file = $this->getFileFromSignRequest($signRequest); + if (!$file instanceof FileEntity) { + return null; + } + + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return null; + } + + $entry = $policySnapshot[IdentificationDocumentsPolicy::KEY] ?? null; + if (!is_array($entry) || !array_key_exists('effectiveValue', $entry)) { + return null; + } + + return IdentificationDocumentsPolicyValue::normalize($entry['effectiveValue'], false); + } + + private function getFileFromSignRequest(?SignRequest $signRequest = null): ?FileEntity { + if (!$signRequest instanceof SignRequest) { + return null; + } + + $fileId = $signRequest->getFileId(); + if ($fileId === null) { + return null; + } + + try { + return $this->fileMapper->getById($fileId); + } catch (\Throwable) { + return null; + } + } + + private function getRelatedSignRequest(IdDocs $idDocs): ?SignRequest { + $signRequestId = $idDocs->getSignRequestId(); + if ($signRequestId === null) { + return null; + } + + try { + return $this->signRequestMapper->getById($signRequestId); + } catch (\Throwable) { + return null; + } } } diff --git a/lib/Service/IdDocsService.php b/lib/Service/IdDocsService.php index cc9b3a9cc7..d99150233b 100644 --- a/lib/Service/IdDocsService.php +++ b/lib/Service/IdDocsService.php @@ -17,6 +17,7 @@ use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\ValidateHelper; use OCP\AppFramework\Utility\ITimeFactory; @@ -101,7 +102,7 @@ public function addIdDocs(array $files, IUser $user): void { $identifyMethod->setSignRequestId($signRequest->getId()); $identifyMethod->setIdentifierKey(IdentifyMethodService::IDENTIFY_ACCOUNT); $identifyMethod->setIdentifierValue($user->getUID()); - $identifyMethod->setMandatory(1); + $identifyMethod->setRequirement(IdentifyMethodRequirement::REQUIRED->value); $this->identifyMethodMapper->insert($identifyMethod); $this->idDocsMapper->save($file->getId(), $signRequest->getId(), $user->getUID(), $fileData['type']); diff --git a/lib/Service/Identify/ResultFilter.php b/lib/Service/Identify/ResultFilter.php index 2751512228..bfe13779b3 100644 --- a/lib/Service/Identify/ResultFilter.php +++ b/lib/Service/Identify/ResultFilter.php @@ -54,7 +54,22 @@ public function excludeEmpty(array $list): array { return array_filter($list, fn ($result) => strlen((string)($result['value']['shareWith'] ?? '')) > 0); } - public function excludeNotAllowed(array $list): array { - return array_filter($list, fn ($result) => isset($result['method']) && !empty($result['method'])); + public function excludeNotAllowed(array $list, array $allowedMethods = []): array { + $allowedMethods = array_values(array_filter(array_map( + static fn (mixed $method): string => is_string($method) ? trim($method) : '', + $allowedMethods, + ), static fn (string $method): bool => $method !== '')); + + return array_filter($list, static function (array $result) use ($allowedMethods): bool { + if (!isset($result['method']) || empty($result['method'])) { + return false; + } + + if ($allowedMethods === []) { + return true; + } + + return in_array((string)$result['method'], $allowedMethods, true); + }); } } diff --git a/lib/Service/Identify/ResultFormatter.php b/lib/Service/Identify/ResultFormatter.php index 7d9687738c..a67736443c 100644 --- a/lib/Service/Identify/ResultFormatter.php +++ b/lib/Service/Identify/ResultFormatter.php @@ -24,6 +24,7 @@ private function getIconName(string $method): ?string { 'telegram', 'whatsapp', 'xmpp' => $method, + 'whatsappbusiness' => 'whatsapp', default => null, }; } diff --git a/lib/Service/Identify/SearchNormalizer.php b/lib/Service/Identify/SearchNormalizer.php index e5b26c8742..7f82df1d15 100644 --- a/lib/Service/Identify/SearchNormalizer.php +++ b/lib/Service/Identify/SearchNormalizer.php @@ -8,12 +8,11 @@ namespace OCA\Libresign\Service\Identify; +use OCA\Libresign\Service\IdentifyMethodService; use OCP\IConfig; use OCP\IPhoneNumberUtil; class SearchNormalizer { - private const array PHONE_BASED_METHODS = ['whatsapp', 'sms', 'telegram', 'signal']; - public function __construct( private IConfig $config, private IPhoneNumberUtil $phoneNumberUtil, @@ -21,7 +20,7 @@ public function __construct( } public function normalize(string $search, string $method): string { - if (!in_array($method, self::PHONE_BASED_METHODS, true)) { + if (!in_array($method, IdentifyMethodService::IDENTIFY_PHONE_METHODS, true)) { return $search; } @@ -40,7 +39,7 @@ public function normalize(string $search, string $method): string { } public function tryNormalizePhoneNumber(string $phoneNumber, string $method): ?string { - if (!in_array($method, self::PHONE_BASED_METHODS, true)) { + if (!in_array($method, IdentifyMethodService::IDENTIFY_PHONE_METHODS, true)) { return null; } diff --git a/lib/Service/Identify/ShareTypeResolver.php b/lib/Service/Identify/ShareTypeResolver.php index 43c5bd3ece..e9b81486a3 100644 --- a/lib/Service/Identify/ShareTypeResolver.php +++ b/lib/Service/Identify/ShareTypeResolver.php @@ -14,11 +14,10 @@ use OCA\Libresign\Collaboration\Collaborators\SignerPlugin; use OCA\Libresign\Service\IdentifyMethod\Account; use OCA\Libresign\Service\IdentifyMethod\Email; +use OCA\Libresign\Service\IdentifyMethodService; use OCP\Share\IShare; class ShareTypeResolver { - private const array PHONE_METHODS = ['whatsapp', 'sms', 'telegram', 'signal']; - public function __construct( private Email $identifyEmailMethod, private Account $identifyAccountMethod, @@ -30,7 +29,7 @@ public function resolve(string $method = ''): array { $isAllMethods = $normalizedMethod === '' || $normalizedMethod === 'all'; $includeAccount = $isAllMethods || $normalizedMethod === 'account'; $includeEmail = $isAllMethods || $normalizedMethod === 'email'; - $includePhone = $isAllMethods || in_array($normalizedMethod, self::PHONE_METHODS, true); + $includePhone = $isAllMethods || in_array($normalizedMethod, IdentifyMethodService::IDENTIFY_PHONE_METHODS, true); $shareTypes = []; if ($includeEmail) { diff --git a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php index 795e54b3b6..f921530163 100644 --- a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php +++ b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php @@ -11,13 +11,14 @@ use DateTime; use InvalidArgumentException; use OC\AppFramework\Http as AppFrameworkHttp; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Events\SendSignNotificationEvent; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\JSActions; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\AbstractSignatureMethod; +use OCA\Libresign\Service\Policy\Provider\ExpirationRules\ExpirationRulesPolicy; use OCA\Libresign\Service\SessionService; use OCA\Libresign\Vendor\Wobeto\EmailBlur\Blur; use OCP\Files\NotFoundException; @@ -92,11 +93,7 @@ public function getEntity(): IdentifyMethod { #[\Override] public function signatureMethodsToArray(): array { - return array_map(fn (AbstractSignatureMethod $method) => [ - 'label' => $method->getFriendlyName(), - 'name' => $method->getName(), - 'enabled' => $method->isEnabled(), - ], $this->signatureMethods); + return array_map(fn (AbstractSignatureMethod $method) => $method->toArray(), $this->signatureMethods); } public function getAvailableSignatureMethods(): array { @@ -132,6 +129,26 @@ public function getSettings(): array { return $this->settings; } + #[\Override] + public function getDefaultSettings(): array { + $this->signatureMethods = []; + foreach ($this->availableSignatureMethods as $signatureMethodName) { + $signatureMethod = $this->getEmptyInstanceOfSignatureMethodByName($signatureMethodName); + if ($signatureMethodName === $this->defaultSignatureMethod) { + $signatureMethod->enable(); + } + $this->signatureMethods[$signatureMethodName] = $signatureMethod; + } + + return [ + 'name' => $this->name, + 'friendly_name' => $this->friendlyName, + 'enabled' => true, + 'requirement' => IdentifyMethodRequirement::REQUIRED->value, + 'signatureMethods' => $this->signatureMethodsToArray(), + ]; + } + #[\Override] public function notify(): bool { if (!$this->willNotify) { @@ -216,7 +233,7 @@ protected function throwIfFileNotFound(): void { } protected function throwIfMaximumValidityExpired(): void { - $maximumValidity = (int)$this->identifyService->getAppConfig()->getValueInt(Application::APP_ID, 'maximum_validity', SessionService::NO_MAXIMUM_VALIDITY); + $maximumValidity = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_MAXIMUM_VALIDITY, SessionService::NO_MAXIMUM_VALIDITY); if ($maximumValidity <= 0) { return; } @@ -247,11 +264,11 @@ protected function throwIfInvalidToken(): void { protected function renewSession(): void { $this->identifyService->getSessionService()->setIdentifyMethodId($this->getEntity()->getId()); - $renewalInterval = $this->getRuntimeConfigInt('renewal_interval', SessionService::NO_RENEWAL_INTERVAL); + $renewalInterval = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, SessionService::NO_RENEWAL_INTERVAL); if ($renewalInterval <= 0) { return; } - $this->identifyService->getSessionService()->resetDurationOfSignPage(); + $this->identifyService->getSessionService()->resetDurationOfSignPage($renewalInterval); } protected function updateIdentifiedAt(): void { @@ -265,7 +282,7 @@ protected function updateIdentifiedAt(): void { } protected function throwIfRenewalIntervalExpired(): void { - $renewalInterval = $this->getRuntimeConfigInt('renewal_interval', SessionService::NO_RENEWAL_INTERVAL); + $renewalInterval = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, SessionService::NO_RENEWAL_INTERVAL); if ($renewalInterval <= 0) { return; } @@ -318,9 +335,9 @@ private function getRenewAction(): int { } private function getRuntimeConfigInt(string $key, int $default): int { - $appConfig = $this->identifyService->getAppConfig(); - $appConfig->clearCache(true); - return (int)$appConfig->getValueInt(Application::APP_ID, $key, $default); + $resolved = $this->identifyService->getPolicyService()->resolve($key); + $value = $resolved->getEffectiveValue(); + return $value !== null ? (int)$value : $default; } protected function throwIfAlreadySigned(): void { @@ -350,7 +367,7 @@ protected function getSettingsFromDatabase(array $default = [], array $immutable 'name' => $this->name, 'friendly_name' => $this->getFriendlyName(), 'enabled' => true, - 'mandatory' => true, + 'requirement' => IdentifyMethodRequirement::REQUIRED->value, 'signatureMethods' => $this->signatureMethodsToArray(), ], $default @@ -358,6 +375,9 @@ protected function getSettingsFromDatabase(array $default = [], array $immutable $this->removeKeysThatDontExists($default); $this->overrideImmutable($immutable); $this->settings = $this->applyDefault($this->settings, $default); + if (!isset($this->settings['requirement'])) { + $this->settings['requirement'] = IdentifyMethodRequirement::REQUIRED->value; + } return $this->settings; } diff --git a/lib/Service/IdentifyMethod/Account.php b/lib/Service/IdentifyMethod/Account.php index 20cbb84514..cd28a7081e 100644 --- a/lib/Service/IdentifyMethod/Account.php +++ b/lib/Service/IdentifyMethod/Account.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Service\IdentifyMethod; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\JSActions; @@ -161,10 +160,7 @@ public function getSettings(): array { } private function isEnabledByDefault(): bool { - $config = $this->identifyService->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []); - if (json_last_error() !== JSON_ERROR_NONE || !is_array($config)) { - return true; - } + $config = $this->identifyService->getSavedSettings(); // Remove not enabled $config = array_filter($config, fn ($i) => isset($i['enabled']) && $i['enabled'] ? true : false); diff --git a/lib/Service/IdentifyMethod/Email.php b/lib/Service/IdentifyMethod/Email.php index aab26b52cc..324db03507 100644 --- a/lib/Service/IdentifyMethod/Email.php +++ b/lib/Service/IdentifyMethod/Email.php @@ -223,7 +223,7 @@ public function getSettings(): array { $this->settings = parent::getSettingsFromDatabase( default: [ 'enabled' => false, - 'can_create_account' => true, + 'can_create_account' => false, ], immutable: [ 'test_url' => $this->identifyService->getUrlGenerator()->linkToRoute('settings.MailSettings.sendTestMail'), @@ -231,4 +231,13 @@ public function getSettings(): array { ); return $this->settings; } + + #[\Override] + public function getDefaultSettings(): array { + $settings = parent::getDefaultSettings(); + $settings['enabled'] = false; + $settings['can_create_account'] = false; + $settings['test_url'] = $this->identifyService->getUrlGenerator()->linkToRoute('settings.MailSettings.sendTestMail'); + return $settings; + } } diff --git a/lib/Service/IdentifyMethod/IIdentifyMethod.php b/lib/Service/IdentifyMethod/IIdentifyMethod.php index e18e8d24c5..df67e5e046 100644 --- a/lib/Service/IdentifyMethod/IIdentifyMethod.php +++ b/lib/Service/IdentifyMethod/IIdentifyMethod.php @@ -25,6 +25,7 @@ public function getEmptyInstanceOfSignatureMethodByName(string $name): AbstractS public function getSignatureMethods(): array; public function signatureMethodsToArray(): array; public function getSettings(): array; + public function getDefaultSettings(): array; public function willNotifyUser(bool $willNotify): void; public function notify(): bool; public function validateToRequest(): void; diff --git a/lib/Service/IdentifyMethod/IdentifyService.php b/lib/Service/IdentifyMethod/IdentifyService.php index 2329a59227..a1832840e1 100644 --- a/lib/Service/IdentifyMethod/IdentifyService.php +++ b/lib/Service/IdentifyMethod/IdentifyService.php @@ -8,12 +8,15 @@ namespace OCA\Libresign\Service\IdentifyMethod; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Service\FolderService; +use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicyValue; use OCA\Libresign\Service\SessionService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; @@ -26,7 +29,7 @@ use Psr\Log\LoggerInterface; class IdentifyService { - private array $savedSettings = []; + private ?array $savedSettings = null; public function __construct( private IdentifyMethodMapper $identifyMethodMapper, private SessionService $sessionService, @@ -42,6 +45,7 @@ public function __construct( private IURLGenerator $urlGenerator, private LoggerInterface $logger, private FolderService $folderService, + private PolicyService $policyService, ) { } @@ -126,10 +130,26 @@ private function refreshIdFromDatabaseIfNecessary(IdentifyMethod $identifyMethod } public function getSavedSettings(): array { - if (!empty($this->savedSettings)) { + if ($this->savedSettings !== null) { return $this->savedSettings; } - return $this->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []); + + $resolved = $this->getPolicyService()->resolve(IdentifyMethodsPolicy::KEY)->getEffectiveValue(); + $normalizedPayload = IdentifyMethodsPolicyValue::normalize($resolved); + $settings = IdentifyMethodsPolicyValue::extractFactors($normalizedPayload); + $globalCanCreateAccount = IdentifyMethodsPolicyValue::resolveGlobalCanCreateAccount($normalizedPayload); + + if ($globalCanCreateAccount !== null) { + foreach ($settings as &$setting) { + if (($setting['name'] ?? null) === IdentifyMethodService::IDENTIFY_EMAIL) { + $setting['can_create_account'] = $globalCanCreateAccount; + } + } + unset($setting); + } + + $this->savedSettings = $settings; + return $this->savedSettings; } public function getEventDispatcher(): IEventDispatcher { @@ -183,4 +203,8 @@ public function getLogger(): LoggerInterface { public function getFolderService(): FolderService { return $this->folderService; } + + public function getPolicyService(): PolicyService { + return $this->policyService; + } } diff --git a/lib/Service/IdentifyMethod/RuntimeRequirementValidator.php b/lib/Service/IdentifyMethod/RuntimeRequirementValidator.php new file mode 100644 index 0000000000..8350d8e79b --- /dev/null +++ b/lib/Service/IdentifyMethod/RuntimeRequirementValidator.php @@ -0,0 +1,239 @@ +getMethodsByName($signRequest); + if (empty($methodsByName)) { + return; + } + + $summary = $this->summarizeVerificationState($methodsByName); + $this->validateRequiredFactorsCompleted($summary); + + $minimumTotalVerifiedFactors = $this->resolveMinimumTotalVerifiedFactors( + $signRequest, + $summary['methodNames'], + $summary['hasOptionalFactor'], + ); + if ($minimumTotalVerifiedFactors === null) { + return; + } + + $this->validateMinimumFactorsCompleted($summary, $minimumTotalVerifiedFactors); + } + + /** + * @return array> + */ + private function getMethodsByName(SignRequest $signRequest): array { + if (!$signRequest->getId()) { + return []; + } + + return $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId()); + } + + /** + * @param array> $methodsByName + * @return array{ + * requiredFactors: int, + * identifiedRequiredFactors: int, + * identifiedFactors: int, + * hasOptionalFactor: bool, + * methodNames: list + * } + */ + private function summarizeVerificationState(array $methodsByName): array { + + $requiredFactors = 0; + $identifiedRequiredFactors = 0; + $identifiedFactors = 0; + $hasOptionalFactor = false; + $methodNames = []; + + foreach ($methodsByName as $methods) { + foreach ($methods as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + $methodName = $entity->getIdentifierKey(); + $methodNames[$methodName] = true; + + $isRequired = $entity->getRequirement() === IdentifyMethodRequirement::REQUIRED->value; + $isIdentified = $entity->getIdentifiedAtDate() !== null; + + if (!$isRequired) { + $hasOptionalFactor = true; + } + + if ($isRequired) { + $requiredFactors++; + if ($isIdentified) { + $identifiedRequiredFactors++; + } + } + + if ($isIdentified) { + $identifiedFactors++; + } + } + } + + return [ + 'requiredFactors' => $requiredFactors, + 'identifiedRequiredFactors' => $identifiedRequiredFactors, + 'identifiedFactors' => $identifiedFactors, + 'hasOptionalFactor' => $hasOptionalFactor, + 'methodNames' => array_keys($methodNames), + ]; + } + + /** + * Pure logic: validates that all required factors are identified. + * @param array{requiredFactors:int, identifiedRequiredFactors:int, identifiedFactors:int, hasOptionalFactor:bool, methodNames:list} $summary + * @throws LibresignException + */ + public function validateRequiredFactorsCompleted(array $summary): void { + if ($summary['identifiedRequiredFactors'] < $summary['requiredFactors']) { + // TRANSLATORS: Error shown when a signer tries to sign before completing every identification factor marked as required, such as email, password, or document validation. + throw new LibresignException($this->l10n->t('You need to complete all required identification factors before signing.')); + } + } + + /** + * Pure logic: validates that total identified factors meet minimum requirement. + * @param array{requiredFactors:int, identifiedRequiredFactors:int, identifiedFactors:int, hasOptionalFactor:bool, methodNames:list} $summary + * @param int $minimumTotalVerifiedFactors + * @throws LibresignException + */ + public function validateMinimumFactorsCompleted(array $summary, int $minimumTotalVerifiedFactors): void { + $requiredVerifiedFactors = max($summary['requiredFactors'], $minimumTotalVerifiedFactors); + if ($summary['identifiedFactors'] < $requiredVerifiedFactors) { + // TRANSLATORS: Error shown when signing requires a minimum number of completed identification factors. %n is the required number of verified factors. + throw new LibresignException( + $this->l10n->n( + 'You need to complete at least %n identification factor before signing.', + 'You need to complete at least %n identification factors before signing.', + $requiredVerifiedFactors + ) + ); + } + } + + /** + * Pure logic: resolves maximum minimum requirement from settings array. + * @param list $settings + * @param array $methodSet + * @return int|null + */ + public function resolveMinimumFromSettingsList(array $settings, array $methodSet): ?int { + return $this->resolveMinimumFromSettings($settings, $methodSet); + } + + /** + * @param list $methodNames + */ + private function resolveMinimumTotalVerifiedFactors(SignRequest $signRequest, array $methodNames, bool $hasOptionalFactor): ?int { + // Runtime minimum enforcement is enabled only when optional factors exist. + if (!$hasOptionalFactor) { + return null; + } + + $methodSet = array_fill_keys($methodNames, true); + + $minimumFromSnapshot = $this->resolveMinimumTotalVerifiedFactorsFromPolicySnapshot($signRequest, $methodSet); + if ($minimumFromSnapshot !== null) { + return $minimumFromSnapshot; + } + + return $this->resolveMinimumFromSettings( + $this->identifyMethodService->getIdentifyMethodsSettings(), + $methodSet, + ); + } + + /** + * @param list $settings + * @param array $methodSet + */ + private function resolveMinimumFromSettings(array $settings, array $methodSet): ?int { + $minimum = null; + foreach ($settings as $setting) { + $candidate = $this->resolveMinimumCandidate($setting, $methodSet); + if ($candidate === null) { + continue; + } + + $minimum = $minimum === null ? $candidate : max($minimum, $candidate); + } + + return $minimum; + } + + /** + * @param mixed $setting + * @param array $methodSet + */ + private function resolveMinimumCandidate(mixed $setting, array $methodSet): ?int { + if (!is_array($setting) || empty($setting['name']) || !isset($methodSet[$setting['name']])) { + return null; + } + if (!array_key_exists('minimumTotalVerifiedFactors', $setting) || !is_numeric($setting['minimumTotalVerifiedFactors'])) { + return null; + } + + $candidate = (int)$setting['minimumTotalVerifiedFactors']; + return $candidate < 1 ? null : $candidate; + } + + /** + * @param array $methodSet + */ + private function resolveMinimumTotalVerifiedFactorsFromPolicySnapshot(SignRequest $signRequest, array $methodSet): ?int { + try { + $file = $this->fileMapper->getById($signRequest->getFileId()); + } catch (\Throwable) { + return null; + } + + $metadata = $file->getMetadata() ?? []; + if (!isset($metadata['policy_snapshot']) || !is_array($metadata['policy_snapshot'])) { + return null; + } + + $entry = $metadata['policy_snapshot'][IdentifyMethodsPolicy::KEY] ?? null; + if (!is_array($entry) || !array_key_exists('effectiveValue', $entry)) { + return null; + } + + $normalized = IdentifyMethodsPolicyValue::normalize($entry['effectiveValue']); + $factors = IdentifyMethodsPolicyValue::extractFactors($normalized); + if (empty($factors)) { + return null; + } + + return $this->resolveMinimumFromSettings($factors, $methodSet); + } +} diff --git a/lib/Service/IdentifyMethod/SignatureMethod/AbstractSignatureMethod.php b/lib/Service/IdentifyMethod/SignatureMethod/AbstractSignatureMethod.php index 8eff23e74c..c20350cabc 100644 --- a/lib/Service/IdentifyMethod/SignatureMethod/AbstractSignatureMethod.php +++ b/lib/Service/IdentifyMethod/SignatureMethod/AbstractSignatureMethod.php @@ -27,6 +27,8 @@ public function isEnabled(): bool { public function toArray(): array { return [ 'label' => $this->getFriendlyName(), + 'name' => $this->getName(), + 'enabled' => $this->isEnabled(), ]; } } diff --git a/lib/Service/IdentifyMethod/SignatureMethod/EmailToken.php b/lib/Service/IdentifyMethod/SignatureMethod/EmailToken.php index 63a8fba4bb..7f0e19e25d 100644 --- a/lib/Service/IdentifyMethod/SignatureMethod/EmailToken.php +++ b/lib/Service/IdentifyMethod/SignatureMethod/EmailToken.php @@ -33,7 +33,7 @@ public function toArray(): array { $entity = $this->getEntity(); $email = match ($entity->getIdentifierKey()) { - 'email' => $entity->getIdentifierValue(), + 'email', 'emailToken' => $entity->getIdentifierValue(), 'account' => $this->identifyService->getUserManager()->get($entity->getIdentifierValue()) ?->getEMailAddress() ?? '', default => '', @@ -50,13 +50,17 @@ public function toArray(): array { || empty($identifiedAt) || empty($codeSentByUser); - $return = parent::toArray(); - $return['identifyMethod'] = $entity->getIdentifierKey(); - $return['needCode'] = $needCode; - $return['hasConfirmCode'] = $hasConfirmCode; - $return['blurredEmail'] = $emailLowercase ? $this->blurEmail($emailLowercase) : ''; - $return['hashOfEmail'] = $emailLowercase ? md5($emailLowercase) : ''; - return $return; + return [ + 'label' => $this->getFriendlyName(), + 'identifyMethod' => match ($entity->getIdentifierKey()) { + 'emailToken' => 'email', + default => $entity->getIdentifierKey(), + }, + 'needCode' => $needCode, + 'hasConfirmCode' => $hasConfirmCode, + 'blurredEmail' => $emailLowercase ? $this->blurEmail($emailLowercase) : '', + 'hashOfEmail' => $emailLowercase ? md5($emailLowercase) : '', + ]; } private function blurEmail(string $email): string { diff --git a/lib/Service/IdentifyMethod/SignatureMethod/TwofactorGatewayToken.php b/lib/Service/IdentifyMethod/SignatureMethod/TwofactorGatewayToken.php index 02760b880e..6eac4ede34 100644 --- a/lib/Service/IdentifyMethod/SignatureMethod/TwofactorGatewayToken.php +++ b/lib/Service/IdentifyMethod/SignatureMethod/TwofactorGatewayToken.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Service\IdentifyMethod\SignatureMethod; use OCA\Libresign\Service\IdentifyMethod\IdentifyService; +use OCA\Libresign\Service\IdentifyMethodService; class TwofactorGatewayToken extends AbstractSignatureMethod implements IToken { private const int VISIBILITY_START = 2; @@ -95,9 +96,6 @@ public function requestCode(string $identifier, string $method): void { } private function getGatewayName(string $identifyMethod): string { - return match ($identifyMethod) { - 'whatsapp' => 'gowhatsapp', - default => strtolower($identifyMethod), - }; + return IdentifyMethodService::resolveTwofactorGatewayName($identifyMethod); } } diff --git a/lib/Service/IdentifyMethod/TwofactorGateway.php b/lib/Service/IdentifyMethod/TwofactorGateway.php index f80c9ecf14..ca5f6f77e7 100644 --- a/lib/Service/IdentifyMethod/TwofactorGateway.php +++ b/lib/Service/IdentifyMethod/TwofactorGateway.php @@ -10,6 +10,7 @@ use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\IdentifyMethodMapper; +use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\SessionService; use OCA\TwoFactorGateway\Provider\Gateway\Factory; use OCP\App\IAppManager; @@ -18,6 +19,7 @@ use OCP\IUserSession; use OCP\Server; use Psr\Log\LoggerInterface; +use Throwable; class TwofactorGateway extends AbstractIdentifyMethod { public function __construct( @@ -67,15 +69,21 @@ public function isTwofactorGatewayEnabled(): bool { $gatewayName = $this->getGatewayName(); - $gateway = $gatewayFactory->get($gatewayName); - return $gateway->isComplete(); + try { + $gateway = $gatewayFactory->get($gatewayName); + return $gateway->isComplete(); + } catch (Throwable $exception) { + $this->logger->warning('Unable to load twofactor gateway provider.', [ + 'gateway' => $gatewayName, + 'identifyMethod' => $this->getId(), + 'exception' => $exception, + ]); + return false; + } } private function getGatewayName(): string { - return match ($this->getId()) { - 'whatsapp' => 'gowhatsapp', - default => strtolower($this->getId()), - }; + return IdentifyMethodService::resolveTwofactorGatewayName($this->getId()); } #[\Override] diff --git a/lib/Service/IdentifyMethod/Whatsappbusiness.php b/lib/Service/IdentifyMethod/Whatsappbusiness.php new file mode 100644 index 0000000000..4d8c3fdbc6 --- /dev/null +++ b/lib/Service/IdentifyMethod/Whatsappbusiness.php @@ -0,0 +1,24 @@ +identifyService->getL10n()->t('WhatsApp Business'); + } +} diff --git a/lib/Service/IdentifyMethodService.php b/lib/Service/IdentifyMethodService.php index 8fa894766b..37e6c9f8f5 100644 --- a/lib/Service/IdentifyMethodService.php +++ b/lib/Service/IdentifyMethodService.php @@ -11,6 +11,7 @@ use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequest; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\IdentifyMethod\Account; @@ -20,12 +21,16 @@ use OCA\Libresign\Service\IdentifyMethod\Sms; use OCA\Libresign\Service\IdentifyMethod\Telegram; use OCA\Libresign\Service\IdentifyMethod\Whatsapp; +use OCA\Libresign\Service\IdentifyMethod\Whatsappbusiness; use OCA\Libresign\Service\IdentifyMethod\Xmpp; +use OCA\Libresign\Vendor\Wobeto\EmailBlur\Blur; use OCP\IL10N; use OCP\IUserManager; /** - * @psalm-import-type LibresignIdentifyMethodSetting from ResponseDefinitions + * @psalm-import-type LibresignIdentifyMethodAdminSetting from ResponseDefinitions + * @psalm-import-type LibresignPolicySnapshotIdentifyMethodFactor from ResponseDefinitions + * @psalm-import-type LibresignPolicySnapshotSignatureMethodSetting from ResponseDefinitions */ class IdentifyMethodService { public const IDENTIFY_ACCOUNT = 'account'; @@ -34,7 +39,23 @@ class IdentifyMethodService { public const IDENTIFY_TELEGRAM = 'telegram'; public const IDENTIFY_SMS = 'sms'; public const IDENTIFY_WHATSAPP = 'whatsapp'; + public const IDENTIFY_WHATSAPP_BUSINESS = 'whatsappbusiness'; public const IDENTIFY_XMPP = 'xmpp'; + public const IDENTIFY_PHONE_METHODS = [ + self::IDENTIFY_WHATSAPP, + self::IDENTIFY_WHATSAPP_BUSINESS, + self::IDENTIFY_SMS, + self::IDENTIFY_TELEGRAM, + self::IDENTIFY_SIGNAL, + ]; + public const IDENTIFY_TWOFACTOR_GATEWAY_METHODS = [ + self::IDENTIFY_SMS, + self::IDENTIFY_SIGNAL, + self::IDENTIFY_TELEGRAM, + self::IDENTIFY_WHATSAPP, + self::IDENTIFY_WHATSAPP_BUSINESS, + self::IDENTIFY_XMPP, + ]; public const IDENTIFY_PASSWORD = 'password'; public const IDENTIFY_CLICK_TO_SIGN = 'clickToSign'; public const IDENTIFY_METHODS = [ @@ -44,14 +65,17 @@ class IdentifyMethodService { self::IDENTIFY_TELEGRAM, self::IDENTIFY_SMS, self::IDENTIFY_WHATSAPP, + self::IDENTIFY_WHATSAPP_BUSINESS, self::IDENTIFY_XMPP, self::IDENTIFY_PASSWORD, self::IDENTIFY_CLICK_TO_SIGN, ]; private bool $isRequest = true; private ?IdentifyMethod $currentIdentifyMethod = null; - /** @var list */ + /** @var list */ private array $identifyMethodsSettings = []; + /** @var list */ + private array $identifyMethodsCatalogSettings = []; /** * @var array> */ @@ -67,6 +91,7 @@ public function __construct( private Sms $sms, private Telegram $telegram, private Whatsapp $Whatsapp, + private Whatsappbusiness $whatsappbusiness, private Xmpp $xmpp, private SubjectAlternativeNameService $subjectAlternativeNameService, ) { @@ -75,6 +100,8 @@ public function __construct( public function clearCache(): void { $this->identifyMethods = []; $this->currentIdentifyMethod = null; + $this->identifyMethodsSettings = []; + $this->identifyMethodsCatalogSettings = []; } public function setIsRequest(bool $isRequest): self { @@ -82,7 +109,7 @@ public function setIsRequest(bool $isRequest): self { return $this; } - public function getInstanceOfIdentifyMethod(string $name, ?string $identifyValue = null): IIdentifyMethod { + public function getInstanceOfIdentifyMethod(string $name, ?string $identifyValue = null, ?string $requirement = null): IIdentifyMethod { if ($identifyValue && isset($this->identifyMethods[$name])) { foreach ($this->identifyMethods[$name] as $identifyMethod) { if ($identifyMethod->getEntity()->getIdentifierValue() === $identifyValue) { @@ -97,7 +124,7 @@ public function getInstanceOfIdentifyMethod(string $name, ?string $identifyValue if (!$entity->getId()) { $entity->setIdentifierKey($name); $entity->setIdentifierValue($identifyValue); - $entity->setMandatory($this->isMandatoryMethod($name) ? 1 : 0); + $entity->setRequirement($requirement ?? $this->resolveMethodRequirement($name)); } if ($identifyValue && $this->isRequest) { $identifyMethod->validateToRequest(); @@ -142,17 +169,23 @@ private function getNewInstanceOfMethod(string $name): IIdentifyMethod { return $identifyMethod; } - private function setEntityData(string $method, string $identifyValue): void { - // @todo Replace by enum when PHP 8.1 is the minimum version acceptable - // at server. Check file lib/versioncheck.php of server repository - if (!in_array($method, IdentifyMethodService::IDENTIFY_METHODS)) { - // TRANSLATORS When is requested to a person to sign a file, is - // necessary identify what is the identification method. The - // identification method is used to define how will be the sign - // flow. - throw new LibresignException($this->l10n->t('Invalid identification method')); - } - $identifyMethod = $this->getInstanceOfIdentifyMethod($method, $identifyValue); + public function exists(string $name): bool { + $className = 'OCA\\Libresign\\Service\\IdentifyMethod\\' . ucfirst($name); + if (class_exists($className)) { + return true; + } + return class_exists('OCA\\Libresign\\Service\\IdentifyMethod\\SignatureMethod\\' . ucfirst($name)); + } + + public static function resolveTwofactorGatewayName(string $identifyMethod): string { + return match ($identifyMethod) { + self::IDENTIFY_WHATSAPP => 'gowhatsapp', + default => strtolower($identifyMethod), + }; + } + + private function setEntityData(string $method, string $identifyValue, ?string $requirement = null): void { + $identifyMethod = $this->getInstanceOfIdentifyMethod($method, $identifyValue, $requirement); $identifyMethod->validateToRequest(); } @@ -161,18 +194,27 @@ public function setAllEntityData(array $user): void { if (!is_array($identifyMethod) || !isset($identifyMethod['method'], $identifyMethod['value'])) { continue; } - $this->setEntityData($identifyMethod['method'], $identifyMethod['value']); + $requirement = isset($identifyMethod['requirement']) && is_string($identifyMethod['requirement']) + ? $identifyMethod['requirement'] + : null; + if ($requirement === null && array_key_exists('mandatory', $identifyMethod)) { + $requirement = ((int)$identifyMethod['mandatory']) === 1 + ? IdentifyMethodRequirement::REQUIRED->value + : IdentifyMethodRequirement::OPTIONAL->value; + } + $this->setEntityData($identifyMethod['method'], $identifyMethod['value'], $requirement); } } - private function isMandatoryMethod(string $methodName): bool { + private function resolveMethodRequirement(string $methodName): string { $settings = $this->getIdentifyMethodsSettings(); foreach ($settings as $setting) { if ($setting['name'] === $methodName) { - return $setting['mandatory']; + $requirement = IdentifyMethodRequirement::tryFrom((string)($setting['requirement'] ?? '')); + return $requirement?->value ?? IdentifyMethodRequirement::OPTIONAL->value; } } - return false; + return IdentifyMethodRequirement::OPTIONAL->value; } /** @@ -303,7 +345,30 @@ public function getSignMethodsOfIdentifiedFactors(int $signRequestId): array { continue; } $signatureMethod->setEntity($identifyMethod->getEntity()); - $return[$signatureMethod->getName()] = $signatureMethod->toArray(); + $signatureMethodData = $signatureMethod->toArray(); + if ($signatureMethod->getName() === 'emailToken') { + $entity = $identifyMethod->getEntity(); + $email = match ($entity->getIdentifierKey()) { + 'email', 'emailToken' => $entity->getIdentifierValue(), + 'account' => $this->userManager->get($entity->getIdentifierValue())?->getEMailAddress() ?? '', + default => '', + }; + $emailLowercase = strtolower($email); + $code = $entity->getCode(); + $identifiedAt = $entity->getIdentifiedAtDate(); + $signatureMethodData = [ + 'label' => $signatureMethodData['label'] ?? $signatureMethod->getFriendlyName(), + 'identifyMethod' => match ($entity->getIdentifierKey()) { + 'emailToken' => 'email', + default => $entity->getIdentifierKey(), + }, + 'needCode' => empty($code) || empty($identifiedAt), + 'hasConfirmCode' => !empty($code), + 'blurredEmail' => $emailLowercase ? (new Blur($emailLowercase))->make() : '', + 'hashOfEmail' => $emailLowercase ? md5($emailLowercase) : '', + ]; + } + $return[$signatureMethod->getName()] = $signatureMethodData; } } } @@ -332,10 +397,54 @@ public function save(SignRequest $signRequest, bool $notify = true): void { } } - /** @return list */ + /** + * @return array + * @psalm-return list + */ + public function getIdentifyMethodsCatalogSettings(): array { + if ($this->identifyMethodsCatalogSettings) { + /** @var list $cachedCatalogSettings */ + $cachedCatalogSettings = $this->identifyMethodsCatalogSettings; + return $cachedCatalogSettings; + } + + $this->identifyMethodsCatalogSettings = [ + $this->account->getDefaultSettings(), + $this->email->getDefaultSettings(), + ]; + if ($this->signal->isTwofactorGatewayEnabled()) { + $this->identifyMethodsCatalogSettings[] = $this->signal->getDefaultSettings(); + } + if ($this->sms->isTwofactorGatewayEnabled()) { + $this->identifyMethodsCatalogSettings[] = $this->sms->getDefaultSettings(); + } + if ($this->telegram->isTwofactorGatewayEnabled()) { + $this->identifyMethodsCatalogSettings[] = $this->telegram->getDefaultSettings(); + } + if ($this->Whatsapp->isTwofactorGatewayEnabled()) { + $this->identifyMethodsCatalogSettings[] = $this->Whatsapp->getDefaultSettings(); + } + if ($this->whatsappbusiness->isTwofactorGatewayEnabled()) { + $this->identifyMethodsCatalogSettings[] = $this->whatsappbusiness->getDefaultSettings(); + } + if ($this->xmpp->isTwofactorGatewayEnabled()) { + $this->identifyMethodsCatalogSettings[] = $this->xmpp->getDefaultSettings(); + } + + /** @var list $catalogSettings */ + $catalogSettings = $this->identifyMethodsCatalogSettings; + return $catalogSettings; + } + + /** + * @return array + * @psalm-return list + */ public function getIdentifyMethodsSettings(): array { if ($this->identifyMethodsSettings) { - return $this->identifyMethodsSettings; + /** @var list $cachedIdentifyMethodsSettings */ + $cachedIdentifyMethodsSettings = $this->identifyMethodsSettings; + return $cachedIdentifyMethodsSettings; } $this->identifyMethodsSettings = [ $this->account->getSettings(), @@ -353,10 +462,91 @@ public function getIdentifyMethodsSettings(): array { if ($this->Whatsapp->isTwofactorGatewayEnabled()) { $this->identifyMethodsSettings[] = $this->Whatsapp->getSettings(); } + if ($this->whatsappbusiness->isTwofactorGatewayEnabled()) { + $this->identifyMethodsSettings[] = $this->whatsappbusiness->getSettings(); + } if ($this->xmpp->isTwofactorGatewayEnabled()) { $this->identifyMethodsSettings[] = $this->xmpp->getSettings(); } - return $this->identifyMethodsSettings; + /** @var list $identifyMethodsSettings */ + $identifyMethodsSettings = $this->identifyMethodsSettings; + return $identifyMethodsSettings; + } + + /** @return array */ + public function getFriendlyNamesMap(): array { + return [ + $this->account->getName() => $this->account->getFriendlyName(), + $this->email->getName() => $this->email->getFriendlyName(), + $this->signal->getName() => $this->signal->getFriendlyName(), + $this->sms->getName() => $this->sms->getFriendlyName(), + $this->telegram->getName() => $this->telegram->getFriendlyName(), + $this->Whatsapp->getName() => $this->Whatsapp->getFriendlyName(), + $this->whatsappbusiness->getName() => $this->whatsappbusiness->getFriendlyName(), + $this->xmpp->getName() => $this->xmpp->getFriendlyName(), + ]; + } + + /** + * Get default identify methods policy seed + * + * Returns a legitimate default configuration with account and email methods + * when no policy is explicitly configured. This provides a reasonable baseline + * for new rules without hardcoding payload values in this service. + * + * @return array Default identify methods factors array + * @psalm-return list + */ + public function getDefaultIdentifyMethodsPolicy(): array { + return [ + $this->buildDefaultPolicyFactorFromSettings($this->account->getDefaultSettings()), + $this->buildDefaultPolicyFactorFromSettings($this->email->getDefaultSettings()), + ]; + } + + /** + * @param LibresignIdentifyMethodAdminSetting $settings + * @return array + * @psalm-return LibresignPolicySnapshotIdentifyMethodFactor + */ + private function buildDefaultPolicyFactorFromSettings(array $settings): array { + /** @var array $signatureMethods */ + $signatureMethods = []; + $signatureMethodEnabled = ''; + + foreach ($settings['signatureMethods'] ?? [] as $signatureMethodName => $signatureMethodConfig) { + $isEnabled = (bool)($signatureMethodConfig['enabled'] ?? false); + $normalizedSignatureMethod = is_array($signatureMethodConfig) + ? $signatureMethodConfig + : []; + $normalizedSignatureMethod['enabled'] = $isEnabled; + $normalizedSignatureMethod['name'] = $signatureMethodName; + $normalizedSignatureMethod['label'] = $normalizedSignatureMethod['label'] ?? $signatureMethodName; + + /** @var LibresignPolicySnapshotSignatureMethodSetting $normalizedSignatureMethod */ + $signatureMethods[$signatureMethodName] = $normalizedSignatureMethod; + + if ($signatureMethodEnabled === '' && $isEnabled) { + $signatureMethodEnabled = $signatureMethodName; + } + } + + if ($signatureMethodEnabled === '' && !empty($signatureMethods)) { + $signatureMethodEnabled = (string)array_key_first($signatureMethods); + } + + $requirement = IdentifyMethodRequirement::tryFrom((string)($settings['requirement'] ?? '')); + + /** @var LibresignPolicySnapshotIdentifyMethodFactor $defaultPolicyFactor */ + $defaultPolicyFactor = [ + 'name' => $settings['name'], + 'enabled' => (bool)($settings['enabled'] ?? true), + 'requirement' => $requirement?->value ?? IdentifyMethodRequirement::REQUIRED->value, + 'signatureMethods' => $signatureMethods, + 'signatureMethodEnabled' => $signatureMethodEnabled, + ]; + + return $defaultPolicyFactor; } /** diff --git a/lib/Service/Install/SignSetupService.php b/lib/Service/Install/SignSetupService.php index c4345cd61c..fff0e4cfa3 100644 --- a/lib/Service/Install/SignSetupService.php +++ b/lib/Service/Install/SignSetupService.php @@ -68,17 +68,26 @@ public function setResource(string $resource): self { return $this; } + /** + * @return array + */ public function getArchitectures(): array { $appInfo = $this->appManager->getAppInfo(Application::APP_ID); if (!is_array($appInfo) || !isset($appInfo['dependencies'])) { throw new \Exception('dependencies>architecture not found at info.xml'); } - /** @var list $architectures */ - $architectures = $appInfo['dependencies']['architecture'] ?? []; - if ($architectures === []) { + $architectures = $appInfo['dependencies']['architecture'] ?? null; + if ($architectures === null || $architectures === '' || $architectures === []) { throw new \Exception('dependencies>architecture not found at info.xml'); } - return $architectures; + + if (is_array($architectures)) { + /** @var list $normalized */ + $normalized = array_values(array_map(static fn (mixed $value): string => (string)$value, $architectures)); + return $normalized; + } + + return [(string)$architectures]; } public function setPrivateKey(PrivateKey $privateKey): void { diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index 5f24ff1d04..5efcae288a 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -47,21 +47,24 @@ private function getFileById(int $fileId): File { */ public function notifySignDataUpdated(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - // TRANSLATORS The subject of the email that is sent after changes are made to the signature request that may affect something for the signer who will sign the document. Some possible reasons: URL for signature changed (when the URL expires), the person who requested the signature sent a notification - $emailTemplate->setSubject($this->l10n->t('LibreSign: Changes into a file for you to sign')); + // TRANSLATORS Email subject notifying a signer that a pending signature request changed and should be reviewed again. + $emailTemplate->setSubject($this->l10n->t('LibreSign: Changes were made to a document waiting for your signature')); $emailTemplate->addHeader(); - $emailTemplate->addHeading($this->l10n->t('File to sign'), false); + // TRANSLATORS Email heading shown above a pending document that still needs the recipient's signature. + $emailTemplate->addHeading($this->l10n->t('Document to sign'), false); if (!empty($description)) { $emailTemplate->addBodyText($description); $emailTemplate->addBodyText(''); } - $emailTemplate->addBodyText($this->l10n->t('Changes have been made in a file that you have to sign. Access the link below:')); + // TRANSLATORS Email body telling the signer to reopen the request because some request details changed. + $emailTemplate->addBodyText($this->l10n->t('Changes were made to a document you need to sign. Open the link below:')); $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $data->getUuid()]); $file = $this->getFileById($data->getFileId()); $emailTemplate->addBodyButton( - $this->l10n->t('Sign »%s«', [$file->getName()]), + // TRANSLATORS Email button label that opens the signing page. %s is the document filename. + $this->l10n->t('Sign "%s"', [$file->getName()]), $link ); $message = $this->mailer->createMessage(); @@ -84,20 +87,24 @@ public function notifySignDataUpdated(SignRequest $data, string $email, ?string */ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->l10n->t('LibreSign: There is a file for you to sign')); + // TRANSLATORS Email subject notifying a signer that a document is ready for their digital signature. + $emailTemplate->setSubject($this->l10n->t('LibreSign: A document is ready for your signature')); $emailTemplate->addHeader(); - $emailTemplate->addHeading($this->l10n->t('File to sign'), false); + // TRANSLATORS Email heading shown above a document awaiting the recipient's signature. + $emailTemplate->addHeading($this->l10n->t('Document to sign'), false); if (!empty($description)) { $emailTemplate->addBodyText($description); $emailTemplate->addBodyText(''); } - $emailTemplate->addBodyText($this->l10n->t('There is a document for you to sign. Access the link below:')); + // TRANSLATORS Email body inviting the signer to open the document and sign it. + $emailTemplate->addBodyText($this->l10n->t('A document is ready for your signature. Open the link below:')); $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $data->getUuid()]); $file = $this->getFileById($data->getFileId()); $emailTemplate->addBodyButton( - $this->l10n->t('Sign »%s«', [$file->getName()]), + // TRANSLATORS Email button label that opens the signing page. %s is the document filename. + $this->l10n->t('Sign "%s"', [$file->getName()]), $link ); $message = $this->mailer->createMessage(); @@ -117,19 +124,20 @@ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $de public function notifySignedUser(SignRequest $signRequest, string $email, File $libreSignFile, string $displayName): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - // TRANSLATORS The subject of the email that is sent after a document has been signed by a user. This email is sent to the person who requested the signature. - $emailTemplate->setSubject($this->l10n->t('LibreSign: A file has been signed')); + // TRANSLATORS Email subject sent to the requester after another signer successfully signs the document. + $emailTemplate->setSubject($this->l10n->t('LibreSign: A document has been signed')); $emailTemplate->addHeader(); - $emailTemplate->addHeading($this->l10n->t('File signed'), false); - // TRANSLATORS The text in the email that is sent after a document has been signed by a user. %s will be replaced with the name of the user who signed the document. + // TRANSLATORS Email heading shown after a document was completed by one signer. + $emailTemplate->addHeading($this->l10n->t('Signed document'), false); + // TRANSLATORS Email body confirming that a signer finished signing. %s is the display name of the signer who completed the document. $emailTemplate->addBodyText($this->l10n->t('%s signed the document. You can access it using the link below:', [$signRequest->getDisplayName()])); $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexFPath', [ 'path' => 'validation/' . $libreSignFile->getUuid(), ]); $file = $this->getFileById($signRequest->getFileId()); $emailTemplate->addBodyButton( - // TRANSLATORS The button text in the email that is sent after a document has been signed by a user. %s will be replaced with the name of the file that was signed. - $this->l10n->t('View signed file »%s«', [$file->getName()]), + // TRANSLATORS Email button label that opens the validation view of the signed document. %s is the document filename. + $this->l10n->t('View signed document "%s"', [$file->getName()]), $link ); $message = $this->mailer->createMessage(); @@ -146,11 +154,12 @@ public function notifySignedUser(SignRequest $signRequest, string $email, File $ public function notifyCanceledRequest(SignRequest $signRequest, string $email, File $libreSignFile): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - // TRANSLATORS The subject of the email that is sent when a signature request has been canceled. + // TRANSLATORS Email subject shown when the requester cancels a pending signature request. $emailTemplate->setSubject($this->l10n->t('LibreSign: A signature request has been canceled')); $emailTemplate->addHeader(); + // TRANSLATORS Email heading shown when a signature request is no longer active. $emailTemplate->addHeading($this->l10n->t('Signature request canceled'), false); - // TRANSLATORS The text in the email that is sent when a signature request has been canceled. %s will be replaced with the name of the file. + // TRANSLATORS Email body text shown after cancellation. %s is the document filename that no longer needs a signature. $emailTemplate->addBodyText($this->l10n->t('The request for you to sign "%s" has been canceled.', [$libreSignFile->getName()])); $message = $this->mailer->createMessage(); if ($signRequest->getDisplayName()) { @@ -169,8 +178,10 @@ public function notifyCanceledRequest(SignRequest $signRequest, string $email, F public function sendCodeToSign(string $email, string $name, string $code): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->l10n->t('LibreSign: Code to sign file')); + // TRANSLATORS Email subject for a one-time verification code required to sign a document. + $emailTemplate->setSubject($this->l10n->t('LibreSign: Verification code to sign a document')); $emailTemplate->addHeader(); + // TRANSLATORS Email instruction introducing the one-time code used to complete a digital signature. $emailTemplate->addBodyText($this->l10n->t('Use this code to sign the document:')); $emailTemplate->addBodyText($code); $message = $this->mailer->createMessage(); diff --git a/lib/Service/Policy/AbstractFilePolicyApplier.php b/lib/Service/Policy/AbstractFilePolicyApplier.php new file mode 100644 index 0000000000..380a1373c7 --- /dev/null +++ b/lib/Service/Policy/AbstractFilePolicyApplier.php @@ -0,0 +1,88 @@ +} $data + * @return array{type: string, id: string}|null + */ + protected function extractActiveContext(array $data): ?array { + if (!isset($data['policyActiveContext']) || !is_array($data['policyActiveContext'])) { + return null; + } + + $type = $data['policyActiveContext']['type'] ?? null; + $id = $data['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } + + /** + * @param callable(mixed):mixed|null $normalizer + * @return array + */ + protected function extractSinglePolicyOverride(array $data, string $policyKey, ?callable $normalizer = null): array { + if (!isset($data['policyOverrides']) || !is_array($data['policyOverrides']) || !array_key_exists($policyKey, $data['policyOverrides'])) { + return []; + } + + $value = $data['policyOverrides'][$policyKey]; + if ($normalizer !== null) { + $value = $normalizer($value); + } + + return [$policyKey => $value]; + } + + /** @param array $requestOverrides */ + protected function assertRequestOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy, string $message): void { + if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) { + return; + } + + $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope(); + $translatedMessage = $this->l10n instanceof IL10N + ? $this->l10n->t($message, [$blockedBy]) + : vsprintf($message, [$blockedBy]); + + throw new LibresignException($translatedMessage, 422); + } + + protected function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy, mixed $effectiveValue = null): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $effectiveValue ?? $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); + } +} diff --git a/lib/Service/Policy/Contract/IFilePolicyApplier.php b/lib/Service/Policy/Contract/IFilePolicyApplier.php new file mode 100644 index 0000000000..29fb035b9b --- /dev/null +++ b/lib/Service/Policy/Contract/IFilePolicyApplier.php @@ -0,0 +1,25 @@ + $data */ + public function apply(FileEntity $file, array $data): void; + + /** @param array $data */ + public function sync(FileEntity $file, array $data): void; + + /** + * Core flow sync is used on the UUID update path where only core flow policies + * should trigger recomputation. + */ + public function supportsCoreFlowSync(): bool; +} diff --git a/lib/Service/Policy/Contract/IPolicyDefinition.php b/lib/Service/Policy/Contract/IPolicyDefinition.php new file mode 100644 index 0000000000..13b23b6155 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyDefinition.php @@ -0,0 +1,95 @@ + */ + public function supportedScopes(): array; + + public function supportsScope(string $scope): bool; + + public function getAppConfigKey(): string; + + public function getUserPreferenceKey(): string; + + public function isBackendOnly(): bool; + + public function isHelper(): bool; + + public function parentPolicyKey(): ?string; + + /** @return list */ + public function compositeChildren(): array; + + public function normalizeValue(mixed $rawValue): mixed; + + public function validateValue(mixed $value, PolicyContext $context): void; + + /** @return list */ + public function allowedValues(PolicyContext $context): array; + + public function defaultSystemValue(): mixed; + + /** @return array */ + public function resolvedStateMeta(PolicyContext $context): array; + + /** + * Whether this policy supports being saved as a user personal preference. + * Returns false for administrative-only policies (e.g. groups_request_sign) + * that must never appear in the user preferences screen. + */ + public function supportsUserPreference(): bool; + + /** + * Whether group-level rules for this policy should be filtered from counts and listings + * for the current non-system actor when they were created by a system administrator. + */ + public function shouldFilterVisibleGroupCountsForActor(PolicyContext $context, ?PolicyLayer $systemPolicy): bool; + + /** + * Whether the current actor may manage group-level rules for this policy. + * + * @param list $groupLayers Group layers already applicable to the actor context. + */ + public function canCurrentActorManageGroupPolicy(PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool; + + /** + * Whether the current actor may edit a system-created group rule for this policy. + */ + public function canCurrentActorEditSystemCreatedGroupPolicy(PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool; + + /** + * Whether this policy supports group-admin-delegated override rules. + * When true, a separate `__delegated_override` slot is used so that the + * system-created seed and the group-admin override coexist independently. + */ + public function supportsGroupAdminDelegation(): bool; + + /** + * Validate a proposed group-admin-delegated value against the parent + * system-created seed value. Implementations should throw + * \InvalidArgumentException with a translatable message when the proposed + * value violates policy constraints. + * + * This hook is called only when the actor is a non-system-admin and the + * definition returns true from supportsGroupAdminDelegation(). + */ + public function validateGroupAdminDelegatedValue( + mixed $proposedNormalizedValue, + mixed $parentSeedNormalizedValue, + PolicyContext $context, + ): void; +} diff --git a/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php b/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php new file mode 100644 index 0000000000..0a23c76c6e --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php @@ -0,0 +1,16 @@ + */ + public function keys(): array; + + public function get(string|\BackedEnum $policyKey): IPolicyDefinition; +} diff --git a/lib/Service/Policy/Contract/IPolicyResolver.php b/lib/Service/Policy/Contract/IPolicyResolver.php new file mode 100644 index 0000000000..5f9fd8d914 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyResolver.php @@ -0,0 +1,21 @@ + $definitions + * @return array + */ + public function resolveMany(array $definitions, PolicyContext $context): array; +} diff --git a/lib/Service/Policy/Contract/IPolicySource.php b/lib/Service/Policy/Contract/IPolicySource.php new file mode 100644 index 0000000000..1ced6ef90a --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicySource.php @@ -0,0 +1,88 @@ + */ + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array; + + /** @return list */ + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array; + + public function loadUserPolicy(string $policyKey, PolicyContext $context): ?PolicyLayer; + + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer; + + /** + * Bulk-load group policy layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array> keyed by policyKey + */ + public function loadAllGroupPolicies(array $policyKeys, PolicyContext $context): array; + + /** + * Bulk-load user preference layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array keyed by policyKey + */ + public function loadAllUserPolicies(array $policyKeys, PolicyContext $context): array; + + /** + * Bulk-load user preference layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array keyed by policyKey + */ + public function loadAllUserPreferences(array $policyKeys, PolicyContext $context): array; + + public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer; + + public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer; + + /** + * @return list + */ + public function listGroupPoliciesByKey(string $policyKey): array; + + /** + * @param list $groupIds + * @return list + */ + public function listGroupPoliciesByKeyForTargets(string $policyKey, array $groupIds): array; + + public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void; + + public function clearSystemPolicy(string $policyKey): void; + + public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride, bool $createdBySystemAdmin = false, ?PolicyContext $context = null): void; + + public function clearGroupPolicy(string $policyKey, string $groupId, bool $preserveSystemCreatedBase = false): void; + + public function loadUserPolicyConfig(string $policyKey, string $userId): ?PolicyLayer; + + /** + * @return list + */ + public function listUserPoliciesByKey(string $policyKey): array; + + public function saveUserPolicy(string $policyKey, PolicyContext $context, mixed $value, bool $allowChildOverride): void; + + public function clearUserPolicy(string $policyKey, PolicyContext $context): void; + + public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void; + + public function clearUserPreference(string $policyKey, PolicyContext $context): void; +} diff --git a/lib/Service/Policy/FilePolicyApplier.php b/lib/Service/Policy/FilePolicyApplier.php new file mode 100644 index 0000000000..3281054eb0 --- /dev/null +++ b/lib/Service/Policy/FilePolicyApplier.php @@ -0,0 +1,96 @@ + */ + private readonly array $appliers; + + public function __construct( + private readonly PolicyService $policyService, + private readonly FileService $fileService, + private readonly IL10N $l10n, + ) { + $this->appliers = $this->discoverAppliers(); + } + + /** + * Apply all policies to a freshly built FileEntity before the first insert. + */ + public function applyAll(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + $applier->apply($file, $data); + } + } + + /** + * Re-evaluate and persist signing-critical file policies on an existing file. + * Use this when updating a file located by UUID. + */ + public function syncCoreFlowPolicies(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + if ($applier->supportsCoreFlowSync()) { + $applier->sync($file, $data); + } + } + } + + /** + * Re-evaluate and persist all three policies on an existing file. + * Use this when updating a file located by node ID. + */ + public function syncAllPolicies(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + $applier->sync($file, $data); + } + } + + /** @return list */ + private function discoverAppliers(): array { + $appliers = []; + + foreach (PolicyProviders::BY_KEY as $providerClass) { + $applierClass = $this->buildFileApplierClassFromProvider($providerClass); + if ($applierClass === null || !class_exists($applierClass)) { + continue; + } + + $instance = new $applierClass($this->policyService, $this->fileService, $this->l10n); + if (!$instance instanceof IFilePolicyApplier) { + continue; + } + + $appliers[] = $instance; + } + + return $appliers; + } + + /** @param class-string $providerClass */ + private function buildFileApplierClassFromProvider(string $providerClass): ?string { + $lastSeparator = strrpos($providerClass, '\\'); + if ($lastSeparator === false) { + return null; + } + + $namespace = substr($providerClass, 0, $lastSeparator); + $shortName = substr($providerClass, $lastSeparator + 1); + $baseName = str_ends_with($shortName, 'Policy') + ? substr($shortName, 0, -strlen('Policy')) + : $shortName; + + return $namespace . '\\FilePolicy\\' . $baseName . 'FilePolicyApplier'; + } +} diff --git a/lib/Service/Policy/IPolicyAuthorizationService.php b/lib/Service/Policy/IPolicyAuthorizationService.php new file mode 100644 index 0000000000..fdface8ce6 --- /dev/null +++ b/lib/Service/Policy/IPolicyAuthorizationService.php @@ -0,0 +1,33 @@ + + */ + public function getManageablePolicyGroupIds(?IUser $user): array; +} diff --git a/lib/Service/Policy/Model/ActiveGroupScope.php b/lib/Service/Policy/Model/ActiveGroupScope.php new file mode 100644 index 0000000000..b662007b35 --- /dev/null +++ b/lib/Service/Policy/Model/ActiveGroupScope.php @@ -0,0 +1,16 @@ + */ + private array $groups = []; + /** @var list */ + private array $circles = []; + private ?ActiveGroupScope $activeGroupScope = null; + /** @var array */ + private array $requestOverrides = []; + public function __construct() { + $this->actorRole = ActorRole::regularUser(); + } + + public static function fromUserId(string $userId): self { + $context = new self(); + $context->setUserId($userId); + return $context; + } + + public function setUserId(?string $userId): self { + $this->userId = $userId; + return $this; + } + + public function getUserId(): ?string { + return $this->userId; + } + + /** @param list $groups */ + public function setGroups(array $groups): self { + $this->groups = $groups; + return $this; + } + + /** @return list */ + public function getGroups(): array { + return $this->groups; + } + + /** @param list $circles */ + public function setCircles(array $circles): self { + $this->circles = $circles; + return $this; + } + + /** @return list */ + public function getCircles(): array { + return $this->circles; + } + + public function setActiveGroupScope(?ActiveGroupScope $activeGroupScope): self { + $this->activeGroupScope = $activeGroupScope; + return $this; + } + + public function getActiveGroupScope(): ?ActiveGroupScope { + return $this->activeGroupScope; + } + + /** @param array $requestOverrides */ + public function setRequestOverrides(array $requestOverrides): self { + $this->requestOverrides = $requestOverrides; + return $this; + } + + /** @return array */ + public function getRequestOverrides(): array { + return $this->requestOverrides; + } + + public function setActorRole(ActorRole $role): self { + $this->actorRole = $role; + return $this; + } + + public function getActorRole(): ActorRole { + return $this->actorRole; + } +} diff --git a/lib/Service/Policy/Model/PolicyLayer.php b/lib/Service/Policy/Model/PolicyLayer.php new file mode 100644 index 0000000000..9fb72b6107 --- /dev/null +++ b/lib/Service/Policy/Model/PolicyLayer.php @@ -0,0 +1,85 @@ + */ + private array $allowedValues = []; + private bool $createdBySystemAdmin = false; + private bool $delegatedFromSystemCreatedSeed = false; + + public function setScope(string $scope): self { + $this->scope = $scope; + return $this; + } + + public function getScope(): string { + return $this->scope; + } + + public function setValue(mixed $value): self { + $this->value = $value; + return $this; + } + + public function getValue(): mixed { + return $this->value; + } + + public function setAllowChildOverride(bool $allowChildOverride): self { + $this->allowChildOverride = $allowChildOverride; + return $this; + } + + public function isAllowChildOverride(): bool { + return $this->allowChildOverride; + } + + public function setVisibleToChild(bool $visibleToChild): self { + $this->visibleToChild = $visibleToChild; + return $this; + } + + public function isVisibleToChild(): bool { + return $this->visibleToChild; + } + + /** @param list $allowedValues */ + public function setAllowedValues(array $allowedValues): self { + $this->allowedValues = $allowedValues; + return $this; + } + + /** @return list */ + public function getAllowedValues(): array { + return $this->allowedValues; + } + + public function setCreatedBySystemAdmin(bool $value): self { + $this->createdBySystemAdmin = $value; + return $this; + } + + public function isCreatedBySystemAdmin(): bool { + return $this->createdBySystemAdmin; + } + + public function setDelegatedFromSystemCreatedSeed(bool $value): self { + $this->delegatedFromSystemCreatedSeed = $value; + return $this; + } + + public function isDelegatedFromSystemCreatedSeed(): bool { + return $this->delegatedFromSystemCreatedSeed; + } +} diff --git a/lib/Service/Policy/Model/PolicySpec.php b/lib/Service/Policy/Model/PolicySpec.php new file mode 100644 index 0000000000..5b84065e40 --- /dev/null +++ b/lib/Service/Policy/Model/PolicySpec.php @@ -0,0 +1,252 @@ +|Closure(PolicyContext): list */ + private array|Closure $allowedValuesResolver; + /** @var Closure(mixed): mixed|null */ + private ?Closure $normalizer; + /** @var Closure(mixed, PolicyContext): void|null */ + private ?Closure $validator; + /** @var array|Closure(PolicyContext): array */ + private array|Closure $resolvedStateMetaResolver; + /** @var Closure(PolicyContext, ?PolicyLayer): bool|null */ + private ?Closure $visibleGroupCountFilterResolver; + /** @var Closure(PolicyContext, ?PolicyLayer, array): bool|null */ + private ?Closure $groupPolicyManagerResolver; + /** @var Closure(PolicyContext, ?PolicyLayer, PolicyLayer): bool|null */ + private ?Closure $systemCreatedGroupRuleEditorResolver; + /** @var Closure(mixed, mixed, PolicyContext): void|null */ + private ?Closure $delegatedValidator; + + /** + * @param list|Closure(PolicyContext): list $allowedValues + * @param Closure(mixed): mixed|null $normalizer + * @param Closure(mixed, PolicyContext): void|null $validator + * @param array|Closure(PolicyContext): array $resolvedStateMeta + * @param Closure(mixed, mixed, PolicyContext): void|null $delegatedValueValidator + * @param Closure(PolicyContext, ?PolicyLayer): bool|null $visibleGroupCountFilter + * @param Closure(PolicyContext, ?PolicyLayer, array): bool|null $groupPolicyManager + * @param Closure(PolicyContext, ?PolicyLayer, PolicyLayer): bool|null $systemCreatedGroupRuleEditor + * @param list $supportedScopes + * @param list $compositeChildren + */ + public function __construct( + private string $key, + private mixed $defaultSystemValue, + array|Closure $allowedValues, + ?Closure $normalizer = null, + ?Closure $validator = null, + private ?string $appConfigKey = null, + private string $resolutionMode = self::RESOLUTION_MODE_RESOLVED, + private bool $supportsUserPreference = true, + array|Closure $resolvedStateMeta = [], + ?Closure $visibleGroupCountFilter = null, + ?Closure $groupPolicyManager = null, + ?Closure $systemCreatedGroupRuleEditor = null, + private bool $supportsGroupAdminDelegation = false, + ?Closure $delegatedValueValidator = null, + private array $supportedScopes = [self::SCOPE_SYSTEM, self::SCOPE_GROUP, self::SCOPE_USER], + private bool $backendOnly = false, + private bool $helper = false, + private ?string $parentPolicyKey = null, + private array $compositeChildren = [], + ) { + $this->allowedValuesResolver = $allowedValues; + $this->normalizer = $normalizer; + $this->validator = $validator; + $this->resolvedStateMetaResolver = $resolvedStateMeta; + $this->visibleGroupCountFilterResolver = $visibleGroupCountFilter; + $this->groupPolicyManagerResolver = $groupPolicyManager; + $this->systemCreatedGroupRuleEditorResolver = $systemCreatedGroupRuleEditor; + $this->delegatedValidator = $delegatedValueValidator; + } + + #[\Override] + public function key(): string { + return $this->key; + } + + #[\Override] + public function resolutionMode(): string { + return $this->resolutionMode; + } + + /** @return list */ + #[\Override] + public function supportedScopes(): array { + return $this->supportedScopes; + } + + #[\Override] + public function supportsScope(string $scope): bool { + return in_array($scope, $this->supportedScopes, true); + } + + #[\Override] + public function getAppConfigKey(): string { + return $this->appConfigKey ?? $this->key; + } + + #[\Override] + public function getUserPreferenceKey(): string { + return 'policy.' . $this->key; + } + + #[\Override] + public function isBackendOnly(): bool { + return $this->backendOnly; + } + + #[\Override] + public function isHelper(): bool { + return $this->helper; + } + + #[\Override] + public function parentPolicyKey(): ?string { + return $this->parentPolicyKey; + } + + /** @return list */ + #[\Override] + public function compositeChildren(): array { + return $this->compositeChildren; + } + + #[\Override] + public function normalizeValue(mixed $rawValue): mixed { + if ($this->normalizer !== null) { + return ($this->normalizer)($rawValue); + } + + return $rawValue; + } + + #[\Override] + public function validateValue(mixed $value, PolicyContext $context): void { + if ($this->validator !== null) { + ($this->validator)($value, $context); + return; + } + + // Empty allowedValues means "no explicit restriction" for this policy key. + if ($this->allowedValues($context) === []) { + return; + } + + if (!in_array($value, $this->allowedValues($context), true)) { + throw new \InvalidArgumentException(sprintf('Invalid value for %s', $this->key())); + } + } + + #[\Override] + public function allowedValues(PolicyContext $context): array { + if ($this->allowedValuesResolver instanceof Closure) { + return ($this->allowedValuesResolver)($context); + } + + return $this->allowedValuesResolver; + } + + #[\Override] + public function defaultSystemValue(): mixed { + return $this->defaultSystemValue; + } + + /** @return array */ + #[\Override] + public function resolvedStateMeta(PolicyContext $context): array { + if ($this->resolvedStateMetaResolver instanceof Closure) { + return ($this->resolvedStateMetaResolver)($context); + } + + return $this->resolvedStateMetaResolver; + } + + #[\Override] + public function supportsUserPreference(): bool { + return $this->supportsUserPreference; + } + + #[\Override] + public function shouldFilterVisibleGroupCountsForActor(PolicyContext $context, ?PolicyLayer $systemPolicy): bool { + if ($this->visibleGroupCountFilterResolver !== null) { + return ($this->visibleGroupCountFilterResolver)($context, $systemPolicy); + } + + if ($context->getActorRole()->canManageSystemPolicies) { + return false; + } + + return !$this->hasExplicitSystemGroupManagementDelegation($systemPolicy); + } + + /** @param list $groupLayers */ + #[\Override] + public function canCurrentActorManageGroupPolicy(PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + if ($this->groupPolicyManagerResolver !== null) { + return ($this->groupPolicyManagerResolver)($context, $systemPolicy, $groupLayers); + } + + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + return $this->hasExplicitSystemGroupManagementDelegation($systemPolicy); + } + + #[\Override] + public function canCurrentActorEditSystemCreatedGroupPolicy(PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + if ($this->systemCreatedGroupRuleEditorResolver !== null) { + return ($this->systemCreatedGroupRuleEditorResolver)($context, $systemPolicy, $existingPolicy); + } + + return true; + } + + private function hasExplicitSystemGroupManagementDelegation(?PolicyLayer $systemPolicy): bool { + return $systemPolicy instanceof PolicyLayer + && $systemPolicy->getScope() === 'global' + && $systemPolicy->isAllowChildOverride(); + } + + #[\Override] + public function supportsGroupAdminDelegation(): bool { + return $this->supportsGroupAdminDelegation; + } + + #[\Override] + public function validateGroupAdminDelegatedValue( + mixed $proposedNormalizedValue, + mixed $parentSeedNormalizedValue, + PolicyContext $context, + ): void { + if ($this->delegatedValidator !== null) { + ($this->delegatedValidator)($proposedNormalizedValue, $parentSeedNormalizedValue, $context); + } + } +} diff --git a/lib/Service/Policy/Model/ResolvedPolicy.php b/lib/Service/Policy/Model/ResolvedPolicy.php new file mode 100644 index 0000000000..c03933d87e --- /dev/null +++ b/lib/Service/Policy/Model/ResolvedPolicy.php @@ -0,0 +1,186 @@ + */ + private array $allowedValues = []; + private bool $canSaveAsUserDefault = false; + private bool $canUseAsRequestOverride = false; + private bool $preferenceWasCleared = false; + private ?string $blockedBy = null; + /** @var array */ + private array $meta = []; + + public function setPolicyKey(string $policyKey): self { + $this->policyKey = $policyKey; + return $this; + } + + public function getPolicyKey(): string { + return $this->policyKey; + } + + public function setEffectiveValue(mixed $effectiveValue): self { + $this->effectiveValue = $effectiveValue; + return $this; + } + + public function getEffectiveValue(): mixed { + return $this->effectiveValue; + } + + public function getEffectiveValueAsBool(bool $default = false): bool { + if (is_bool($this->effectiveValue)) { + return $this->effectiveValue; + } + + if ($this->effectiveValue === null) { + return $default; + } + + if (is_int($this->effectiveValue)) { + return $this->effectiveValue !== 0; + } + + if (is_float($this->effectiveValue)) { + return $this->effectiveValue !== 0.0; + } + + if (is_string($this->effectiveValue)) { + $parsed = filter_var($this->effectiveValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return $parsed ?? $default; + } + + return $default; + } + + public function setInheritedValue(mixed $inheritedValue): self { + $this->inheritedValue = $inheritedValue; + return $this; + } + + public function getInheritedValue(): mixed { + return $this->inheritedValue; + } + + public function setSourceScope(string $sourceScope): self { + $this->sourceScope = $sourceScope; + return $this; + } + + public function getSourceScope(): string { + return $this->sourceScope; + } + + public function setVisible(bool $visible): self { + $this->visible = $visible; + return $this; + } + + public function isVisible(): bool { + return $this->visible; + } + + public function setEditableByCurrentActor(bool $editableByCurrentActor): self { + $this->editableByCurrentActor = $editableByCurrentActor; + return $this; + } + + public function isEditableByCurrentActor(): bool { + return $this->editableByCurrentActor; + } + + /** @param list $allowedValues */ + public function setAllowedValues(array $allowedValues): self { + $this->allowedValues = $allowedValues; + return $this; + } + + /** @return list */ + public function getAllowedValues(): array { + return $this->allowedValues; + } + + public function setCanSaveAsUserDefault(bool $canSaveAsUserDefault): self { + $this->canSaveAsUserDefault = $canSaveAsUserDefault; + return $this; + } + + public function canSaveAsUserDefault(): bool { + return $this->canSaveAsUserDefault; + } + + public function setCanUseAsRequestOverride(bool $canUseAsRequestOverride): self { + $this->canUseAsRequestOverride = $canUseAsRequestOverride; + return $this; + } + + public function canUseAsRequestOverride(): bool { + return $this->canUseAsRequestOverride; + } + + public function setPreferenceWasCleared(bool $preferenceWasCleared): self { + $this->preferenceWasCleared = $preferenceWasCleared; + return $this; + } + + public function wasPreferenceCleared(): bool { + return $this->preferenceWasCleared; + } + + public function setBlockedBy(?string $blockedBy): self { + $this->blockedBy = $blockedBy; + return $this; + } + + public function getBlockedBy(): ?string { + return $this->blockedBy; + } + + /** @param array $meta */ + public function setMeta(array $meta): self { + $this->meta = $meta; + return $this; + } + + /** @return array */ + public function getMeta(): array { + return $this->meta; + } + + /** @return array */ + public function toArray(): array { + $payload = [ + 'policyKey' => $this->getPolicyKey(), + 'effectiveValue' => $this->getEffectiveValue(), + 'inheritedValue' => $this->getInheritedValue(), + 'sourceScope' => $this->getSourceScope(), + 'visible' => $this->isVisible(), + 'editableByCurrentActor' => $this->isEditableByCurrentActor(), + 'allowedValues' => $this->getAllowedValues(), + 'canSaveAsUserDefault' => $this->canSaveAsUserDefault(), + 'canUseAsRequestOverride' => $this->canUseAsRequestOverride(), + 'preferenceWasCleared' => $this->wasPreferenceCleared(), + 'blockedBy' => $this->getBlockedBy(), + ]; + + if ($this->getMeta() !== []) { + $payload['meta'] = $this->getMeta(); + } + + return $payload; + } +} diff --git a/lib/Service/Policy/PolicyAuthorizationService.php b/lib/Service/Policy/PolicyAuthorizationService.php new file mode 100644 index 0000000000..62bf1a540a --- /dev/null +++ b/lib/Service/Policy/PolicyAuthorizationService.php @@ -0,0 +1,104 @@ +groupManager->isAdmin($user->getUID()) + || $this->subAdmin->isSubAdmin($user); + } + + /** + * Get list of group IDs manageable by the given user. + * + * For instance admins: returns empty (all groups are manageable). + * For subadmins: returns all groups they subadmin-manage, but only after the + * request-sign policy itself has been explicitly delegated by the system + * administrator (exposed as editable for the current actor). + * For regular users: returns empty. + * + * @return list + */ + #[\Override] + public function getManageablePolicyGroupIds(?IUser $user): array { + if ($user === null) { + return []; + } + + // Instance admins do not need a restricted group list + // (they have access to all groups at the policy layer) + if ($this->groupManager->isAdmin($user->getUID())) { + return []; + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return []; + } + + $managedGroupIds = array_values(array_unique(array_filter(array_map( + static fn (IGroup $group): string => trim($group->getGID()), + $this->subAdmin->getSubAdminsGroups($user), + ), static fn (string $groupId): bool => $groupId !== ''))); + + if ($managedGroupIds === []) { + return []; + } + + $requestSignPolicy = $this->policyService->resolveForUser(RequestSignGroupsPolicy::KEY, $user); + if (!$requestSignPolicy->isEditableByCurrentActor()) { + foreach ($this->policyService->listGroupPoliciesForTargets(RequestSignGroupsPolicy::KEY, $managedGroupIds) as $record) { + $policy = $record['policy'] ?? null; + if (!$policy instanceof PolicyLayer) { + continue; + } + + if (!$this->wasGroupPolicyCreatedBySystemAdmin($policy)) { + return $managedGroupIds; + } + + if ($policy->isAllowChildOverride()) { + return $managedGroupIds; + } + } + + return []; + } + + return $managedGroupIds; + } + + private function wasGroupPolicyCreatedBySystemAdmin(PolicyLayer $policy): bool { + return $policy->isCreatedBySystemAdmin(); + } +} diff --git a/lib/Service/Policy/PolicyManagementScopeService.php b/lib/Service/Policy/PolicyManagementScopeService.php new file mode 100644 index 0000000000..a0fd78977f --- /dev/null +++ b/lib/Service/Policy/PolicyManagementScopeService.php @@ -0,0 +1,209 @@ + + */ + public function resolveVisibleRuleCountsForCurrentActor(): array { + return $this->resolveVisibleRuleCountsForActor($this->userSession->getUser()); + } + + public function canCurrentActorManageGroupPolicy(string $groupId, string $policyKey): bool { + return $this->canManageGroupPolicy($this->userSession->getUser(), $groupId, $policyKey); + } + + public function canCurrentActorManageUserPolicy(string $userId): bool { + return $this->canManageUserPolicy($this->userSession->getUser(), $userId); + } + + public function canCurrentActorManageScopedUserPolicy(string $userId, string $policyKey): bool { + $user = $this->userSession->getUser(); + + return $this->canManageUserPolicy($user, $userId) + && $this->policyService->canManageUserPolicyForUserId($policyKey, $userId); + } + + /** + * @return list|null Null means the current actor can manage all groups. + */ + public function resolveCurrentActorManageableGroupIdsForPolicy(string $policyKey): ?array { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return []; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return null; + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return []; + } + + return $this->resolveManageableGroupIdsForPolicy($user, $policyKey); + } + + private function canManageGroupPolicy(?IUser $user, string $groupId, string $policyKey): bool { + if (!$user instanceof IUser) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return false; + } + + return in_array($groupId, $this->resolveManageableGroupIdsForPolicy($user, $policyKey), true); + } + + private function canManageUserPolicy(?IUser $user, string $userId): bool { + if (!$user instanceof IUser) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return false; + } + + $targetUser = $this->userManager->get($userId); + if (!$targetUser instanceof IUser) { + return false; + } + + $managedGroupIds = array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + if ($managedGroupIds === []) { + return false; + } + + $targetGroupIds = $this->groupManager->getUserGroupIds($targetUser); + return array_intersect($managedGroupIds, $targetGroupIds) !== []; + } + + /** + * @return array + */ + private function resolveVisibleRuleCountsForActor(?IUser $user): array { + if ($user === null) { + return []; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return $this->policyService->getAllRuleCounts(); + } + + if ($this->subAdmin->isSubAdmin($user)) { + $groupIds = $this->resolveSubAdminManagedGroupIds($user); + return $this->filterVisibleRuleCountsForManagedGroups( + $this->policyService->getRuleCounts($groupIds, []), + $user, + $groupIds, + ); + } + + return []; + } + + /** + * @param array $ruleCounts + * @param list $groupIds + * @return array + */ + private function filterVisibleRuleCountsForManagedGroups(array $ruleCounts, IUser $user, array $groupIds): array { + /** @var array $normalizedRuleCounts */ + $normalizedRuleCounts = []; + foreach ($ruleCounts as $policyKey => $counts) { + $normalizedRuleCounts[$policyKey] = $this->normalizeRuleCounts($counts); + } + + $groupIds = array_values(array_unique(array_filter( + $groupIds, + static fn (string $groupId): bool => trim($groupId) !== '', + ))); + + if ($groupIds === []) { + return $normalizedRuleCounts; + } + + foreach ($normalizedRuleCounts as $policyKey => $counts) { + if (($counts['groupCount'] ?? 0) <= 0) { + continue; + } + + if (!$this->policyService->shouldFilterVisibleGroupCountsForCurrentActor($policyKey)) { + continue; + } + + $counts['groupCount'] = $this->policyService->countVisibleGroupPoliciesForTargets( + $policyKey, + $this->resolveManageableGroupIdsForPolicy($user, $policyKey), + ); + $normalizedRuleCounts[$policyKey] = $counts; + } + + return $normalizedRuleCounts; + } + + /** + * @param array{groupCount?: int, userCount?: int, everyoneCount?: int} $counts + * @return array{groupCount: int, userCount: int, everyoneCount: int} + */ + private function normalizeRuleCounts(array $counts): array { + return [ + 'groupCount' => (int)($counts['groupCount'] ?? 0), + 'userCount' => (int)($counts['userCount'] ?? 0), + 'everyoneCount' => (int)($counts['everyoneCount'] ?? 0), + ]; + } + + /** @return list */ + private function resolveSubAdminManagedGroupIds(IUser $user): array { + return array_values(array_filter( + $this->groupManager->getUserGroupIds($user), + static fn (mixed $groupId): bool => is_string($groupId) && trim($groupId) !== '', + )); + } + + /** @return list */ + private function resolveManageableGroupIdsForPolicy(IUser $user, string $policyKey): array { + if ($policyKey === RequestSignGroupsPolicy::KEY) { + return $this->policyAuthorizationService->getManageablePolicyGroupIds($user); + } + + return $this->resolveSubAdminManagedGroupIds($user); + } +} diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php new file mode 100644 index 0000000000..8e654eafa0 --- /dev/null +++ b/lib/Service/Policy/PolicyService.php @@ -0,0 +1,509 @@ +resolver = new DefaultPolicyResolver($this->source); + } + + /** @param array $requestOverrides */ + public function resolve(string|\BackedEnum $policyKey, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + return $this->resolver->resolve( + $this->registry->get($policyKey), + $this->contextFactory->forCurrentUser($requestOverrides, $activeContext), + ); + } + + /** @param array $requestOverrides */ + public function resolveForUserId(string|\BackedEnum $policyKey, ?string $userId, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + return $this->resolver->resolve( + $this->registry->get($policyKey), + $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext), + ); + } + + /** @param array $requestOverrides */ + public function resolveForUser(string|\BackedEnum $policyKey, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + return $this->resolver->resolve( + $this->registry->get($policyKey), + $this->contextFactory->forUser($user, $requestOverrides, $activeContext), + ); + } + + /** @return array */ + public function resolveKnownPolicies(array $requestOverrides = [], ?array $activeContext = null): array { + return $this->resolveKnownPoliciesForContext( + $this->contextFactory->forCurrentUser($requestOverrides, $activeContext), + ); + } + + /** @return array */ + public function resolveKnownPoliciesForUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): array { + return $this->resolveKnownPoliciesForContext( + $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext), + ); + } + + /** @return array> */ + public function resolveKnownPolicyStates(array $requestOverrides = [], ?array $activeContext = null): array { + return $this->serializeResolvedPolicies($this->resolveKnownPolicies($requestOverrides, $activeContext)); + } + + /** @return array> */ + public function resolveKnownPolicyStatesForUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): array { + return $this->serializeResolvedPolicies($this->resolveKnownPoliciesForUserId($userId, $requestOverrides, $activeContext)); + } + + /** + * Resolve requester-facing policy states using the target user's group membership, + * while intentionally ignoring user-specific layers such as assigned user policies + * and personal preferences. + * + * This is used by public validation pages, which should reflect the requester's + * inherited group/system policy posture without exposing user-scoped overrides. + * + * @return array> + */ + public function resolveKnownPolicyStatesForUserIdWithoutUserScope(?string $userId, array $requestOverrides = [], ?array $activeContext = null): array { + $context = $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext); + $context->setUserId(null); + + return $this->serializeResolvedPolicies( + $this->resolveKnownPoliciesForContext($context), + ); + } + + /** + * @param array $ruleCounts + * @return array> + */ + public function resolveKnownPolicyStatesWithRuleCounts(array $ruleCounts, array $requestOverrides = [], ?array $activeContext = null): array { + return $this->serializeResolvedPolicies( + $this->resolveKnownPolicies($requestOverrides, $activeContext), + $ruleCounts, + ); + } + + public function getSystemPolicy(string|\BackedEnum $policyKey): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + return $this->source->loadSystemPolicy($definition->key()); + } + + public function getUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_USER)) { + return null; + } + + return $this->source->loadUserPolicyConfig($definition->key(), $userId); + } + + /** + * @return list + */ + public function listUserPolicies(string|\BackedEnum $policyKey): array { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_USER)) { + return []; + } + + return $this->source->listUserPoliciesByKey($definition->key()); + } + + public function saveSystem(string|\BackedEnum $policyKey, mixed $value, bool $allowChildOverride = false): ResolvedPolicy { + $context = $this->contextFactory->forCurrentUser(); + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_SYSTEM); + $normalizedValue = $value === null + ? $definition->normalizeValue($definition->defaultSystemValue()) + : $definition->normalizeValue($value); + + $definition->validateValue($normalizedValue, $context); + $this->source->saveSystemPolicy($definition->key(), $normalizedValue, $allowChildOverride); + + return $this->resolver->resolve( + $definition, + $this->contextFactory->forUserId(null), + ); + } + + public function clearSystem(string|\BackedEnum $policyKey): ResolvedPolicy { + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_SYSTEM); + $this->source->clearSystemPolicy($definition->key()); + + return $this->resolver->resolve( + $definition, + $this->contextFactory->forUserId(null), + ); + } + + public function getGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) { + return null; + } + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + + /** + * @return list + */ + public function listGroupPolicies(string|\BackedEnum $policyKey): array { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) { + return []; + } + + return $this->source->listGroupPoliciesByKey($definition->key()); + } + + /** + * @param list $groupIds + * @return list + */ + public function listGroupPoliciesForTargets(string|\BackedEnum $policyKey, array $groupIds): array { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) { + return []; + } + + return $this->source->listGroupPoliciesByKeyForTargets($definition->key(), $groupIds); + } + + /** + * @param list $groupIds + */ + public function countVisibleGroupPoliciesForTargets(string|\BackedEnum $policyKey, array $groupIds): int { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) { + return 0; + } + + $visibleCount = 0; + + foreach ($this->source->listGroupPoliciesByKeyForTargets($definition->key(), $groupIds) as $record) { + $groupId = (string)($record['targetId'] ?? ''); + $policy = $record['policy'] ?? null; + if ($groupId === '' || !$policy instanceof PolicyLayer) { + continue; + } + + if (!$this->canViewGroupPolicy($definition->key(), $groupId, $policy)) { + continue; + } + + $visibleCount++; + } + + return $visibleCount; + } + + public function saveGroupPolicy(string|\BackedEnum $policyKey, string $groupId, mixed $value, bool $allowChildOverride): PolicyLayer { + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_GROUP); + $context = $this->contextFactory->forCurrentUser(); + $this->assertCurrentActorCanManageGroupPolicy($definition->key(), $context); + $this->assertCurrentActorCanEditGroupPolicy($definition->key(), $groupId, $context); + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $createdBySystemAdmin = $context->getActorRole()->canManageSystemPolicies; + $this->source->saveGroupPolicy( + $definition->key(), + $groupId, + $normalizedValue, + $allowChildOverride, + $createdBySystemAdmin, + $context, + ); + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId) + ?? (new PolicyLayer()) + ->setScope('group') + ->setVisibleToChild(true) + ->setAllowChildOverride(true) + ->setAllowedValues([]); + } + + public function clearGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_GROUP); + $this->assertCurrentActorCanDeleteGroupPolicy($definition->key(), $groupId); + $this->source->clearGroupPolicy( + $definition->key(), + $groupId, + !$this->contextFactory->isCurrentActorSystemAdmin(), + ); + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + + public function canDeleteGroupPolicy(string|\BackedEnum $policyKey, string $groupId, ?PolicyLayer $policy = null): bool { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) { + return false; + } + + if ($this->contextFactory->isCurrentActorSystemAdmin()) { + return true; + } + + $groupPolicy = $policy ?? $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + if (!$groupPolicy instanceof PolicyLayer) { + return false; + } + + return !$groupPolicy->isCreatedBySystemAdmin(); + } + + public function canViewGroupPolicy(string|\BackedEnum $policyKey, string $groupId, ?PolicyLayer $policy = null): bool { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) { + return false; + } + + if ($this->contextFactory->isCurrentActorSystemAdmin()) { + return true; + } + + $groupPolicy = $policy ?? $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + if (!$groupPolicy instanceof PolicyLayer) { + return true; + } + + return !$this->shouldHideSystemCreatedGroupRuleFromCurrentActor($definition->key(), $groupPolicy); + } + + public function shouldFilterVisibleGroupCountsForCurrentActor(string|\BackedEnum $policyKey): bool { + $definition = $this->registry->get($policyKey); + return $definition->shouldFilterVisibleGroupCountsForActor( + $this->contextFactory->forCurrentUser(), + $this->source->loadSystemPolicy($definition->key()), + ); + } + + public function canManageUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): bool { + $definition = $this->registry->get($policyKey); + if (!$definition->supportsScope(PolicySpec::SCOPE_USER)) { + return false; + } + + if ($this->contextFactory->isCurrentActorSystemAdmin()) { + return true; + } + + $resolved = $this->resolver->resolve( + $definition, + $this->contextFactory->forUserId($userId), + ); + + return $resolved->canSaveAsUserDefault() + || (($resolved->getMeta()['canCreateDescendantRules'] ?? false) === true); + } + + private function assertCurrentActorCanDeleteGroupPolicy(string $policyKey, string $groupId): void { + if ($this->canDeleteGroupPolicy($policyKey, $groupId)) { + return; + } + + throw new \DomainException($this->l10n->t('Only system administrators can delete group rules created by a system administrator')); + } + + private function assertCurrentActorCanEditGroupPolicy(string $policyKey, string $groupId, ?PolicyContext $context = null): void { + $context ??= $this->contextFactory->forCurrentUser(); + if ($context->getActorRole()->canManageSystemPolicies) { + return; + } + + $definition = $this->registry->get($policyKey); + $existingPolicy = $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + if (!$existingPolicy instanceof PolicyLayer) { + return; + } + + if (!$this->wasGroupPolicyCreatedBySystemAdmin($existingPolicy)) { + return; + } + + if ($definition->canCurrentActorEditSystemCreatedGroupPolicy( + $context, + $this->source->loadSystemPolicy($definition->key()), + $existingPolicy, + )) { + return; + } + + throw new \DomainException($this->l10n->t('Only system administrators can edit group access rules created by a system administrator')); + } + + private function wasGroupPolicyCreatedBySystemAdmin(PolicyLayer $policy): bool { + return $policy->isCreatedBySystemAdmin(); + } + + private function shouldHideSystemCreatedGroupRuleFromCurrentActor(string $policyKey, PolicyLayer $policy): bool { + if (!$this->wasGroupPolicyCreatedBySystemAdmin($policy)) { + return false; + } + + return $this->shouldFilterVisibleGroupCountsForCurrentActor($policyKey); + } + + private function assertCurrentActorCanManageGroupPolicy(string $policyKey, ?PolicyContext $context = null): void { + $context ??= $this->contextFactory->forCurrentUser(); + if ($context->getActorRole()->canManageSystemPolicies) { + return; + } + + $definition = $this->registry->get($policyKey); + if (!$this->canCurrentActorManageGroupPolicy($definition, $context)) { + throw new \DomainException($this->l10n->t('Group policy management requires explicit delegation from the system administrator')); + } + } + + private function canCurrentActorManageGroupPolicy(IPolicyDefinition $definition, PolicyContext $context): bool { + return $definition->canCurrentActorManageGroupPolicy( + $context, + $this->source->loadSystemPolicy($definition->key()), + $this->source->loadGroupPolicies($definition->key(), $context), + ); + } + + private function assertScopeSupported(IPolicyDefinition $definition, string $scope): void { + if ($definition->supportsScope($scope)) { + return; + } + + $scopeLabel = match ($scope) { + PolicySpec::SCOPE_SYSTEM => 'System', + PolicySpec::SCOPE_GROUP => 'Group', + PolicySpec::SCOPE_USER => 'User', + default => ucfirst($scope), + }; + + throw new \InvalidArgumentException($this->l10n->t('%s-level scope is not supported for this policy', [$scopeLabel])); + } + + public function saveUserPreference(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy { + $context = $this->contextFactory->forCurrentUser(); + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER); + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $resolved = $this->resolver->resolve($definition, $context); + if (!$resolved->canSaveAsUserDefault()) { + throw new \InvalidArgumentException($this->l10n->t('Saving a user preference is not allowed for {policyKey}', [ + 'policyKey' => $definition->key(), + ])); + } + + $this->source->saveUserPreference($definition->key(), $context, $normalizedValue); + + return $this->resolver->resolve($definition, $context); + } + + public function clearUserPreference(string|\BackedEnum $policyKey): ResolvedPolicy { + $context = $this->contextFactory->forCurrentUser(); + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER); + $this->source->clearUserPreference($definition->key(), $context); + + return $this->resolver->resolve($definition, $context); + } + + public function saveUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId, mixed $value, bool $allowChildOverride): ?PolicyLayer { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER); + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $this->source->saveUserPolicy($definition->key(), $context, $normalizedValue, $allowChildOverride); + + return $this->source->loadUserPolicy($definition->key(), $context) + ?? (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($normalizedValue) + ->setAllowChildOverride($allowChildOverride) + ->setVisibleToChild(true); + } + + public function clearUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER); + $this->source->clearUserPolicy($definition->key(), $context); + + return $this->source->loadUserPolicy($definition->key(), $context); + } + + /** + * @param list $groupIds + * @param list $userIds + * @return array + */ + public function getRuleCounts(array $groupIds, array $userIds): array { + return $this->source->loadRuleCounts($groupIds, $userIds); + } + + /** @return array */ + public function getAllRuleCounts(): array { + return $this->source->loadAllRuleCounts(); + } + + /** + * @param array $resolvedPolicies + * @param null|array $ruleCounts + * @return array> + */ + private function serializeResolvedPolicies(array $resolvedPolicies, ?array $ruleCounts = null): array { + $states = []; + foreach ($resolvedPolicies as $policyKey => $resolvedPolicy) { + $policyState = $resolvedPolicy->toArray(); + if ($ruleCounts !== null) { + $policyState['groupCount'] = $ruleCounts[$policyKey]['groupCount'] ?? 0; + $policyState['userCount'] = $ruleCounts[$policyKey]['userCount'] ?? 0; + $policyState['everyoneCount'] = $ruleCounts[$policyKey]['everyoneCount'] ?? 0; + } + + $states[$policyKey] = $policyState; + } + + return $states; + } + + /** @return array */ + private function resolveKnownPoliciesForContext(PolicyContext $context): array { + $definitions = []; + foreach ($this->registry->getAllPolicyKeys() as $policyKey) { + $definitions[] = $this->registry->get($policyKey); + } + + return $this->resolver->resolveMany($definitions, $context); + } +} diff --git a/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php new file mode 100644 index 0000000000..9730af8023 --- /dev/null +++ b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php @@ -0,0 +1,54 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: ApprovalGroupsPolicyValue::encode(ApprovalGroupsPolicyValue::DEFAULT_GROUPS), + allowedValues: static fn (PolicyContext $context): array => [], + normalizer: static fn (mixed $rawValue): mixed => ApprovalGroupsPolicyValue::encode($rawValue), + validator: static function (mixed $value): void { + if (!is_string($value)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + $decoded = ApprovalGroupsPolicyValue::decode($value); + if ($decoded === []) { + throw new \InvalidArgumentException('At least one authorized group is required for ' . self::KEY); + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + supportedScopes: [PolicySpec::SCOPE_SYSTEM], + backendOnly: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } +} diff --git a/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicyValue.php b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicyValue.php new file mode 100644 index 0000000000..a714a9eca7 --- /dev/null +++ b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicyValue.php @@ -0,0 +1,66 @@ + */ + public const DEFAULT_GROUPS = ['admin']; + + /** @return list */ + public static function decode(mixed $rawValue): array { + if (is_array($rawValue)) { + return self::normalizeGroupIds($rawValue); + } + + if (!is_string($rawValue)) { + return []; + } + + $trimmed = trim($rawValue); + if ($trimmed === '') { + return []; + } + + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + return self::normalizeGroupIds($decoded); + } + + return self::normalizeGroupIds(array_map('trim', explode(',', $trimmed))); + } + + public static function encode(mixed $rawValue): string { + return json_encode(self::decode($rawValue), JSON_THROW_ON_ERROR); + } + + /** + * @param array $rawGroups + * @return list + */ + private static function normalizeGroupIds(array $rawGroups): array { + $normalized = []; + foreach ($rawGroups as $groupId) { + if (!is_string($groupId)) { + continue; + } + + $trimmed = trim($groupId); + if ($trimmed === '') { + continue; + } + + $normalized[] = $trimmed; + } + + $unique = array_values(array_unique($normalized)); + sort($unique); + + return $unique; + } +} diff --git a/lib/Service/Policy/Provider/CollectMetadata/CollectMetadataPolicy.php b/lib/Service/Policy/Provider/CollectMetadata/CollectMetadataPolicy.php new file mode 100644 index 0000000000..80a5831742 --- /dev/null +++ b/lib/Service/Policy/Provider/CollectMetadata/CollectMetadataPolicy.php @@ -0,0 +1,103 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: false, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportedScopes: [ + PolicySpec::SCOPE_SYSTEM, + PolicySpec::SCOPE_GROUP, + PolicySpec::SCOPE_USER, + ], + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild()) { + return false; + } + + if (!$existingPolicy->isAllowChildOverride()) { + return false; + } + + if ($existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/Confetti/ConfettiPolicy.php b/lib/Service/Policy/Provider/Confetti/ConfettiPolicy.php new file mode 100644 index 0000000000..20b3d71bfa --- /dev/null +++ b/lib/Service/Policy/Provider/Confetti/ConfettiPolicy.php @@ -0,0 +1,103 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: true, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportedScopes: [ + PolicySpec::SCOPE_SYSTEM, + PolicySpec::SCOPE_GROUP, + PolicySpec::SCOPE_USER, + ], + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild()) { + return false; + } + + if (!$existingPolicy->isAllowChildOverride()) { + return false; + } + + if ($existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/CrlValidation/CrlValidationPolicy.php b/lib/Service/Policy/Provider/CrlValidation/CrlValidationPolicy.php new file mode 100644 index 0000000000..394f63a644 --- /dev/null +++ b/lib/Service/Policy/Provider/CrlValidation/CrlValidationPolicy.php @@ -0,0 +1,103 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: true, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportedScopes: [ + PolicySpec::SCOPE_SYSTEM, + PolicySpec::SCOPE_GROUP, + ], + supportsUserPreference: false, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild()) { + return false; + } + + if (!$existingPolicy->isAllowChildOverride()) { + return false; + } + + if ($existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/DefaultUserFolder/DefaultUserFolderPolicy.php b/lib/Service/Policy/Provider/DefaultUserFolder/DefaultUserFolderPolicy.php new file mode 100644 index 0000000000..6787a27287 --- /dev/null +++ b/lib/Service/Policy/Provider/DefaultUserFolder/DefaultUserFolderPolicy.php @@ -0,0 +1,86 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: self::DEFAULT_FOLDER, + allowedValues: [], + normalizer: static function (mixed $rawValue): string { + $candidate = trim((string)$rawValue); + return $candidate !== '' ? $candidate : self::DEFAULT_FOLDER; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php b/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php new file mode 100644 index 0000000000..10873594d2 --- /dev/null +++ b/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php @@ -0,0 +1,60 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: DocMdpLevel::NOT_CERTIFIED->value, + allowedValues: [ + DocMdpLevel::NOT_CERTIFIED->value, + DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED->value, + DocMdpLevel::CERTIFIED_FORM_FILLING->value, + DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value, + ], + normalizer: static function (mixed $rawValue): mixed { + if ($rawValue instanceof DocMdpLevel) { + return $rawValue->value; + } + + if (is_int($rawValue)) { + return $rawValue; + } + + if (is_numeric($rawValue)) { + return (int)$rawValue; + } + + return $rawValue; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } +} diff --git a/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php b/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php new file mode 100644 index 0000000000..9a4e420a13 --- /dev/null +++ b/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php @@ -0,0 +1,58 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(DocMdpPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(DocMdpPolicy::KEY, $user, $requestOverrides, $activeContext); + $file->setDocmdpLevelEnum(DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(DocMdpPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(DocMdpPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $newLevel = DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED; + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($file->getDocmdpLevelEnum() !== $newLevel || $metadataChanged) { + $file->setDocmdpLevelEnum($newLevel); + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** @return array */ + private function getOverrides(array $data): array { + return $this->extractSinglePolicyOverride($data, DocMdpPolicy::KEY); + } +} diff --git a/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php b/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php new file mode 100644 index 0000000000..4d4e5f54ea --- /dev/null +++ b/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php @@ -0,0 +1,103 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: true, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportedScopes: [ + PolicySpec::SCOPE_SYSTEM, + PolicySpec::SCOPE_GROUP, + PolicySpec::SCOPE_USER, + ], + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild()) { + return false; + } + + if (!$existingPolicy->isAllowChildOverride()) { + return false; + } + + if ($existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/ExpirationRules/ExpirationRulesPolicy.php b/lib/Service/Policy/Provider/ExpirationRules/ExpirationRulesPolicy.php new file mode 100644 index 0000000000..080c052405 --- /dev/null +++ b/lib/Service/Policy/Provider/ExpirationRules/ExpirationRulesPolicy.php @@ -0,0 +1,196 @@ + $this->buildDelegableNonNegativeIntPolicy( + self::KEY_MAXIMUM_VALIDITY, + self::DEFAULT_MAXIMUM_VALIDITY, + compositeChildren: [self::KEY_RENEWAL_INTERVAL], + ), + self::KEY_RENEWAL_INTERVAL => $this->buildDelegableNonNegativeIntPolicy( + self::KEY_RENEWAL_INTERVAL, + self::DEFAULT_RENEWAL_INTERVAL, + helper: true, + parentPolicyKey: self::KEY_MAXIMUM_VALIDITY, + ), + self::KEY_EXPIRY_IN_DAYS => $this->buildDelegablePositiveIntPolicy( + self::KEY_EXPIRY_IN_DAYS, + self::DEFAULT_EXPIRY_IN_DAYS, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey), + }; + } + + private function buildDelegableNonNegativeIntPolicy( + string $key, + int $defaultValue, + array $compositeChildren = [], + bool $helper = false, + ?string $parentPolicyKey = null, + ): PolicySpec { + return $this->buildDelegableIntPolicy( + key: $key, + defaultValue: $defaultValue, + normalizer: static fn (mixed $rawValue): int => self::normalizeNonNegativeInt($rawValue, $defaultValue), + supportsUserPreference: false, + compositeChildren: $compositeChildren, + helper: $helper, + parentPolicyKey: $parentPolicyKey, + ); + } + + private function buildDelegablePositiveIntPolicy( + string $key, + int $defaultValue, + array $compositeChildren = [], + bool $helper = false, + ?string $parentPolicyKey = null, + ): PolicySpec { + return $this->buildDelegableIntPolicy( + key: $key, + defaultValue: $defaultValue, + normalizer: static fn (mixed $rawValue): int => self::normalizePositiveInt($rawValue, $defaultValue), + supportsUserPreference: false, + compositeChildren: $compositeChildren, + helper: $helper, + parentPolicyKey: $parentPolicyKey, + ); + } + + private function buildDelegableIntPolicy( + string $key, + int $defaultValue, + \Closure $normalizer, + bool $supportsUserPreference = true, + array $compositeChildren = [], + bool $helper = false, + ?string $parentPolicyKey = null, + ): PolicySpec { + return new PolicySpec( + key: $key, + defaultSystemValue: $defaultValue, + allowedValues: [], + normalizer: $normalizer, + appConfigKey: $key, + supportsUserPreference: $supportsUserPreference, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + compositeChildren: $compositeChildren, + helper: $helper, + parentPolicyKey: $parentPolicyKey, + ); + } + + private static function normalizeNonNegativeInt(mixed $rawValue, int $fallback): int { + $parsed = self::parseInt($rawValue); + if ($parsed === null) { + return $fallback; + } + + return max(0, $parsed); + } + + private static function normalizePositiveInt(mixed $rawValue, int $fallback): int { + $parsed = self::parseInt($rawValue); + if ($parsed === null || $parsed <= 0) { + return $fallback; + } + + return $parsed; + } + + private static function parseInt(mixed $rawValue): ?int { + if (is_int($rawValue)) { + return $rawValue; + } + + if (is_float($rawValue) && is_finite($rawValue)) { + return (int)$rawValue; + } + + if (is_string($rawValue)) { + $trimmed = trim($rawValue); + if ($trimmed === '' || !is_numeric($trimmed)) { + return null; + } + + return (int)$trimmed; + } + + return null; + } + +} diff --git a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php new file mode 100644 index 0000000000..66774bf5c3 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php @@ -0,0 +1,62 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(FooterPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(FooterPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return false; + } + + /** @return array */ + private function getOverrides(array $data): array { + return $this->extractSinglePolicyOverride($data, FooterPolicy::KEY); + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + $this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, 'Footer template override is blocked by %s.'); + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicy.php b/lib/Service/Policy/Provider/Footer/FooterPolicy.php new file mode 100644 index 0000000000..cbbb64caa3 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicy.php @@ -0,0 +1,123 @@ +getDefaultTemplate(); + $defaultSystemValue = FooterPolicyValue::encode(FooterPolicyValue::defaults()); + $resolvedStateDefault = FooterPolicyValue::encode(FooterPolicyValue::defaults($defaultTemplate), $defaultTemplate); + + return match (PolicyKeyNormalizer::normalize($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: $defaultSystemValue, + allowedValues: static fn (): array => [], + normalizer: static function (mixed $rawValue): mixed { + return FooterPolicyValue::encode(FooterPolicyValue::normalize($rawValue)); + }, + validator: static function (mixed $value, PolicyContext $context): void { + if (!is_string($value) || trim($value) === '') { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + if (!self::canManageTechnicalFooterSettings($context)) { + $normalized = FooterPolicyValue::normalize($decoded); + if ($normalized['validationSite'] !== '') { + throw new \InvalidArgumentException('Validation URL override is not allowed for this actor'); + } + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + resolvedStateMeta: static fn (PolicyContext $_context): array => [ + 'defaultSystemValue' => $resolvedStateDefault, + ], + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + + private function getDefaultTemplate(): string { + if ($this->defaultTemplate !== null) { + return $this->defaultTemplate; + } + + return $this->defaultTemplate = (string)file_get_contents(FooterHandler::DEFAULT_TEMPLATE_PATH); + } + + private static function canManageTechnicalFooterSettings(PolicyContext $context): bool { + $actorRole = $context->getActorRole(); + + return $actorRole->canManageSystemPolicies || $actorRole->canManageGroupPolicies; + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php new file mode 100644 index 0000000000..5a390bcf1c --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php @@ -0,0 +1,128 @@ + true, + 'writeQrcodeOnFooter' => true, + 'validationSite' => '', + 'customizeFooterTemplate' => false, + 'footerTemplate' => $defaultTemplate, + 'previewWidth' => 595, + 'previewHeight' => 100, + 'previewZoom' => 100, + ]; + } + + /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ + public static function normalize(mixed $rawValue, string $defaultTemplate = ''): array { + $defaults = self::defaults($defaultTemplate); + + if (is_array($rawValue)) { + $normalized = [ + 'enabled' => self::toBool($rawValue['enabled'] ?? $rawValue['addFooter'] ?? $defaults['enabled']), + 'writeQrcodeOnFooter' => self::toBool($rawValue['writeQrcodeOnFooter'] ?? $rawValue['write_qrcode_on_footer'] ?? $defaults['writeQrcodeOnFooter']), + 'validationSite' => self::toString($rawValue['validationSite'] ?? $rawValue['validation_site'] ?? $defaults['validationSite']), + 'customizeFooterTemplate' => self::toBool($rawValue['customizeFooterTemplate'] ?? $rawValue['customize_footer_template'] ?? $defaults['customizeFooterTemplate']), + 'footerTemplate' => self::toTemplateString($rawValue['footerTemplate'] ?? $rawValue['footer_template'] ?? $defaults['footerTemplate']), + 'previewWidth' => self::toInt($rawValue['previewWidth'] ?? $rawValue['preview_width'] ?? null, $defaults['previewWidth']), + 'previewHeight' => self::toInt($rawValue['previewHeight'] ?? $rawValue['preview_height'] ?? null, $defaults['previewHeight']), + 'previewZoom' => self::toInt($rawValue['previewZoom'] ?? $rawValue['preview_zoom'] ?? null, $defaults['previewZoom']), + ]; + + return $normalized; + } + + if (is_bool($rawValue) || is_int($rawValue)) { + $defaults['enabled'] = self::toBool($rawValue); + return $defaults; + } + + if (is_string($rawValue)) { + $trimmedValue = trim($rawValue); + if ($trimmedValue === '') { + return $defaults; + } + + $decoded = json_decode($trimmedValue, true); + if (is_array($decoded)) { + return self::normalize($decoded, $defaultTemplate); + } + + $defaults['enabled'] = self::toBool($trimmedValue); + return $defaults; + } + + return $defaults; + } + + public static function encode(array $value, string $defaultTemplate = ''): string { + return (string)json_encode(self::normalize($value, $defaultTemplate), JSON_UNESCAPED_SLASHES); + } + + public static function isEnabled(mixed $rawValue): bool { + return self::normalize($rawValue)['enabled']; + } + + public static function isQrCodeEnabled(mixed $rawValue): bool { + $normalized = self::normalize($rawValue); + return $normalized['enabled'] && $normalized['writeQrcodeOnFooter']; + } + + private static function toBool(mixed $rawValue): bool { + if (is_bool($rawValue)) { + return $rawValue; + } + + if (is_int($rawValue)) { + return $rawValue === 1; + } + + if (is_string($rawValue)) { + return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true); + } + + return (bool)$rawValue; + } + + private static function toString(mixed $rawValue): string { + if (!is_scalar($rawValue)) { + return ''; + } + + return trim((string)$rawValue); + } + + private static function toTemplateString(mixed $rawValue): string { + if (is_string($rawValue)) { + return $rawValue; + } + + if (is_scalar($rawValue)) { + return (string)$rawValue; + } + + return ''; + } + + private static function toInt(mixed $rawValue, int $fallback): int { + if (is_int($rawValue)) { + return $rawValue; + } + + if (is_numeric($rawValue)) { + return (int)$rawValue; + } + + return $fallback; + } +} diff --git a/lib/Service/Policy/Provider/Helper/DelegationLayerHelper.php b/lib/Service/Policy/Provider/Helper/DelegationLayerHelper.php new file mode 100644 index 0000000000..096e909996 --- /dev/null +++ b/lib/Service/Policy/Provider/Helper/DelegationLayerHelper.php @@ -0,0 +1,44 @@ +getScope() === 'global' + && $systemPolicy->isVisibleToChild() + && $systemPolicy->isAllowChildOverride() + && $systemPolicy->getValue() !== null; + } + + /** @param array $groupLayers */ + public static function hasSystemCreatedGroupDelegation(array $groupLayers): bool { + foreach ($groupLayers as $groupLayer) { + if (!$groupLayer instanceof PolicyLayer) { + continue; + } + + if (!$groupLayer->isVisibleToChild() || $groupLayer->getValue() === null) { + continue; + } + + if ($groupLayer->isDelegatedFromSystemCreatedSeed()) { + return true; + } + + if ($groupLayer->isAllowChildOverride() && $groupLayer->isCreatedBySystemAdmin()) { + return true; + } + } + + return false; + } +} diff --git a/lib/Service/Policy/Provider/Helper/PolicyKeyNormalizer.php b/lib/Service/Policy/Provider/Helper/PolicyKeyNormalizer.php new file mode 100644 index 0000000000..6e3c174f53 --- /dev/null +++ b/lib/Service/Policy/Provider/Helper/PolicyKeyNormalizer.php @@ -0,0 +1,19 @@ +value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Provider/IdentificationDocuments/FilePolicy/IdentificationDocumentsFilePolicyApplier.php b/lib/Service/Policy/Provider/IdentificationDocuments/FilePolicy/IdentificationDocumentsFilePolicyApplier.php new file mode 100644 index 0000000000..bde20ee98e --- /dev/null +++ b/lib/Service/Policy/Provider/IdentificationDocuments/FilePolicy/IdentificationDocumentsFilePolicyApplier.php @@ -0,0 +1,75 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(IdentificationDocumentsPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(IdentificationDocumentsPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $this->storeIdentificationDocumentsPolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(IdentificationDocumentsPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(IdentificationDocumentsPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storeIdentificationDocumentsPolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** @return array}> */ + private function getOverrides(array $data): array { + return $this->extractSinglePolicyOverride( + $data, + IdentificationDocumentsPolicy::KEY, + static fn (mixed $value): array => IdentificationDocumentsPolicyValue::normalize($value, false), + ); + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + $this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, 'Identification documents flow override is blocked by %s.'); + } + + private function storeIdentificationDocumentsPolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + parent::storePolicySnapshot( + $file, + $resolvedPolicy, + IdentificationDocumentsPolicyValue::normalize($resolvedPolicy->getEffectiveValue(), false), + ); + } +} diff --git a/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php new file mode 100644 index 0000000000..2cf78d9fbc --- /dev/null +++ b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php @@ -0,0 +1,99 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: [ + 'enabled' => false, + 'approvers' => ['admin'], + ], + allowedValues: static fn (): array => [], + normalizer: static fn (mixed $rawValue): array => \OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicyValue::normalize($rawValue, false), + validator: static function (mixed $value): void { + if (!is_array($value)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + if (!array_key_exists('enabled', $value)) { + throw new \InvalidArgumentException('Missing "enabled" key in ' . self::KEY); + } + if (!array_key_exists('approvers', $value)) { + throw new \InvalidArgumentException('Missing "approvers" key in ' . self::KEY); + } + if (!is_array($value['approvers'])) { + throw new \InvalidArgumentException('Approvers must be an array'); + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicyValue.php b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicyValue.php new file mode 100644 index 0000000000..c51042f047 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicyValue.php @@ -0,0 +1,65 @@ + */ + private const DEFAULT_APPROVERS = ['admin']; + + /** + * Normalize payload structure. + * Expected format: {enabled: bool, approvers: string[]} + * + * @return array{enabled: bool, approvers: list} + */ + public static function normalize(mixed $rawValue, bool $enabledDefault = false): array { + if (!is_array($rawValue)) { + return [ + 'enabled' => $enabledDefault, + 'approvers' => self::DEFAULT_APPROVERS, + ]; + } + + $enabled = (bool)($rawValue['enabled'] ?? $enabledDefault); + $approvers = self::DEFAULT_APPROVERS; + + if (isset($rawValue['approvers']) && is_array($rawValue['approvers'])) { + $filtered = array_filter( + array_map('strval', $rawValue['approvers']), + static fn (string $v): bool => $v !== '' + ); + if (!empty($filtered)) { + $approvers = array_values($filtered); + } + } + + return [ + 'enabled' => $enabled, + 'approvers' => $approvers, + ]; + } + + /** + * Get enabled flag from payload. + */ + public static function isEnabled(mixed $rawValue, bool $default = false): bool { + $normalized = self::normalize($rawValue, $default); + return $normalized['enabled']; + } + + /** + * Get approvers from payload. + * + * @return list + */ + public static function getApprovers(mixed $rawValue): array { + $normalized = self::normalize($rawValue); + return $normalized['approvers']; + } +} diff --git a/lib/Service/Policy/Provider/IdentifyMethods/FilePolicy/IdentifyMethodsFilePolicyApplier.php b/lib/Service/Policy/Provider/IdentifyMethods/FilePolicy/IdentifyMethodsFilePolicyApplier.php new file mode 100644 index 0000000000..1d4566ffea --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/FilePolicy/IdentifyMethodsFilePolicyApplier.php @@ -0,0 +1,48 @@ +policyService->resolveForUser(IdentifyMethodsPolicy::KEY, $user, []); + $this->storeIdentifyMethodsPolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $resolvedPolicy = $this->policyService->resolveForUserId(IdentifyMethodsPolicy::KEY, $file->getUserId(), []); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storeIdentifyMethodsPolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return false; + } + + private function storeIdentifyMethodsPolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $normalized = IdentifyMethodsPolicyValue::normalize($resolvedPolicy->getEffectiveValue()); + parent::storePolicySnapshot($file, $resolvedPolicy, IdentifyMethodsPolicyValue::extractFactors($normalized)); + } +} diff --git a/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php new file mode 100644 index 0000000000..0af63bbd63 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php @@ -0,0 +1,125 @@ +identifyMethodService; + return match (PolicyKeyNormalizer::normalize($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: [ + 'factors' => [], + ], + allowedValues: static fn (): array => [], + normalizer: fn (mixed $rawValue): array => IdentifyMethodsPolicyValue::normalize($rawValue, $identifyMethodService), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + delegatedValueValidator: static function (mixed $proposedNormalizedValue, mixed $parentSeedNormalizedValue): void { + self::assertDelegatedOverrideOnlyEnablesGrantedFactors($proposedNormalizedValue, $parentSeedNormalizedValue); + }, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + + private static function assertDelegatedOverrideOnlyEnablesGrantedFactors(mixed $proposedNormalizedValue, mixed $parentSeedNormalizedValue): void { + $parentEnabledFactorNames = self::extractEnabledFactorNames($parentSeedNormalizedValue); + $proposedEnabledFactorNames = self::extractEnabledFactorNames($proposedNormalizedValue); + + $invalidFactorNames = array_values(array_diff($proposedEnabledFactorNames, $parentEnabledFactorNames)); + if ($invalidFactorNames !== []) { + throw new \InvalidArgumentException('Delegated identify methods overrides can only enable factors already granted by the system administrator.'); + } + } + + /** @return list */ + private static function extractEnabledFactorNames(mixed $normalizedValue): array { + if (!is_array($normalizedValue)) { + return []; + } + + $enabledFactorNames = []; + foreach (IdentifyMethodsPolicyValue::extractFactors($normalizedValue) as $factor) { + $name = isset($factor['name']) && is_string($factor['name']) + ? trim($factor['name']) + : ''; + if ($name === '' || !($factor['enabled'] ?? false)) { + continue; + } + + $enabledFactorNames[] = $name; + } + + return array_values(array_unique($enabledFactorNames)); + } + +} diff --git a/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php new file mode 100644 index 0000000000..44528970e3 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php @@ -0,0 +1,506 @@ +>, can_create_account?: bool} + */ + public static function normalize(mixed $rawValue, ?IdentifyMethodService $identifyMethodService = null): array { + $preparedInput = self::prepareInput($rawValue); + $catalogFactors = self::normalizeCatalogFactors($identifyMethodService); + // Use service-provided defaults when policy is empty and service is available + $isEmpty = $preparedInput['factors'] === null || (is_array($preparedInput['factors']) && count($preparedInput['factors']) === 0); + if ($isEmpty) { + $defaultFactors = []; + if ($identifyMethodService !== null) { + $defaultFactors = $identifyMethodService->getDefaultIdentifyMethodsPolicy(); + if ($defaultFactors === []) { + $friendlyNames = $identifyMethodService->getFriendlyNamesMap(); + foreach ($friendlyNames as $name => $friendlyName) { + $defaultFactors[] = [ + 'name' => $name, + 'enabled' => true, + 'signatureMethods' => [], + 'friendly_name' => $friendlyName, + ]; + } + } + } + $normalization = self::normalizeFactors($defaultFactors, null, null); + $normalized = $normalization['factors']; + if ($catalogFactors !== []) { + $normalized = self::mergeFactorsWithCatalog($normalized, $catalogFactors); + } + + if ($identifyMethodService !== null) { + $normalized = self::enrichFriendlyNames($normalized, $identifyMethodService->getFriendlyNamesMap()); + } + $normalized = self::canonicalizeFactorEntries($normalized); + + return [ + 'factors' => $normalized, + ]; + } + $normalization = self::normalizeFactors( + $preparedInput['factors'], + $preparedInput['sharedMinimumTotalVerifiedFactors'], + ); + $normalized = $normalization['factors']; + $globalCanCreateAccount = $preparedInput['globalCanCreateAccount']; + if ($catalogFactors !== []) { + $normalized = self::mergeFactorsWithCatalog($normalized, $catalogFactors, false); + } + + if ($identifyMethodService !== null) { + $normalized = self::enrichFriendlyNames($normalized, $identifyMethodService->getFriendlyNamesMap()); + } + $normalized = self::canonicalizeFactorEntries($normalized); + + $payload = [ + 'factors' => $normalized, + ]; + if ($globalCanCreateAccount !== null) { + $payload['can_create_account'] = $globalCanCreateAccount; + } + + return $payload; + } + + /** + * @return array{factors: list|null, sharedMinimumTotalVerifiedFactors: ?int, globalCanCreateAccount: ?bool} + */ + private static function prepareInput(mixed $rawValue): array { + $globalCanCreateAccount = null; + $sharedMinimumTotalVerifiedFactors = null; + $factors = null; + + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + return self::prepareInputFromArrayPayload($decoded); + } + } + + if (is_array($rawValue)) { + return self::prepareInputFromArrayPayload($rawValue); + } + + return [ + 'factors' => $factors, + 'sharedMinimumTotalVerifiedFactors' => $sharedMinimumTotalVerifiedFactors, + 'globalCanCreateAccount' => $globalCanCreateAccount, + ]; + } + + /** + * @param array|list $payload + * @return array{factors: list|null, sharedMinimumTotalVerifiedFactors: ?int, globalCanCreateAccount: ?bool} + */ + private static function prepareInputFromArrayPayload(array $payload): array { + if (array_is_list($payload)) { + return [ + 'factors' => array_values($payload), + 'sharedMinimumTotalVerifiedFactors' => null, + 'globalCanCreateAccount' => null, + ]; + } + + $factors = null; + $sharedMinimumTotalVerifiedFactors = null; + if (isset($payload['factors']) && is_array($payload['factors'])) { + $factors = array_values($payload['factors']); + $sharedMinimumTotalVerifiedFactors = self::normalizeMinimumTotalVerifiedFactors($payload['minimumTotalVerifiedFactors'] ?? null); + } + + $globalCanCreateAccount = null; + if (array_key_exists('can_create_account', $payload)) { + $globalCanCreateAccount = (bool)$payload['can_create_account']; + } + + return [ + 'factors' => $factors, + 'sharedMinimumTotalVerifiedFactors' => $sharedMinimumTotalVerifiedFactors, + 'globalCanCreateAccount' => $globalCanCreateAccount, + ]; + } + + /** + * @param list $rawFactors + * @return array{factors: list>} + */ + private static function normalizeFactors( + array $rawFactors, + ?int $sharedMinimumTotalVerifiedFactors, + ): array { + $normalized = []; + foreach ($rawFactors as $entry) { + if (!is_array($entry)) { + continue; + } + + $normalizedEntry = self::normalizeFactorEntry($entry, $sharedMinimumTotalVerifiedFactors); + if ($normalizedEntry === null) { + continue; + } + + $normalized[] = $normalizedEntry; + } + + return [ + 'factors' => $normalized, + ]; + } + + /** + * @return list> + */ + private static function normalizeCatalogFactors(?IdentifyMethodService $identifyMethodService): array { + if ($identifyMethodService === null) { + return []; + } + + return self::extractFactors(self::normalize([ + 'factors' => $identifyMethodService->getIdentifyMethodsCatalogSettings(), + ])); + } + + /** + * @param list> $policyFactors + * @param list> $catalogFactors + * @param bool $addMissingFromCatalog When true (default), factors present in the catalog but absent + * from the policy are appended as disabled entries (used for the + * empty-policy path). When false, only the factors already in + * the policy are kept (in policy order) and each is enriched + * with catalog metadata such as labels and friendly names. + * @return list> + */ + private static function mergeFactorsWithCatalog(array $policyFactors, array $catalogFactors, bool $addMissingFromCatalog = true): array { + if ($catalogFactors === []) { + return $policyFactors; + } + + if (!$addMissingFromCatalog) { + // Enrich-only mode: preserve policy factors in policy order, enrich each from catalog. + $catalogFactorsByName = []; + foreach ($catalogFactors as $catalogFactor) { + if (!isset($catalogFactor['name']) || !is_string($catalogFactor['name']) || $catalogFactor['name'] === '') { + continue; + } + $catalogFactorsByName[$catalogFactor['name']] = $catalogFactor; + } + + $mergedFactors = []; + foreach ($policyFactors as $policyFactor) { + if (!isset($policyFactor['name']) || !is_string($policyFactor['name']) || $policyFactor['name'] === '') { + $mergedFactors[] = $policyFactor; + continue; + } + $catalogFactor = $catalogFactorsByName[$policyFactor['name']] ?? null; + if ($catalogFactor === null) { + $mergedFactors[] = $policyFactor; + continue; + } + $mergedFactors[] = self::mergeFactorWithCatalog($policyFactor, $catalogFactor); + } + return $mergedFactors; + } + + // Full merge mode: catalog-ordered, catalog factors missing from the policy are added as disabled. + $policyFactorsByName = []; + foreach ($policyFactors as $policyFactor) { + if (!isset($policyFactor['name']) || !is_string($policyFactor['name']) || $policyFactor['name'] === '') { + continue; + } + + $policyFactorsByName[$policyFactor['name']] = $policyFactor; + } + + $catalogFactorNames = []; + $mergedFactors = []; + foreach ($catalogFactors as $catalogFactor) { + if (!isset($catalogFactor['name']) || !is_string($catalogFactor['name']) || $catalogFactor['name'] === '') { + continue; + } + + $catalogFactorNames[$catalogFactor['name']] = true; + $policyFactor = $policyFactorsByName[$catalogFactor['name']] ?? null; + if ($policyFactor === null) { + $mergedFactors[] = self::buildCatalogFactor($catalogFactor); + continue; + } + + $mergedFactors[] = self::mergeFactorWithCatalog($policyFactor, $catalogFactor); + } + + foreach ($policyFactors as $policyFactor) { + if (!isset($policyFactor['name']) || !is_string($policyFactor['name']) || $policyFactor['name'] === '') { + $mergedFactors[] = $policyFactor; + continue; + } + + if (!isset($catalogFactorNames[$policyFactor['name']])) { + $mergedFactors[] = $policyFactor; + } + } + + return $mergedFactors; + } + + /** + * @param array $catalogFactor + * @return array + */ + private static function buildCatalogFactor(array $catalogFactor): array { + $catalogFactor['enabled'] = false; + + return $catalogFactor; + } + + /** + * @param array $policyFactor + * @param array $catalogFactor + * @return array + */ + private static function mergeFactorWithCatalog(array $policyFactor, array $catalogFactor): array { + $mergedFactor = $policyFactor; + + if (!isset($mergedFactor['friendly_name']) && isset($catalogFactor['friendly_name'])) { + $mergedFactor['friendly_name'] = $catalogFactor['friendly_name']; + } + + if (!isset($mergedFactor['requirement']) && isset($catalogFactor['requirement'])) { + $mergedFactor['requirement'] = $catalogFactor['requirement']; + } + + if (!isset($mergedFactor['minimumTotalVerifiedFactors']) && isset($catalogFactor['minimumTotalVerifiedFactors'])) { + $mergedFactor['minimumTotalVerifiedFactors'] = $catalogFactor['minimumTotalVerifiedFactors']; + } + + if (!isset($mergedFactor['signatureMethodEnabled']) && isset($catalogFactor['signatureMethodEnabled'])) { + $mergedFactor['signatureMethodEnabled'] = $catalogFactor['signatureMethodEnabled']; + } + + $catalogSignatureMethods = isset($catalogFactor['signatureMethods']) && is_array($catalogFactor['signatureMethods']) + ? $catalogFactor['signatureMethods'] + : []; + $policySignatureMethods = isset($policyFactor['signatureMethods']) && is_array($policyFactor['signatureMethods']) + ? $policyFactor['signatureMethods'] + : []; + $mergedFactor['signatureMethods'] = self::mergeSignatureMethods($catalogSignatureMethods, $policySignatureMethods); + + return $mergedFactor; + } + + /** + * @param array> $catalogSignatureMethods + * @param array> $policySignatureMethods + * @return array> + */ + private static function mergeSignatureMethods(array $catalogSignatureMethods, array $policySignatureMethods): array { + $mergedSignatureMethods = []; + + if ($policySignatureMethods !== []) { + // Policy explicitly declares which signature methods are active. + // Enrich each declared method with catalog metadata (e.g. label) but do NOT + // add catalog methods that the policy omitted. + foreach (array_keys($policySignatureMethods) as $signatureMethodName) { + $mergedSignatureMethods[$signatureMethodName] = array_merge( + $catalogSignatureMethods[$signatureMethodName] ?? [], + $policySignatureMethods[$signatureMethodName], + ); + } + return $mergedSignatureMethods; + } + + // No explicit signature methods in policy – expose all catalog methods. + foreach ($catalogSignatureMethods as $signatureMethodName => $catalogMethod) { + $mergedSignatureMethods[$signatureMethodName] = $catalogMethod; + } + + return $mergedSignatureMethods; + } + + /** + * @param array $entry + * @return ?array + */ + private static function normalizeFactorEntry( + array $entry, + ?int $sharedMinimumTotalVerifiedFactors, + ): ?array { + $name = isset($entry['name']) && is_string($entry['name']) + ? trim($entry['name']) + : ''; + if ($name === '') { + return null; + } + + $signatureMethods = self::normalizeSignatureMethods($entry); + $normalizedEntry = [ + 'name' => $name, + 'enabled' => array_key_exists('enabled', $entry) ? (bool)$entry['enabled'] : true, + 'signatureMethods' => $signatureMethods, + ]; + + if (isset($entry['friendly_name']) && is_string($entry['friendly_name'])) { + $normalizedEntry['friendly_name'] = $entry['friendly_name']; + } + + $minimumTotalVerifiedFactors = self::normalizeMinimumTotalVerifiedFactors($entry['minimumTotalVerifiedFactors'] ?? null) + ?? $sharedMinimumTotalVerifiedFactors; + if ($minimumTotalVerifiedFactors !== null) { + $normalizedEntry['minimumTotalVerifiedFactors'] = $minimumTotalVerifiedFactors; + } + + $requirement = IdentifyMethodRequirement::tryFrom((string)($entry['requirement'] ?? '')); + if ($requirement !== null) { + $normalizedEntry['requirement'] = $requirement->value; + } + + if (isset($entry['signatureMethodEnabled']) && is_string($entry['signatureMethodEnabled'])) { + $normalizedEntry['signatureMethodEnabled'] = $entry['signatureMethodEnabled']; + } + + return $normalizedEntry; + } + + /** + * @param array $entry + * @return array> + */ + private static function normalizeSignatureMethods(array $entry): array { + $signatureMethods = []; + if (isset($entry['signatureMethods']) && is_array($entry['signatureMethods']) && !array_is_list($entry['signatureMethods'])) { + foreach ($entry['signatureMethods'] as $signatureMethodName => $signatureMethodConfig) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + $normalizedSignatureMethod = self::normalizeSignatureMethodConfig($signatureMethodConfig); + if ($normalizedSignatureMethod !== null) { + $signatureMethods[$signatureMethodName] = $normalizedSignatureMethod; + } + } + } + + return $signatureMethods; + } + + /** + * @return ?array + */ + private static function normalizeSignatureMethodConfig(mixed $signatureMethodConfig): ?array { + if (is_string($signatureMethodConfig)) { + return [ + 'enabled' => false, + 'label' => $signatureMethodConfig, + ]; + } + + if (!is_array($signatureMethodConfig)) { + return null; + } + + $normalizedSignatureMethod = []; + $normalizedSignatureMethod = $signatureMethodConfig; + if (array_key_exists('enabled', $signatureMethodConfig)) { + $normalizedSignatureMethod['enabled'] = (bool)$signatureMethodConfig['enabled']; + } + + if (isset($signatureMethodConfig['name']) && is_string($signatureMethodConfig['name']) && trim($signatureMethodConfig['name']) !== '') { + $normalizedSignatureMethod['name'] = trim($signatureMethodConfig['name']); + } + + if (isset($signatureMethodConfig['label']) && is_string($signatureMethodConfig['label']) && trim($signatureMethodConfig['label']) !== '') { + $normalizedSignatureMethod['label'] = $signatureMethodConfig['label']; + } + + if (!isset($normalizedSignatureMethod['name']) && isset($normalizedSignatureMethod['label']) && is_string($normalizedSignatureMethod['label'])) { + $normalizedSignatureMethod['name'] = $normalizedSignatureMethod['label']; + } + + return $normalizedSignatureMethod; + } + + /** + * @param list> $normalized + * @param array $friendlyNames + * @return list> + */ + private static function enrichFriendlyNames(array $normalized, array $friendlyNames): array { + foreach ($normalized as &$entry) { + if (!isset($entry['friendly_name']) && isset($entry['name'], $friendlyNames[$entry['name']])) { + $entry['friendly_name'] = $friendlyNames[$entry['name']]; + } + } + unset($entry); + + return $normalized; + } + + /** + * @param list> $normalized + * @return list> + */ + private static function canonicalizeFactorEntries(array $normalized): array { + $canonicalized = []; + foreach ($normalized as $entry) { + $canonicalEntry = []; + foreach (['name', 'enabled', 'signatureMethods', 'friendly_name', 'minimumTotalVerifiedFactors', 'requirement', 'signatureMethodEnabled'] as $key) { + if (array_key_exists($key, $entry)) { + $canonicalEntry[$key] = $entry[$key]; + unset($entry[$key]); + } + } + + foreach ($entry as $key => $value) { + $canonicalEntry[$key] = $value; + } + + $canonicalized[] = $canonicalEntry; + } + + return $canonicalized; + } + + /** + * @return list> + */ + public static function extractFactors(array $normalizedPayload): array { + if (isset($normalizedPayload['factors']) && is_array($normalizedPayload['factors'])) { + return array_values(array_filter($normalizedPayload['factors'], static fn (mixed $entry): bool => is_array($entry))); + } + + return []; + } + + public static function resolveGlobalCanCreateAccount(array $normalizedPayload): ?bool { + if (array_key_exists('can_create_account', $normalizedPayload)) { + return (bool)$normalizedPayload['can_create_account']; + } + + return null; + } + + private static function normalizeMinimumTotalVerifiedFactors(mixed $value): ?int { + if (!is_numeric($value)) { + return null; + } + + $normalized = (int)$value; + if ($normalized < 1) { + return null; + } + + return $normalized; + } +} diff --git a/lib/Service/Policy/Provider/LegalInformation/FilePolicy/LegalInformationFilePolicyApplier.php b/lib/Service/Policy/Provider/LegalInformation/FilePolicy/LegalInformationFilePolicyApplier.php new file mode 100644 index 0000000000..de66a32b93 --- /dev/null +++ b/lib/Service/Policy/Provider/LegalInformation/FilePolicy/LegalInformationFilePolicyApplier.php @@ -0,0 +1,62 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(LegalInformationPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(LegalInformationPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(LegalInformationPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(LegalInformationPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return false; + } + + /** @return array */ + private function getOverrides(array $data): array { + return $this->extractSinglePolicyOverride($data, LegalInformationPolicy::KEY); + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + $this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, 'Legal information override is blocked by %s.'); + } +} diff --git a/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php b/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php new file mode 100644 index 0000000000..4a932255d1 --- /dev/null +++ b/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php @@ -0,0 +1,82 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: '', + allowedValues: [], + normalizer: static fn (mixed $rawValue): string => (string)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php new file mode 100644 index 0000000000..9d3d95176e --- /dev/null +++ b/lib/Service/Policy/Provider/PolicyProviders.php @@ -0,0 +1,60 @@ + */ + public const BY_KEY = [ + ApprovalGroupsPolicy::KEY => ApprovalGroupsPolicy::class, + CollectMetadataPolicy::KEY => CollectMetadataPolicy::class, + ConfettiPolicy::KEY => ConfettiPolicy::class, + CrlValidationPolicy::KEY => CrlValidationPolicy::class, + FooterPolicy::KEY => FooterPolicy::class, + DocMdpPolicy::KEY => DocMdpPolicy::class, + EnvelopePolicy::KEY => EnvelopePolicy::class, + ExpirationRulesPolicy::KEY_MAXIMUM_VALIDITY => ExpirationRulesPolicy::class, + ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL => ExpirationRulesPolicy::class, + ExpirationRulesPolicy::KEY_EXPIRY_IN_DAYS => ExpirationRulesPolicy::class, + RequestSignGroupsPolicy::KEY => RequestSignGroupsPolicy::class, + ReminderPolicy::KEY => ReminderPolicy::class, + DefaultUserFolderPolicy::KEY => DefaultUserFolderPolicy::class, + LegalInformationPolicy::KEY => LegalInformationPolicy::class, + SignatureHashAlgorithmPolicy::KEY => SignatureHashAlgorithmPolicy::class, + ValidationAccessPolicy::KEY => ValidationAccessPolicy::class, + SignatureFlowPolicy::KEY => SignatureFlowPolicy::class, + SigningModePolicy::KEY_SIGNING_MODE => SigningModePolicy::class, + WorkerConfigPolicy::KEY => WorkerConfigPolicy::class, + IdentificationDocumentsPolicy::KEY => IdentificationDocumentsPolicy::class, + IdentifyMethodsPolicy::KEY => IdentifyMethodsPolicy::class, + SignatureTextPolicy::KEY => SignatureTextPolicy::class, + TsaPolicy::KEY => TsaPolicy::class, + ]; +} diff --git a/lib/Service/Policy/Provider/Reminder/ReminderPolicy.php b/lib/Service/Policy/Provider/Reminder/ReminderPolicy.php new file mode 100644 index 0000000000..ad9c3b256f --- /dev/null +++ b/lib/Service/Policy/Provider/Reminder/ReminderPolicy.php @@ -0,0 +1,82 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: ReminderPolicyValue::encode(ReminderPolicyValue::defaults()), + allowedValues: static fn (): array => [], + normalizer: static fn (mixed $rawValue): string => ReminderPolicyValue::encode(ReminderPolicyValue::normalize($rawValue)), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/Reminder/ReminderPolicyValue.php b/lib/Service/Policy/Provider/Reminder/ReminderPolicyValue.php new file mode 100644 index 0000000000..d3cd1086be --- /dev/null +++ b/lib/Service/Policy/Provider/Reminder/ReminderPolicyValue.php @@ -0,0 +1,99 @@ + 0, + 'days_between' => 0, + 'max' => 0, + 'send_timer' => '10:00', + ]; + } + + /** @return array{days_before: int, days_between: int, max: int, send_timer: string} */ + public static function normalize(mixed $rawValue): array { + $defaults = self::defaults(); + + if (is_string($rawValue)) { + $trimmed = trim($rawValue); + if ($trimmed !== '') { + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } + } + + if (!is_array($rawValue)) { + return $defaults; + } + + $daysBefore = self::toNonNegativeInt($rawValue['days_before'] ?? $defaults['days_before']); + $daysBetween = self::toNonNegativeInt($rawValue['days_between'] ?? $defaults['days_between']); + $max = self::toNonNegativeInt($rawValue['max'] ?? $defaults['max']); + $sendTimer = self::normalizeSendTimer($rawValue['send_timer'] ?? $defaults['send_timer']); + + if ($daysBefore <= 0 || $daysBetween <= 0 || $max <= 0) { + return [ + 'days_before' => 0, + 'days_between' => 0, + 'max' => 0, + 'send_timer' => '', + ]; + } + + if ($sendTimer === '') { + $sendTimer = $defaults['send_timer']; + } + + return [ + 'days_before' => $daysBefore, + 'days_between' => $daysBetween, + 'max' => $max, + 'send_timer' => $sendTimer, + ]; + } + + /** @param array $value */ + public static function encode(array $value): string { + return (string)json_encode(self::normalize($value), JSON_UNESCAPED_SLASHES); + } + + private static function toNonNegativeInt(mixed $rawValue): int { + if (is_int($rawValue)) { + return max(0, $rawValue); + } + + if (is_numeric($rawValue)) { + return max(0, (int)$rawValue); + } + + return 0; + } + + private static function normalizeSendTimer(mixed $rawValue): string { + if (!is_scalar($rawValue)) { + return '10:00'; + } + + $value = trim((string)$rawValue); + if ($value === '') { + return ''; + } + + if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $value)) { + return '10:00'; + } + + return $value; + } +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php new file mode 100644 index 0000000000..5c1df9acbb --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php @@ -0,0 +1,111 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: RequestSignGroupsPolicyValue::encode([ + 'allowGroups' => RequestSignGroupsPolicyValue::DEFAULT_ALLOW_GROUPS, + 'denyGroups' => RequestSignGroupsPolicyValue::DEFAULT_DENY_GROUPS, + ]), + allowedValues: static fn (PolicyContext $context): array => [], + normalizer: static fn (mixed $rawValue): mixed => RequestSignGroupsPolicyValue::encode($rawValue), + validator: static function (mixed $value): void { + if (!is_string($value)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + $decoded = RequestSignGroupsPolicyValue::decodePolicy($value); + if ($decoded['allowGroups'] === []) { + throw new \InvalidArgumentException('At least one authorized group is required for ' . self::KEY); + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + supportedScopes: [PolicySpec::SCOPE_SYSTEM, PolicySpec::SCOPE_GROUP], + visibleGroupCountFilter: static function (PolicyContext $context, ?PolicyLayer $systemPolicy): bool { + return !$context->getActorRole()->canManageSystemPolicies; + }, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + delegatedValueValidator: static function (mixed $proposedNormalizedValue): void { + if (!is_string($proposedNormalizedValue)) { + return; + } + + $decoded = RequestSignGroupsPolicyValue::decodePolicy($proposedNormalizedValue); + if ($decoded['denyGroups'] === []) { + throw new \InvalidArgumentException('This group is already authorized by a system administrator. Add a deny rule to override it.'); + } + }, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey), + }; + } +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php new file mode 100644 index 0000000000..47d7bd0ae0 --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php @@ -0,0 +1,92 @@ +l10n->t('User-level scope is not supported for this policy')); + } + + public function normalizeManagedValue(string $policyKey, mixed $value, bool $allowNullReset = false, ?string $requiredGroupId = null): mixed { + if ($policyKey !== RequestSignGroupsPolicy::KEY) { + return $value; + } + + if ($allowNullReset && $value === null) { + return null; + } + + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + throw new \InvalidArgumentException($this->l10n->t('Not allowed to manage this policy')); + } + + $decodedPolicy = RequestSignGroupsPolicyValue::decodePolicy($value); + $allowGroupIds = $decodedPolicy['allowGroups']; + $denyGroupIds = $decodedPolicy['denyGroups']; + + if ($allowGroupIds === []) { + throw new \InvalidArgumentException($this->l10n->t('At least one authorized group is required')); + } + + $isSystemAdmin = $this->groupManager->isAdmin($user->getUID()); + if (!$isSystemAdmin + && is_string($requiredGroupId) + && trim($requiredGroupId) !== '' + && !in_array($requiredGroupId, $allowGroupIds, true)) { + throw new \InvalidArgumentException($this->l10n->t('You cannot remove your managed group from this rule')); + } + + $allowedGroupIds = $this->resolveAllowedGroupIdsForActor($user); + $groupsToValidate = array_values(array_unique(array_merge($allowGroupIds, $denyGroupIds))); + $unknownGroupIds = array_values(array_diff($groupsToValidate, $allowedGroupIds)); + if ($unknownGroupIds !== []) { + throw new \InvalidArgumentException($this->l10n->t('One or more selected groups are not allowed for your administration scope')); + } + + return RequestSignGroupsPolicyValue::encode($decodedPolicy); + } + + /** @return list */ + private function resolveAllowedGroupIdsForActor(IUser $user): array { + if ($this->groupManager->isAdmin($user->getUID())) { + return array_values(array_map( + static fn (IGroup $group): string => $group->getGID(), + $this->groupManager->search(''), + )); + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return []; + } + + return $this->policyAuthorizationService->getManageablePolicyGroupIds($user); + } +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php new file mode 100644 index 0000000000..7074502fea --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php @@ -0,0 +1,156 @@ + */ + public const DEFAULT_ALLOW_GROUPS = ['admin']; + + /** @var list */ + public const DEFAULT_DENY_GROUPS = []; + + /** + * @return array{allowGroups: list, denyGroups: list} + */ + public static function decodePolicy(mixed $rawValue): array { + if (is_array($rawValue)) { + if (array_is_list($rawValue)) { + return [ + 'allowGroups' => self::normalizeGroupIds($rawValue), + 'denyGroups' => [], + ]; + } + + return self::normalizePolicyPayload($rawValue); + } + + if (!is_string($rawValue)) { + return self::normalizePolicyPayload([]); + } + + $trimmed = trim($rawValue); + if ($trimmed === '') { + return self::normalizePolicyPayload([]); + } + + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + if (array_is_list($decoded)) { + return [ + 'allowGroups' => self::normalizeGroupIds($decoded), + 'denyGroups' => [], + ]; + } + + return self::normalizePolicyPayload($decoded); + } + + return self::normalizePolicyPayload([]); + } + + /** @return list */ + public static function decode(mixed $rawValue): array { + return self::decodePolicy($rawValue)['allowGroups']; + } + + /** @return list */ + public static function decodeDenied(mixed $rawValue): array { + return self::decodePolicy($rawValue)['denyGroups']; + } + + /** + * Return all policy-scoped groups (allow + deny), normalized and deduplicated. + * + * @return list + */ + public static function decodeScopedGroups(mixed $rawValue): array { + $policy = self::decodePolicy($rawValue); + + return self::normalizeGroupIds([ + ...$policy['allowGroups'], + ...$policy['denyGroups'], + ]); + } + + /** + * Evaluate if at least one user group is authorized to request sign + * and none of the user groups are explicitly denied. + * + * @param array $userGroups + */ + public static function canUserGroupsRequestSign(mixed $rawValue, array $userGroups): bool { + $authorizedGroups = self::decode($rawValue); + if ($authorizedGroups === []) { + return false; + } + + $normalizedUserGroups = self::normalizeGroupIds($userGroups); + if ($normalizedUserGroups === []) { + return false; + } + + if (array_intersect($normalizedUserGroups, $authorizedGroups) === []) { + return false; + } + + return array_intersect($normalizedUserGroups, self::decodeDenied($rawValue)) === []; + } + + public static function encode(mixed $rawValue): string { + $payload = is_array($rawValue) && array_is_list($rawValue) + ? [ + 'allowGroups' => self::normalizeGroupIds($rawValue), + 'denyGroups' => [], + ] + : self::decodePolicy($rawValue); + return json_encode($payload, JSON_THROW_ON_ERROR); + } + + /** @param array $rawValue */ + private static function normalizePolicyPayload(array $rawValue): array { + $rawAllow = $rawValue['allowGroups'] ?? []; + $rawDeny = $rawValue['denyGroups'] ?? []; + + $allowGroups = is_array($rawAllow) + ? self::normalizeGroupIds($rawAllow) + : []; + $denyGroups = is_array($rawDeny) + ? self::normalizeGroupIds($rawDeny) + : []; + + return [ + 'allowGroups' => $allowGroups, + 'denyGroups' => $denyGroups, + ]; + } + + /** @param array $rawGroups + * @return list + */ + private static function normalizeGroupIds(array $rawGroups): array { + $normalized = []; + foreach ($rawGroups as $groupId) { + if (!is_string($groupId)) { + continue; + } + + $trimmed = trim($groupId); + if ($trimmed === '') { + continue; + } + + $normalized[] = $trimmed; + } + + $unique = array_values(array_unique($normalized)); + sort($unique); + + return $unique; + } +} diff --git a/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php new file mode 100644 index 0000000000..4841675292 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php @@ -0,0 +1,66 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(SignatureFlowPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(SignatureFlowPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $file->setSignatureFlowEnum(SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue())); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(SignatureFlowPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(SignatureFlowPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $newFlow = SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue()); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($file->getSignatureFlowEnum() !== $newFlow || $metadataChanged) { + $file->setSignatureFlowEnum($newFlow); + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** @return array */ + private function getOverrides(array $data): array { + return $this->extractSinglePolicyOverride($data, SignatureFlowPolicy::KEY); + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + $this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, 'Signature flow override is blocked by %s.'); + } +} diff --git a/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php new file mode 100644 index 0000000000..89d17ece59 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php @@ -0,0 +1,94 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: SignatureFlow::NONE->value, + allowedValues: [ + SignatureFlow::NONE->value, + SignatureFlow::PARALLEL->value, + SignatureFlow::ORDERED_NUMERIC->value, + ], + normalizer: static function (mixed $rawValue): mixed { + if ($rawValue instanceof SignatureFlow) { + return $rawValue->value; + } + + return $rawValue; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + resolutionMode: PolicySpec::RESOLUTION_MODE_VALUE_CHOICE, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/SignatureHashAlgorithm/SignatureHashAlgorithmPolicy.php b/lib/Service/Policy/Provider/SignatureHashAlgorithm/SignatureHashAlgorithmPolicy.php new file mode 100644 index 0000000000..18cd2a1730 --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureHashAlgorithm/SignatureHashAlgorithmPolicy.php @@ -0,0 +1,96 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: 'SHA256', + allowedValues: self::ALGORITHMS, + normalizer: static fn (mixed $rawValue): string => strtoupper(trim((string)$rawValue)), + validator: static function (mixed $value): void { + if (!is_string($value) || !in_array($value, self::ALGORITHMS, true)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php new file mode 100644 index 0000000000..dd502ec779 --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php @@ -0,0 +1,167 @@ +encodeConsolidatedValue($this->defaultConsolidatedValue()); + + return match ($normalizedKey) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: $defaultConsolidatedValue, + allowedValues: [], + normalizer: fn (mixed $rawValue): string => $this->encodeConsolidatedValue( + $this->normalizeConsolidatedValue($rawValue), + ), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy) + || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + resolvedStateMeta: [ + 'defaultSystemValue' => $defaultConsolidatedValue, + ], + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey), + }; + } + + /** + * @return array + */ + private function defaultConsolidatedValue(): array { + return [ + 'template' => SignatureTextTemplate::translated($this->l10n, false), + 'template_font_size' => SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE, + 'signature_font_size' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE, + 'signature_width' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH, + 'signature_height' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT, + 'background_type' => 'default', + 'render_mode' => 'default', + ]; + } + + /** + * @return array + */ + private function normalizeConsolidatedValue(mixed $rawValue): array { + $defaults = $this->defaultConsolidatedValue(); + + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } + + if (!is_array($rawValue)) { + return $defaults; + } + + $renderMode = (string)($rawValue['render_mode'] ?? $defaults['render_mode']); + if (!in_array($renderMode, ['default', 'graphic', 'text', 'description_only'], true)) { + $renderMode = 'default'; + } + + $backgroundType = $this->normalizeBackgroundType($rawValue['background_type'] ?? $defaults['background_type']); + + return [ + 'template' => (string)($rawValue['template'] ?? $defaults['template']), + 'template_font_size' => max(0.1, (float)($rawValue['template_font_size'] ?? $defaults['template_font_size'])), + 'signature_font_size' => max(0.1, (float)($rawValue['signature_font_size'] ?? $defaults['signature_font_size'])), + 'signature_width' => max(0.1, (float)($rawValue['signature_width'] ?? $defaults['signature_width'])), + 'signature_height' => max(0.1, (float)($rawValue['signature_height'] ?? $defaults['signature_height'])), + 'background_type' => $backgroundType, + 'render_mode' => $renderMode, + ]; + } + + /** + * @param array $value + */ + private function encodeConsolidatedValue(array $value): string { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private function normalizeBackgroundType(mixed $rawValue): string { + if (!is_string($rawValue)) { + return 'default'; + } + + $normalized = trim(strtolower($rawValue)); + if (in_array($normalized, ['default', 'custom', 'deleted'], true)) { + return $normalized; + } + + return 'default'; + } +} diff --git a/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicyValue.php b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicyValue.php new file mode 100644 index 0000000000..8ebdab0e81 --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicyValue.php @@ -0,0 +1,130 @@ + '', + 'template_font_size' => self::DEFAULT_TEMPLATE_FONT_SIZE, + 'signature_font_size' => self::DEFAULT_SIGNATURE_FONT_SIZE, + 'signature_width' => self::DEFAULT_SIGNATURE_WIDTH, + 'signature_height' => self::DEFAULT_SIGNATURE_HEIGHT, + 'background_type' => 'default', + 'render_mode' => 'default', + ]; + + /** + * @param mixed $rawValue + * @param null|array{ + * template: string, + * template_font_size: float, + * signature_font_size: float, + * signature_width: float, + * signature_height: float, + * background_type: 'default'|'custom'|'deleted', + * render_mode: 'default'|'graphic'|'text'|'description_only', + * } $defaults + * @return array{ + * template: string, + * template_font_size: float, + * signature_font_size: float, + * signature_width: float, + * signature_height: float, + * background_type: 'default'|'custom'|'deleted', + * render_mode: 'default'|'graphic'|'text'|'description_only', + * } + */ + public static function normalize(mixed $rawValue, ?array $defaults = null): array { + $defaults = $defaults === null ? self::DEFAULTS : array_replace(self::DEFAULTS, $defaults); + + if (is_string($rawValue)) { + try { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } catch (\JsonException) { + // Fallback to defaults + } + } + + if (!is_array($rawValue)) { + return $defaults; + } + + return [ + 'template' => self::normalizeString($rawValue['template'] ?? $defaults['template']), + 'template_font_size' => self::normalizeFloat($rawValue['template_font_size'] ?? $defaults['template_font_size']), + 'signature_font_size' => self::normalizeFloat($rawValue['signature_font_size'] ?? $defaults['signature_font_size']), + 'signature_width' => self::normalizeFloat($rawValue['signature_width'] ?? $defaults['signature_width']), + 'signature_height' => self::normalizeFloat($rawValue['signature_height'] ?? $defaults['signature_height']), + 'background_type' => self::normalizeBackgroundType($rawValue['background_type'] ?? $defaults['background_type']), + 'render_mode' => self::normalizeRenderMode($rawValue['render_mode'] ?? $defaults['render_mode']), + ]; + } + + /** + * @param array{ + * template: string, + * template_font_size: float, + * signature_font_size: float, + * signature_width: float, + * signature_height: float, + * background_type: 'default'|'custom'|'deleted', + * render_mode: 'default'|'graphic'|'text'|'description_only', + * } $value + */ + public static function encode(array $value): string { + $normalized = self::normalize($value); + return json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private static function normalizeString(mixed $value): string { + return (string)($value ?? ''); + } + + private static function normalizeFloat(mixed $value): float { + $float = (float)($value ?? 0); + return max(0.1, $float); + } + + /** @return 'default'|'graphic'|'text'|'description_only' */ + private static function normalizeRenderMode(mixed $value): string { + $mode = (string)($value ?? 'default'); + return match ($mode) { + 'default', 'graphic', 'text', 'description_only' => $mode, + default => 'default', + }; + } + + /** @return 'default'|'custom'|'deleted' */ + private static function normalizeBackgroundType(mixed $value): string { + $mode = (string)($value ?? 'default'); + return match ($mode) { + 'default', 'custom', 'deleted' => $mode, + default => 'default', + }; + } +} diff --git a/lib/Service/Policy/Provider/Tsa/TsaPolicy.php b/lib/Service/Policy/Provider/Tsa/TsaPolicy.php new file mode 100644 index 0000000000..43bde3057b --- /dev/null +++ b/lib/Service/Policy/Provider/Tsa/TsaPolicy.php @@ -0,0 +1,49 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: TsaPolicyValue::encode(TsaPolicyValue::defaults()), + allowedValues: static fn (): array => [], + normalizer: fn (mixed $rawValue): string => $this->managedValue->normalizeForPersistence($rawValue), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + supportedScopes: [PolicySpec::SCOPE_SYSTEM], + supportsGroupAdminDelegation: false, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } +} diff --git a/lib/Service/Policy/Provider/Tsa/TsaPolicyManagedValue.php b/lib/Service/Policy/Provider/Tsa/TsaPolicyManagedValue.php new file mode 100644 index 0000000000..51fd978943 --- /dev/null +++ b/lib/Service/Policy/Provider/Tsa/TsaPolicyManagedValue.php @@ -0,0 +1,99 @@ +decodeRawPayload($value); + $rawPolicyOid = isset($rawPayload['policy_oid']) && is_string($rawPayload['policy_oid']) + ? trim($rawPayload['policy_oid']) + : ''; + if ($rawPolicyOid !== '' && !preg_match('/^[0-9]+(\.[0-9]+)*$/', $rawPolicyOid)) { + throw new \InvalidArgumentException('Invalid OID format'); + } + + $normalized = TsaPolicyValue::decode($value); + if ($normalized['url'] === '') { + $this->clearStoredPassword(); + return TsaPolicyValue::encode(TsaPolicyValue::defaults()); + } + + $urlScheme = parse_url($normalized['url'], PHP_URL_SCHEME); + if (!filter_var($normalized['url'], FILTER_VALIDATE_URL) + || !is_string($urlScheme) + || !in_array($urlScheme, ['http', 'https'], true)) { + throw new \InvalidArgumentException('Invalid URL format'); + } + + if ($normalized['auth_type'] !== 'basic') { + $this->clearStoredPassword(); + $normalized['username'] = ''; + return TsaPolicyValue::encode($normalized); + } + + $password = isset($rawPayload['password']) && is_string($rawPayload['password']) + ? trim($rawPayload['password']) + : ''; + $hasFreshPassword = $password !== '' && $password !== Admin::PASSWORD_PLACEHOLDER; + $existingPassword = $this->appConfig->getValueString(Application::APP_ID, TsaPolicy::PASSWORD_APP_CONFIG_KEY, ''); + $hasPersistedPassword = $existingPassword !== ''; + + if ($normalized['username'] === '' && !$hasFreshPassword && !$hasPersistedPassword) { + throw new \InvalidArgumentException('Username and password are required for basic authentication'); + } + + if ($normalized['username'] === '') { + throw new \InvalidArgumentException('Username is required'); + } + + if (!$hasFreshPassword && !$hasPersistedPassword) { + throw new \InvalidArgumentException('Password is required'); + } + + if ($hasFreshPassword) { + $this->appConfig->setValueString( + Application::APP_ID, + key: TsaPolicy::PASSWORD_APP_CONFIG_KEY, + value: $password, + sensitive: true, + ); + } + + return TsaPolicyValue::encode($normalized); + } + + private function clearStoredPassword(): void { + if ($this->appConfig->hasKey(Application::APP_ID, TsaPolicy::PASSWORD_APP_CONFIG_KEY)) { + $this->appConfig->deleteKey(Application::APP_ID, TsaPolicy::PASSWORD_APP_CONFIG_KEY); + } + } + + /** @return array */ + private function decodeRawPayload(array|string $rawValue): array { + if (is_array($rawValue)) { + return $rawValue; + } + + $decoded = json_decode($rawValue, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/lib/Service/Policy/Provider/Tsa/TsaPolicyValue.php b/lib/Service/Policy/Provider/Tsa/TsaPolicyValue.php new file mode 100644 index 0000000000..0608b0b05e --- /dev/null +++ b/lib/Service/Policy/Provider/Tsa/TsaPolicyValue.php @@ -0,0 +1,73 @@ + '', + 'policy_oid' => '', + 'auth_type' => 'none', + 'username' => '', + ]; + } + + /** @return array{url: string, policy_oid: string, auth_type: string, username: string} */ + public static function decode(mixed $rawValue): array { + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } + + if (!is_array($rawValue)) { + return self::defaults(); + } + + $url = isset($rawValue['url']) && is_string($rawValue['url']) + ? trim($rawValue['url']) + : ''; + + $policyOid = isset($rawValue['policy_oid']) && is_string($rawValue['policy_oid']) + ? trim($rawValue['policy_oid']) + : ''; + + if ($policyOid !== '' && !preg_match('/^[0-9]+(\.[0-9]+)*$/', $policyOid)) { + $policyOid = ''; + } + + $authType = isset($rawValue['auth_type']) && is_string($rawValue['auth_type']) + ? trim($rawValue['auth_type']) + : 'none'; + if (!in_array($authType, ['none', 'basic'], true)) { + $authType = 'none'; + } + + $username = isset($rawValue['username']) && is_string($rawValue['username']) + ? trim($rawValue['username']) + : ''; + + if ($authType !== 'basic') { + $username = ''; + } + + return [ + 'url' => $url, + 'policy_oid' => $policyOid, + 'auth_type' => $authType, + 'username' => $username, + ]; + } + + public static function encode(mixed $rawValue): string { + return json_encode(self::decode($rawValue), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } +} diff --git a/lib/Service/Policy/Provider/ValidationAccess/ValidationAccessPolicy.php b/lib/Service/Policy/Provider/ValidationAccess/ValidationAccessPolicy.php new file mode 100644 index 0000000000..4e68220232 --- /dev/null +++ b/lib/Service/Policy/Provider/ValidationAccess/ValidationAccessPolicy.php @@ -0,0 +1,103 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: false, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportedScopes: [ + PolicySpec::SCOPE_SYSTEM, + PolicySpec::SCOPE_GROUP, + PolicySpec::SCOPE_USER, + ], + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild()) { + return false; + } + + if (!$existingPolicy->isAllowChildOverride()) { + return false; + } + + if ($existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } + +} diff --git a/lib/Service/Policy/Provider/Worker/SigningModePolicy.php b/lib/Service/Policy/Provider/Worker/SigningModePolicy.php new file mode 100644 index 0000000000..86679a07dd --- /dev/null +++ b/lib/Service/Policy/Provider/Worker/SigningModePolicy.php @@ -0,0 +1,46 @@ + new PolicySpec( + key: self::KEY_SIGNING_MODE, + defaultSystemValue: 'sync', + allowedValues: ['sync', 'async'], + normalizer: static fn (mixed $rawValue): string => in_array((string)$rawValue, ['sync', 'async'], true) + ? (string)$rawValue + : 'sync', + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_SIGNING_MODE, + supportsUserPreference: false, + supportedScopes: [PolicySpec::SCOPE_SYSTEM], + compositeChildren: [WorkerConfigPolicy::KEY], + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } +} diff --git a/lib/Service/Policy/Provider/Worker/WorkerConfigPolicy.php b/lib/Service/Policy/Provider/Worker/WorkerConfigPolicy.php new file mode 100644 index 0000000000..28030e144a --- /dev/null +++ b/lib/Service/Policy/Provider/Worker/WorkerConfigPolicy.php @@ -0,0 +1,91 @@ +encodeDefault(), + allowedValues: [], + normalizer: fn (mixed $rawValue): string => $this->encodeNormalized($rawValue), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + supportedScopes: [PolicySpec::SCOPE_SYSTEM], + helper: true, + parentPolicyKey: SigningModePolicy::KEY_SIGNING_MODE, + ); + } + + /** + * @return array{worker_type: string, parallel_workers: int} + */ + public function defaultValue(): array { + return [ + 'worker_type' => self::DEFAULT_WORKER_TYPE, + 'parallel_workers' => self::DEFAULT_PARALLEL_WORKERS, + ]; + } + + /** + * @return array{worker_type: string, parallel_workers: int} + */ + public function normalizeValue(mixed $rawValue): array { + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } + + if (!is_array($rawValue)) { + return $this->defaultValue(); + } + + $workerType = (string)($rawValue['worker_type'] ?? self::DEFAULT_WORKER_TYPE); + if (!in_array($workerType, ['local', 'external'], true)) { + $workerType = self::DEFAULT_WORKER_TYPE; + } + + $parallelWorkers = (int)($rawValue['parallel_workers'] ?? self::DEFAULT_PARALLEL_WORKERS); + $parallelWorkers = max(self::MIN_PARALLEL_WORKERS, min(self::MAX_PARALLEL_WORKERS, $parallelWorkers)); + + return [ + 'worker_type' => $workerType, + 'parallel_workers' => $parallelWorkers, + ]; + } + + private function encodeDefault(): string { + return json_encode($this->defaultValue(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private function encodeNormalized(mixed $rawValue): string { + return json_encode($this->normalizeValue($rawValue), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + +} diff --git a/lib/Service/Policy/RequestSignAuthorizationService.php b/lib/Service/Policy/RequestSignAuthorizationService.php new file mode 100644 index 0000000000..c872bf7ef0 --- /dev/null +++ b/lib/Service/Policy/RequestSignAuthorizationService.php @@ -0,0 +1,46 @@ +policyService->resolveForUser(RequestSignGroupsPolicy::KEY, $user); + $userGroups = $this->groupManager->getUserGroupIds($user); + + return RequestSignGroupsPolicyValue::canUserGroupsRequestSign( + $resolvedPolicy->getEffectiveValue(), + $userGroups, + ); + } +} diff --git a/lib/Service/Policy/Runtime/DefaultPolicyResolver.php b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php new file mode 100644 index 0000000000..07e040fade --- /dev/null +++ b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php @@ -0,0 +1,471 @@ +resolveCore( + $definition, + $context, + $this->source->loadGroupPolicies($definition->key(), $context), + $this->source->loadUserPolicy($definition->key(), $context), + $this->source->loadUserPreference($definition->key(), $context), + ); + } + + /** + * @param list $groupLayers Pre-fetched group layers (avoids repeat DB calls in bulk resolution) + */ + private function resolveCore( + IPolicyDefinition $definition, + PolicyContext $context, + array $groupLayers, + ?PolicyLayer $userPolicy, + ?PolicyLayer $userPreference, + ): ResolvedPolicy { + $policyKey = $definition->key(); + $resolved = (new ResolvedPolicy()) + ->setPolicyKey($policyKey) + ->setAllowedValues($definition->allowedValues($context)); + + $systemLayer = $this->source->loadSystemPolicy($policyKey); + + $currentValue = $definition->defaultSystemValue(); + $currentSourceScope = 'system'; + $currentBlockedBy = null; + $canOverrideBelow = false; + $visible = true; + $actorRole = $context->getActorRole(); + $currentActorCanManageSystemPolicies = $actorRole->canManageSystemPolicies; + $currentActorCanManageGroupPolicies = $actorRole->canManageGroupPolicies && $actorRole->manageableGroupCount > 0; + // Lower-level rule creation is closed by default for implicit system defaults, + // but remains available when the system explicitly delegates downward. + $isSystemExplicitlyGrantedForDescendantRules = $systemLayer !== null + && $systemLayer->getScope() === 'global' + && $systemLayer->isAllowChildOverride(); + + if ($systemLayer !== null) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $systemLayer, + $context, + $currentValue, + $currentSourceScope, + true, + $visible, + ); + } + + if ($definition->resolutionMode() === 'value_choice') { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyValueChoiceGroupLayers( + $definition, + $resolved, + $groupLayers, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } else { + foreach ($groupLayers as $layer) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $layer, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } + } + + if ($userPolicy !== null) { + $canOverrideAtGroupScope = $canOverrideBelow; + $hasConfiguredGroupLayer = $groupLayers !== []; + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $userPolicy, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } else { + $canOverrideAtGroupScope = $canOverrideBelow; + $hasConfiguredGroupLayer = $groupLayers !== []; + } + + $inheritedValue = $currentValue; + + if ($userPreference !== null) { + if (!$definition->supportsUserPreference()) { + $this->source->clearUserPreference($policyKey, $context); + $resolved->setPreferenceWasCleared(true); + } elseif ($this->canApplyLowerLayer($definition, $resolved, $userPreference, $canOverrideBelow, $visible, $context)) { + $currentValue = $definition->normalizeValue($userPreference->getValue()); + $definition->validateValue($currentValue, $context); + $currentSourceScope = $userPreference->getScope(); + } else { + $this->source->clearUserPreference($policyKey, $context); + $currentBlockedBy = $currentSourceScope; + $resolved->setPreferenceWasCleared(true); + } + } + + $requestOverride = $this->source->loadRequestOverride($policyKey, $context); + if ($requestOverride !== null) { + if ($this->canApplyLowerLayer($definition, $resolved, $requestOverride, $canOverrideBelow, $visible, $context)) { + $currentValue = $definition->normalizeValue($requestOverride->getValue()); + $definition->validateValue($currentValue, $context); + $currentSourceScope = $requestOverride->getScope(); + } elseif ($currentBlockedBy === null) { + $currentBlockedBy = $currentSourceScope; + } + } + + $canCreateDescendantRules = ( + $definition->supportsScope(PolicySpec::SCOPE_GROUP) + || $definition->supportsScope(PolicySpec::SCOPE_USER) + ) + && $visible + && $canOverrideBelow + && ( + $currentActorCanManageSystemPolicies + || ( + $currentActorCanManageGroupPolicies + && ( + $isSystemExplicitlyGrantedForDescendantRules + || $hasConfiguredGroupLayer + ) + ) + ); + + $canPersistUserPreference = $definition->supportsScope(PolicySpec::SCOPE_USER) + && $visible + && $canOverrideBelow + && $definition->supportsUserPreference() + && ( + $currentActorCanManageSystemPolicies + || $isSystemExplicitlyGrantedForDescendantRules + || $hasConfiguredGroupLayer + ); + + $resolved + ->setMeta($this->buildResolvedStateMeta($definition, $context, $canCreateDescendantRules)) + ->setEffectiveValue($currentValue) + ->setInheritedValue($inheritedValue) + ->setSourceScope($currentSourceScope) + ->setVisible($visible) + ->setEditableByCurrentActor( + $visible + && $definition->supportsScope(PolicySpec::SCOPE_GROUP) + && $definition->canCurrentActorManageGroupPolicy($context, $systemLayer, $groupLayers) + ) + ->setCanSaveAsUserDefault($canPersistUserPreference) + ->setCanUseAsRequestOverride($canPersistUserPreference) + ->setBlockedBy($currentBlockedBy); + + return $resolved; + } + + /** @return array */ + private function buildResolvedStateMeta(IPolicyDefinition $definition, PolicyContext $context, bool $canCreateDescendantRules): array { + $meta = [ + 'appConfigKey' => $definition->getAppConfigKey(), + 'userPreferenceKey' => $definition->getUserPreferenceKey(), + 'defaultSystemValue' => $definition->defaultSystemValue(), + 'resolutionMode' => $definition->resolutionMode(), + 'supportsUserPreference' => $definition->supportsUserPreference(), + 'supportsGroupAdminDelegation' => $definition->supportsGroupAdminDelegation(), + 'supportedScopes' => $definition->supportedScopes(), + 'backendOnly' => $definition->isBackendOnly(), + 'helper' => $definition->isHelper(), + ]; + + if ($definition->parentPolicyKey() !== null) { + $meta['parentPolicyKey'] = $definition->parentPolicyKey(); + } + + if ($definition->compositeChildren() !== []) { + $meta['compositeChildren'] = $definition->compositeChildren(); + } + + $meta = array_merge($meta, $definition->resolvedStateMeta($context)); + + if ($canCreateDescendantRules) { + $meta['canCreateDescendantRules'] = true; + } + + return $meta; + } + + /** + * @param list $layers + * @return array{0: mixed, 1: string, 2: bool, 3: bool} + */ + private function applyValueChoiceGroupLayers( + IPolicyDefinition $definition, + ResolvedPolicy $resolved, + array $layers, + PolicyContext $context, + mixed $currentValue, + string $currentSourceScope, + bool $canOverrideBelow, + bool $visible, + ): array { + if ($layers === [] || !$visible || !$canOverrideBelow) { + return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible]; + } + + $upstreamAllowedValues = $resolved->getAllowedValues(); + $combinedChoices = []; + $groupDefaultValues = []; + $hasVisibleLayer = false; + + foreach ($layers as $layer) { + if (!$layer->isVisibleToChild()) { + continue; + } + + $hasVisibleLayer = true; + $layerChoices = $this->resolveValueChoiceLayerChoices($definition, $layer, $upstreamAllowedValues, $context); + $combinedChoices = $this->mergeUnionAllowedValues( + $definition->allowedValues($context), + $combinedChoices, + $layerChoices, + ); + + $normalizedDefault = $definition->normalizeValue($layer->getValue()); + if ($layer->getValue() !== null && in_array($normalizedDefault, $combinedChoices, true) && !in_array($normalizedDefault, $groupDefaultValues, true)) { + $groupDefaultValues[] = $normalizedDefault; + } + } + + if (!$hasVisibleLayer || $combinedChoices === []) { + return [$currentValue, $currentSourceScope, false, $visible && $hasVisibleLayer]; + } + + $resolved->setAllowedValues($combinedChoices); + + return [ + $this->pickValueChoiceDefault($definition, $currentValue, $combinedChoices, $groupDefaultValues, $context), + 'group', + count($combinedChoices) > 1, + true, + ]; + } + + #[\Override] + /** @param list $definitions */ + public function resolveMany(array $definitions, PolicyContext $context): array { + $validDefinitions = array_filter( + $definitions, + static fn (mixed $d): bool => $d instanceof IPolicyDefinition, + ); + + $policyKeys = array_map( + static fn (IPolicyDefinition $d): string => $d->key(), + $validDefinitions, + ); + + $allGroupLayers = $this->source->loadAllGroupPolicies($policyKeys, $context); + $allUserPolicies = $this->source->loadAllUserPolicies($policyKeys, $context); + $allUserPrefs = $this->source->loadAllUserPreferences($policyKeys, $context); + + $resolved = []; + foreach ($validDefinitions as $definition) { + $key = $definition->key(); + $resolved[$key] = $this->resolveCore( + $definition, + $context, + $allGroupLayers[$key] ?? [], + $allUserPolicies[$key] ?? null, + $allUserPrefs[$key] ?? null, + ); + } + + return $resolved; + } + + private function applyLayer( + IPolicyDefinition $definition, + ResolvedPolicy $resolved, + PolicyLayer $layer, + PolicyContext $context, + mixed $currentValue, + string $currentSourceScope, + bool $canOverrideBelow, + bool $visible, + ): array { + $visible = $visible && $layer->isVisibleToChild(); + $resolved->setAllowedValues($this->mergeAllowedValues($resolved->getAllowedValues(), $layer->getAllowedValues())); + + if ($layer->getValue() !== null && $canOverrideBelow) { + $normalized = $definition->normalizeValue($layer->getValue()); + try { + $definition->validateValue($normalized, $context); + } catch (\InvalidArgumentException) { + // Stored config value is invalid (e.g. leftover from an unconfigured + // or migrated state). Skip this layer and keep the current/default value. + $canOverrideBelow = $canOverrideBelow && $layer->isAllowChildOverride(); + return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible]; + } + $currentValue = $normalized; + $currentSourceScope = $layer->getScope(); + } + + $canOverrideBelow = $canOverrideBelow && $layer->isAllowChildOverride(); + + return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible]; + } + + private function canApplyLowerLayer( + IPolicyDefinition $definition, + ResolvedPolicy $resolved, + PolicyLayer $layer, + bool $canOverrideBelow, + bool $visible, + PolicyContext $context, + ): bool { + if (!$visible || !$canOverrideBelow || $layer->getValue() === null) { + return false; + } + + $value = $definition->normalizeValue($layer->getValue()); + $allowedValues = $resolved->getAllowedValues(); + if ($allowedValues !== [] && !in_array($value, $allowedValues, true)) { + return false; + } + + $definition->validateValue($value, $context); + return true; + } + + /** @param list $currentAllowedValues + * @param list $layerAllowedValues + * @return list + */ + private function mergeAllowedValues(array $currentAllowedValues, array $layerAllowedValues): array { + if ($layerAllowedValues === []) { + return $currentAllowedValues; + } + + if ($currentAllowedValues === []) { + return $layerAllowedValues; + } + + return array_values(array_intersect($currentAllowedValues, $layerAllowedValues)); + } + + /** + * @param list $upstreamAllowedValues + * @return list + */ + private function resolveValueChoiceLayerChoices( + IPolicyDefinition $definition, + PolicyLayer $layer, + array $upstreamAllowedValues, + PolicyContext $context, + ): array { + if ($layer->isAllowChildOverride()) { + $choices = $layer->getAllowedValues() === [] + ? $upstreamAllowedValues + : array_values(array_intersect($upstreamAllowedValues, $layer->getAllowedValues())); + + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + return array_values(array_filter( + $choices, + static fn (mixed $choice): bool => $choice !== $defaultValue, + )); + } + + if ($layer->getValue() === null) { + return []; + } + + $value = $definition->normalizeValue($layer->getValue()); + if ($upstreamAllowedValues !== [] && !in_array($value, $upstreamAllowedValues, true)) { + return []; + } + + $definition->validateValue($value, $context); + return [$value]; + } + + /** + * @param list $canonicalOrder + * @param list $currentValues + * @param list $newValues + * @return list + */ + private function mergeUnionAllowedValues(array $canonicalOrder, array $currentValues, array $newValues): array { + $merged = []; + foreach ($canonicalOrder as $candidate) { + if ((in_array($candidate, $currentValues, true) || in_array($candidate, $newValues, true)) && !in_array($candidate, $merged, true)) { + $merged[] = $candidate; + } + } + + foreach ([$currentValues, $newValues] as $values) { + foreach ($values as $candidate) { + if (!in_array($candidate, $merged, true)) { + $merged[] = $candidate; + } + } + } + + return $merged; + } + + /** + * @param list $allowedValues + * @param list $groupDefaultValues + */ + private function pickValueChoiceDefault( + IPolicyDefinition $definition, + mixed $currentValue, + array $allowedValues, + array $groupDefaultValues, + PolicyContext $context, + ): mixed { + $normalizedCurrentValue = $definition->normalizeValue($currentValue); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + + if (count($groupDefaultValues) === 1 && in_array($groupDefaultValues[0], $allowedValues, true)) { + return $groupDefaultValues[0]; + } + + if ($normalizedCurrentValue !== $defaultValue && in_array($normalizedCurrentValue, $allowedValues, true)) { + return $normalizedCurrentValue; + } + + $orderedAllowedValues = $this->mergeUnionAllowedValues($definition->allowedValues($context), [], $allowedValues); + return $orderedAllowedValues[0] ?? $normalizedCurrentValue; + } +} diff --git a/lib/Service/Policy/Runtime/PolicyContextFactory.php b/lib/Service/Policy/Runtime/PolicyContextFactory.php new file mode 100644 index 0000000000..91ce3b7bfb --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyContextFactory.php @@ -0,0 +1,165 @@ + $requestOverrides */ + public function forCurrentUser(array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $user = $this->userSession->getUser(); + $actorGroupIds = $activeContext !== null ? $this->getUserGroupIds($user) : null; + $scope = $this->parseActiveGroupScope($activeContext, $user, $actorGroupIds); + return $this->build($user?->getUID(), $user, $requestOverrides, $scope, $user, $actorGroupIds); + } + + public function isCurrentActorSystemAdmin(): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + return $this->groupManager->isAdmin($user->getUID()); + } + + /** @param array $requestOverrides */ + public function forUser(?IUser $user, array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $currentActor = $this->userSession->getUser(); + $actorGroupIds = $activeContext !== null ? $this->getUserGroupIds($currentActor) : null; + $scope = $this->parseActiveGroupScope($activeContext, $currentActor, $actorGroupIds); + return $this->build($user?->getUID(), $user, $requestOverrides, $scope, $currentActor, $actorGroupIds); + } + + /** @param array $requestOverrides */ + public function forUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $user = null; + if ($userId !== null && $userId !== '') { + $loadedUser = $this->userManager->get($userId); + if ($loadedUser instanceof IUser) { + $user = $loadedUser; + } + } + + $currentActor = $this->userSession->getUser(); + $actorGroupIds = $activeContext !== null ? $this->getUserGroupIds($currentActor) : null; + $scope = $this->parseActiveGroupScope($activeContext, $currentActor, $actorGroupIds); + return $this->build($userId, $user, $requestOverrides, $scope, $currentActor, $actorGroupIds); + } + + /** @param array $requestOverrides */ + private function build(?string $userId, ?IUser $user, array $requestOverrides = [], ?ActiveGroupScope $activeGroupScope = null, ?IUser $currentActor = null, ?array $currentActorGroupIds = null): PolicyContext { + $isCurrentActorContext = $user instanceof IUser + && $currentActor instanceof IUser + && $user->getUID() === $currentActor->getUID(); + $sharedGroupIds = []; + if ($isCurrentActorContext) { + $sharedGroupIds = $currentActorGroupIds ?? $this->getUserGroupIds($user); + } + + $actorGroupIds = $currentActorGroupIds ?? ($isCurrentActorContext ? $sharedGroupIds : null); + + $context = (new PolicyContext()) + ->setRequestOverrides($requestOverrides) + ->setActiveGroupScope($activeGroupScope) + ->setActorRole($this->resolveActorRole($currentActor, $actorGroupIds)); + + if ($userId !== null && $userId !== '') { + $context->setUserId($userId); + if ($user instanceof IUser) { + $context->setGroups($isCurrentActorContext ? $sharedGroupIds : $this->getUserGroupIds($user)); + } + } + + return $context; + } + + /** + * @param array|null $activeContext + * @param list|null $currentActorGroupIds + */ + private function parseActiveGroupScope(?array $activeContext, ?IUser $currentActor, ?array $currentActorGroupIds = null): ?ActiveGroupScope { + if ($activeContext === null) { + return null; + } + + $type = $activeContext['type'] ?? null; + $id = $activeContext['id'] ?? null; + if ($type !== 'group' || !is_string($id) || trim($id) === '') { + throw new LibresignException('Only group active context is supported for policy overrides.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + $groupId = trim($id); + if (!$currentActor instanceof IUser) { + throw new LibresignException('You are not allowed to use this policy context.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + $actorGroupIds = $currentActorGroupIds ?? $this->getUserGroupIds($currentActor); + if (!in_array($groupId, $actorGroupIds, true)) { + throw new LibresignException('You are not allowed to use this policy context.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return new ActiveGroupScope($groupId); + } + + private function resolveActorRole(?IUser $currentActor, ?array $currentActorGroupIds = null): ActorRole { + if (!$currentActor instanceof IUser) { + return ActorRole::regularUser(); + } + + $userId = $currentActor->getUID(); + if ($this->groupManager->isAdmin($userId) === true) { + return ActorRole::systemAdmin(); + } + + $actorGroupIds = $currentActorGroupIds ?? $this->getUserGroupIds($currentActor); + $manageableGroupIds = array_values(array_filter( + $actorGroupIds, + static fn (mixed $groupId): bool => is_string($groupId) && trim($groupId) !== '', + )); + if ($manageableGroupIds === []) { + return ActorRole::regularUser(); + } + + if ($this->subAdmin->isSubAdmin($currentActor)) { + return ActorRole::groupAdmin(count($manageableGroupIds)); + } + + return ActorRole::regularUser(); + } + + /** @return list */ + private function getUserGroupIds(?IUser $user): array { + if (!$user instanceof IUser) { + return []; + } + + $groupIds = $this->groupManager->getUserGroupIds($user); + return array_values(array_filter( + $groupIds, + static fn (mixed $groupId): bool => is_string($groupId) && trim($groupId) !== '', + )); + } +} diff --git a/lib/Service/Policy/Runtime/PolicyRegistry.php b/lib/Service/Policy/Runtime/PolicyRegistry.php new file mode 100644 index 0000000000..f1b5b896b1 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyRegistry.php @@ -0,0 +1,85 @@ +> */ + private array $keyToProviderClass = []; + /** @var array */ + private array $providerInstances = []; + /** @var array */ + private array $definitions = []; + + /** + * @param list> $providerClasses + */ + public function __construct( + private ContainerInterface $container, + array $providerClasses = [], + ) { + if ($providerClasses === []) { + $this->keyToProviderClass = PolicyProviders::BY_KEY; + return; + } + + $allowedProviders = array_fill_keys($providerClasses, true); + foreach (PolicyProviders::BY_KEY as $key => $providerClass) { + if (!isset($allowedProviders[$providerClass])) { + continue; + } + + $this->keyToProviderClass[$key] = $providerClass; + } + } + + public function get(string|\BackedEnum $policyKey): IPolicyDefinition { + $policyKeyValue = $this->normalizePolicyKey($policyKey); + $definition = $this->definitions[$policyKeyValue] ?? null; + if ($definition instanceof IPolicyDefinition) { + return $definition; + } + + $providerClass = $this->keyToProviderClass[$policyKeyValue] ?? null; + if (!is_string($providerClass) || $providerClass === '') { + throw new \InvalidArgumentException('Unknown policy key: ' . $policyKeyValue); + } + + $provider = $this->providerInstances[$providerClass] ?? $this->container->get($providerClass); + if (!$provider instanceof IPolicyDefinitionProvider) { + throw new \UnexpectedValueException('Invalid policy provider: ' . $providerClass); + } + + $this->providerInstances[$providerClass] = $provider; + + $definition = $provider->get($policyKeyValue); + if ($definition->key() !== $policyKeyValue) { + throw new \InvalidArgumentException('Policy provider returned mismatched key: ' . $definition->key()); + } + + return $this->definitions[$policyKeyValue] = $definition; + } + + /** @return list */ + public function getAllPolicyKeys(): array { + return array_keys($this->keyToProviderClass); + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Runtime/PolicySource.php b/lib/Service/Policy/Runtime/PolicySource.php new file mode 100644 index 0000000000..fcaf7c21c3 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -0,0 +1,1196 @@ +registry->get($policyKey); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + $hasExplicitSystemValue = $this->appConfig->hasAppKey($definition->getAppConfigKey()); + $storedValue = $hasExplicitSystemValue + ? $this->readSystemValue($definition->getAppConfigKey(), $defaultValue) + : null; + + // An empty string means the key was written directly (bypassing the + // policy API) or cleared via a migration path. Treat it the same as + // having no explicit system value so the default applies cleanly. + if ($storedValue === '') { + $hasExplicitSystemValue = false; + $storedValue = null; + } + + $value = $hasExplicitSystemValue + ? $definition->normalizeValue($storedValue) + : $defaultValue; + + $layer = (new PolicyLayer()) + ->setScope($hasExplicitSystemValue ? 'global' : 'system') + ->setValue($value) + ->setVisibleToChild(true); + + if (!$hasExplicitSystemValue) { + return $layer->setAllowChildOverride(true); + } + + if ($value === $defaultValue) { + $allowChildOverride = $this->appConfig->getAppValueString( + $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()), + '0', + ) === '1'; + + if ($allowChildOverride) { + // Explicitly persisted default value ("let users choose") + return $layer + ->setAllowChildOverride(true) + ->setAllowedValues([]); + } + + return $layer->setAllowChildOverride(true); + } + + $allowChildOverride = $this->appConfig->getAppValueString( + $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()), + '0', + ) === '1'; + + return $layer + ->setAllowChildOverride($allowChildOverride) + ->setAllowedValues($allowChildOverride ? [] : [$value]); + } + + #[\Override] + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array { + $groupIds = $this->resolveGroupIds($context); + if ($groupIds === []) { + return []; + } + + $bindingsByTargetId = []; + foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) { + $bindingsByTargetId[$binding->getTargetId()] = $binding; + } + + $permissionSetIds = []; + foreach ($bindingsByTargetId as $binding) { + $permissionSetIds[] = $binding->getPermissionSetId(); + } + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds(array_values(array_unique($permissionSetIds))) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + $layers = []; + + foreach ($groupIds as $groupId) { + $binding = $bindingsByTargetId[$groupId] ?? null; + if (!$binding instanceof PermissionSetBinding) { + continue; + } + + $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null; + if (!$permissionSet instanceof PermissionSet) { + continue; + } + + $policyConfig = $this->resolveStoredGroupPolicyConfig( + $permissionSet->getDecodedPolicyJson(), + $policyKey, + ); + if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) { + continue; + } + + $layers[] = $this->createGroupPolicyLayer($policyConfig); + } + + return $layers; + } + + #[\Override] + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array { + return []; + } + + #[\Override] + public function loadUserPolicy(string $policyKey, PolicyContext $context): ?PolicyLayer { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return null; + } + + return $this->loadUserPolicyConfig($policyKey, $userId); + } + + #[\Override] + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return null; + } + + $definition = $this->registry->get($policyKey); + $value = $this->appConfig->getUserValue($userId, $definition->getUserPreferenceKey(), ''); + if ($value === '') { + return null; + } + + return (new PolicyLayer()) + ->setScope('user') + ->setValue($definition->normalizeValue($this->deserializeStoredValue($value))); + } + + #[\Override] + public function loadUserPolicyConfig(string $policyKey, string $userId): ?PolicyLayer { + if ($userId === '') { + return null; + } + + $definition = $this->registry->get($policyKey); + $storedPayload = $this->appConfig->getUserValue($userId, $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()), ''); + if ($storedPayload === '') { + return null; + } + + return $this->createUserPolicyLayerFromPayload($definition, $storedPayload); + } + + /** + * @return list + */ + #[\Override] + public function listUserPoliciesByKey(string $policyKey): array { + $definition = $this->registry->get($policyKey); + $assignedPolicyKey = $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()); + + $query = $this->db->getQueryBuilder(); + $query->select('userid', 'configvalue') + ->from('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->eq('configkey', $query->createNamedParameter($assignedPolicyKey))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))); + + $result = $query->executeQuery(); + $policies = []; + try { + while ($row = $result->fetchAssociative()) { + $userId = (string)($row['userid'] ?? ''); + $payload = (string)($row['configvalue'] ?? ''); + if ($userId === '' || $payload === '') { + continue; + } + + $policyLayer = $this->createUserPolicyLayerFromPayload($definition, $payload); + if (!$policyLayer instanceof PolicyLayer) { + continue; + } + + $policies[] = [ + 'targetId' => $userId, + 'policy' => $policyLayer, + ]; + } + } finally { + $result->closeCursor(); + } + + return $policies; + } + + /** + * @param list $policyKeys + * @return array> + */ + #[\Override] + public function loadAllGroupPolicies(array $policyKeys, PolicyContext $context): array { + /** @var array> $result */ + $result = array_fill_keys($policyKeys, []); + + $groupIds = $this->resolveGroupIds($context); + if ($groupIds === []) { + return $result; + } + + $bindingsByTargetId = []; + foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) { + $bindingsByTargetId[$binding->getTargetId()] = $binding; + } + + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $b): int => $b->getPermissionSetId(), + $bindingsByTargetId, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupIds as $groupId) { + $binding = $bindingsByTargetId[$groupId] ?? null; + if (!$binding instanceof PermissionSetBinding) { + continue; + } + + $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null; + if (!$permissionSet instanceof PermissionSet) { + continue; + } + + $policyJson = $permissionSet->getDecodedPolicyJson(); + foreach ($policyKeys as $policyKey) { + $policyConfig = $this->resolveStoredGroupPolicyConfig($policyJson, $policyKey); + if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) { + continue; + } + + $result[$policyKey][] = $this->createGroupPolicyLayer($policyConfig); + } + } + + return $result; + } + + /** + * @param list $policyKeys + * @return array + */ + #[\Override] + public function loadAllUserPolicies(array $policyKeys, PolicyContext $context): array { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return []; + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByAssignedKey = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey', 'configvalue') + ->from('preferences') + ->where($query->expr()->eq('userid', $query->createNamedParameter($userId))) + ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))); + + $result = $query->executeQuery(); + $layers = []; + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByAssignedKey[$row['configkey']] ?? null; + if ($policyKey === null) { + continue; + } + + $definition = $this->registry->get($policyKey); + $decodedPayload = $this->deserializeStoredUserPolicyPayload($row['configvalue']); + if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload) || $decodedPayload['value'] === null) { + continue; + } + + $normalizedValue = $definition->normalizeValue($decodedPayload['value']); + $allowChildOverride = (bool)($decodedPayload['allowChildOverride'] ?? false); + $layers[$policyKey] = (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($normalizedValue) + ->setAllowChildOverride($allowChildOverride) + ->setVisibleToChild(true) + ->setAllowedValues($allowChildOverride ? [] : [$normalizedValue]); + } + } finally { + $result->closeCursor(); + } + + return $layers; + } + + /** + * @param list $policyKeys + * @return array + */ + #[\Override] + public function loadAllUserPreferences(array $policyKeys, PolicyContext $context): array { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return []; + } + + $userPreferenceKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPreferenceKeyByPolicy[$policyKey] = $this->registry->get($policyKey)->getUserPreferenceKey(); + } + $policyKeyByPreferenceKey = array_flip($userPreferenceKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey', 'configvalue') + ->from('preferences') + ->where($query->expr()->eq('userid', $query->createNamedParameter($userId))) + ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPreferenceKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))); + + $result = $query->executeQuery(); + $layers = []; + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByPreferenceKey[$row['configkey']] ?? null; + if ($policyKey === null) { + continue; + } + + $definition = $this->registry->get($policyKey); + $layers[$policyKey] = (new PolicyLayer()) + ->setScope('user') + ->setValue($definition->normalizeValue($this->deserializeStoredValue($row['configvalue']))); + } + } finally { + $result->closeCursor(); + } + + return $layers; + } + + #[\Override] + public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer { + $requestOverrides = $context->getRequestOverrides(); + if (!array_key_exists($policyKey, $requestOverrides)) { + return null; + } + + $definition = $this->registry->get($policyKey); + + return (new PolicyLayer()) + ->setScope('request') + ->setValue($definition->normalizeValue($requestOverrides[$policyKey])); + } + + #[\Override] + public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer { + $permissionSet = $this->findPermissionSetByGroupId($groupId); + if (!$permissionSet instanceof PermissionSet) { + return null; + } + + $policyConfig = $this->resolveStoredGroupPolicyConfig( + $permissionSet->getDecodedPolicyJson(), + $policyKey, + ); + if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) { + return null; + } + + return $this->createGroupPolicyLayer($policyConfig); + } + + /** + * @return list + */ + #[\Override] + public function listGroupPoliciesByKey(string $policyKey): array { + return $this->buildGroupPoliciesByKeyFromBindings( + $policyKey, + $this->bindingMapper->findByTargetType('group'), + ); + } + + /** + * @param list $groupIds + * @return list + */ + #[\Override] + public function listGroupPoliciesByKeyForTargets(string $policyKey, array $groupIds): array { + $groupIds = array_values(array_unique(array_filter( + $groupIds, + static fn (string $groupId): bool => $groupId !== '', + ))); + if ($groupIds === []) { + return []; + } + + return $this->buildGroupPoliciesByKeyFromBindings( + $policyKey, + $this->bindingMapper->findByTargets('group', $groupIds), + ); + } + + /** + * @param list $bindings + * @return list + */ + private function buildGroupPoliciesByKeyFromBindings(string $policyKey, array $bindings): array { + if ($bindings === []) { + return []; + } + + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(), + $bindings, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + $policies = []; + foreach ($bindings as $binding) { + $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null; + if (!$permissionSet instanceof PermissionSet) { + continue; + } + + $policyConfig = $this->resolveStoredGroupPolicyConfig( + $permissionSet->getDecodedPolicyJson(), + $policyKey, + ); + if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) { + continue; + } + + $policies[] = [ + 'targetId' => $binding->getTargetId(), + 'policy' => $this->createGroupPolicyLayer($policyConfig), + ]; + } + + return $policies; + } + + /** + * @param list $groupIds + * @param list $userIds + * @return array + */ + public function loadRuleCounts(array $groupIds, array $userIds): array { + $policyKeys = $this->registry->getAllPolicyKeys(); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = [ + 'groupCount' => 0, + 'userCount' => 0, + 'everyoneCount' => 0, + ]; + } + + $groupIds = array_values(array_unique(array_filter($groupIds, static fn (string $groupId): bool => $groupId !== ''))); + if ($groupIds !== []) { + $groupBindings = $this->bindingMapper->findByTargets('group', $groupIds); + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(), + $groupBindings, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupBindings as $binding) { + $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? []; + $this->incrementGroupRuleCounts($counts, $policyJson); + } + } + + $userIds = array_values(array_unique(array_filter($userIds, static fn (string $userId): bool => $userId !== ''))); + if ($userIds !== []) { + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByUserPreference = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey') + ->selectAlias($query->createFunction('COUNT(DISTINCT userid)'), 'user_count') + ->from('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('userid', $query->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))) + ->groupBy('configkey'); + + $result = $query->executeQuery(); + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByUserPreference[$row['configkey']] ?? null; + if (!is_string($policyKey) || !isset($counts[$policyKey])) { + continue; + } + + $counts[$policyKey]['userCount'] = (int)($row['user_count'] ?? 0); + } + } finally { + $result->closeCursor(); + } + } + + $this->hydrateExplicitSystemRuleCounts($policyKeys, $counts); + + return $counts; + } + + /** + * Count group/user/system rules for ALL known targets (no ID filter). Suitable for system admins. + * + * @return array + */ + public function loadAllRuleCounts(): array { + $policyKeys = $this->registry->getAllPolicyKeys(); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = ['groupCount' => 0, 'userCount' => 0, 'everyoneCount' => 0]; + } + + $groupBindings = $this->bindingMapper->findByTargetType('group'); + if ($groupBindings !== []) { + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(), + $groupBindings, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupBindings as $binding) { + $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? []; + $this->incrementGroupRuleCounts($counts, $policyJson); + } + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByUserPreference = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey') + ->selectAlias($query->createFunction('COUNT(DISTINCT userid)'), 'user_count') + ->from('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))) + ->groupBy('configkey'); + + $result = $query->executeQuery(); + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByUserPreference[$row['configkey']] ?? null; + if (!is_string($policyKey) || !isset($counts[$policyKey])) { + continue; + } + + $counts[$policyKey]['userCount'] = (int)($row['user_count'] ?? 0); + } + } finally { + $result->closeCursor(); + } + + $this->hydrateExplicitSystemRuleCounts($policyKeys, $counts); + + return $counts; + } + + /** + * @param list $policyKeys + * @param array $counts + */ + private function hydrateExplicitSystemRuleCounts(array $policyKeys, array &$counts): void { + $configKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $configKeyByPolicy[$policyKey] = $this->registry->get($policyKey)->getAppConfigKey(); + } + + if ($configKeyByPolicy === []) { + return; + } + + $policyKeyByConfigKey = array_flip($configKeyByPolicy); + + $systemConfigQuery = $this->db->getQueryBuilder(); + $systemConfigQuery->select('configkey') + ->from('appconfig') + ->where($systemConfigQuery->expr()->eq('appid', $systemConfigQuery->createNamedParameter(Application::APP_ID))) + ->andWhere($systemConfigQuery->expr()->in('configkey', $systemConfigQuery->createNamedParameter(array_values($configKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($systemConfigQuery->expr()->neq('configvalue', $systemConfigQuery->createNamedParameter(''))); + + $systemConfigResult = $systemConfigQuery->executeQuery(); + try { + while ($row = $systemConfigResult->fetchAssociative()) { + $policyKey = $policyKeyByConfigKey[$row['configkey'] ?? ''] ?? null; + if (!is_string($policyKey) || !isset($counts[$policyKey])) { + continue; + } + + $counts[$policyKey]['everyoneCount'] = 1; + } + } finally { + $systemConfigResult->closeCursor(); + } + } + + #[\Override] + public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void { + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + $allowOverrideConfigKey = $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()); + + $valuesAreEqual = $normalizedValue === $defaultValue; + if (!$valuesAreEqual && is_string($normalizedValue) && is_string($defaultValue)) { + $d1 = json_decode($normalizedValue, true); + $d2 = json_decode($defaultValue, true); + if (is_array($d1) && is_array($d2)) { + $valuesAreEqual = $d1 === $d2; + } + } + if ($valuesAreEqual) { + if ($allowChildOverride) { + $this->writeSystemValue($definition->getAppConfigKey(), $normalizedValue); + $this->appConfig->setAppValueString($allowOverrideConfigKey, '1'); + return; + } + + $this->clearSystemPolicy($policyKey); + return; + } + + $this->writeSystemValue($definition->getAppConfigKey(), $normalizedValue); + $this->appConfig->setAppValueString($allowOverrideConfigKey, $allowChildOverride ? '1' : '0'); + } + + #[\Override] + public function clearSystemPolicy(string $policyKey): void { + $definition = $this->registry->get($policyKey); + $configKey = $definition->getAppConfigKey(); + $allowOverrideConfigKey = $this->getSystemAllowOverrideConfigKey($configKey); + + if ($this->appConfig->hasAppKey($configKey)) { + $this->appConfig->deleteAppValue($configKey); + } + + if ($this->appConfig->hasAppKey($allowOverrideConfigKey)) { + $this->appConfig->deleteAppValue($allowOverrideConfigKey); + } + } + + private function readSystemValue(string $key, mixed $defaultValue): mixed { + try { + if (is_int($defaultValue)) { + return $this->appConfig->getAppValueInt($key, $defaultValue); + } + + if (is_bool($defaultValue)) { + return $this->appConfig->getAppValueBool($key, $defaultValue); + } + + if (is_float($defaultValue)) { + return $this->appConfig->getAppValueFloat($key, $defaultValue); + } + + if (is_array($defaultValue)) { + return $this->appConfig->getAppValueArray($key, $defaultValue); + } + + return $this->appConfig->getAppValueString($key, (string)$defaultValue); + } catch (AppConfigTypeConflictException $exception) { + if (is_string($defaultValue)) { + try { + $arrayValue = $this->appConfig->getAppValueArray($key, []); + return json_encode($arrayValue, JSON_THROW_ON_ERROR); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getAppValueBool($key, in_array(strtolower(trim($defaultValue)), ['1', 'true', 'yes', 'on'], true)); + } catch (\JsonException) { + return (string)$defaultValue; + } + } + + if (is_bool($defaultValue)) { + return $this->appConfig->getAppValueString($key, $defaultValue ? '1' : '0'); + } + + if (is_array($defaultValue)) { + // Value was stored as a scalar (e.g., by Nextcloud provisioning API). + // Return the raw string so the policy normalizer can decode it. + try { + return $this->appConfig->getAppValueString($key, ''); + } catch (AppConfigTypeConflictException) { + return $defaultValue; + } + } + + throw $exception; + } + } + + private function writeSystemValue(string $key, mixed $value): void { + try { + $this->setSystemValueByType($key, $value); + } catch (AppConfigTypeConflictException) { + if ($this->appConfig->hasAppKey($key)) { + $this->appConfig->deleteAppValue($key); + } + + $this->setSystemValueByType($key, $value); + } + } + + private function setSystemValueByType(string $key, mixed $value): void { + if (is_int($value)) { + $this->appConfig->setAppValueInt($key, $value); + return; + } + + if (is_bool($value)) { + $this->appConfig->setAppValueBool($key, $value); + return; + } + + if (is_float($value)) { + $this->appConfig->setAppValueFloat($key, $value); + return; + } + + if (is_array($value)) { + $this->appConfig->setAppValueArray($key, $value); + return; + } + + $this->appConfig->setAppValueString($key, (string)$value); + } + + /** + * Deserialize a raw appconfig value string based on the expected type. + * Does not perform any database queries - pure local deserialization. + */ + private function deserializeStoredValueWithType(string $storedValueRaw, mixed $defaultValue): mixed { + if (is_int($defaultValue)) { + return (int)$storedValueRaw; + } + + if (is_bool($defaultValue)) { + return in_array(strtolower(trim($storedValueRaw)), ['1', 'true', 'yes', 'on'], true); + } + + if (is_float($defaultValue)) { + return (float)$storedValueRaw; + } + + if (is_array($defaultValue)) { + try { + $decoded = json_decode($storedValueRaw, true, flags: JSON_THROW_ON_ERROR); + return is_array($decoded) ? $decoded : []; + } catch (\JsonException) { + return []; + } + } + + return $storedValueRaw; + } + + private function getSystemAllowOverrideConfigKey(string $policyConfigKey): string { + return $policyConfigKey . '.allow_child_override'; + } + + private function getAssignedUserPolicyKey(string $policyConfigKey): string { + return $policyConfigKey . '.assigned'; + } + + private function serializeStoredValue(mixed $value): string { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + private function deserializeStoredValue(string $value): mixed { + try { + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $value; + } + } + + private function serializeStoredUserPolicyPayload(mixed $value, bool $allowChildOverride): string { + return json_encode([ + 'value' => $value, + 'allowChildOverride' => $allowChildOverride, + ], JSON_THROW_ON_ERROR); + } + + private function createUserPolicyLayerFromPayload(IPolicyDefinition $definition, string $storedPayload): ?PolicyLayer { + $decodedPayload = $this->deserializeStoredUserPolicyPayload($storedPayload); + if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload) || $decodedPayload['value'] === null) { + return null; + } + + $normalizedValue = $definition->normalizeValue($decodedPayload['value']); + $allowChildOverride = (bool)($decodedPayload['allowChildOverride'] ?? false); + + return (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($normalizedValue) + ->setAllowChildOverride($allowChildOverride) + ->setVisibleToChild(true) + ->setAllowedValues($allowChildOverride ? [] : [$normalizedValue]); + } + + private function deserializeStoredUserPolicyPayload(string $payload): mixed { + try { + return json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + } + + /** @param array $policyJson */ + private function resolveStoredGroupPolicyConfig(array $policyJson, string $policyKey): ?array { + $overrideKey = $this->getDelegatedGroupAdminOverrideKey($policyKey); + $delegatedOverride = $policyJson[$overrideKey] ?? null; + if ($this->hasExplicitGroupPolicyConfig($delegatedOverride)) { + $definition = $this->registry->get($policyKey); + if ($definition->supportsGroupAdminDelegation()) { + $resolvedOverride = $delegatedOverride; + if ($this->isGroupPolicyConfigCreatedBySystemAdmin($policyJson[$policyKey] ?? null)) { + $resolvedOverride['delegatedFromSystemCreatedSeed'] = true; + } + + return $resolvedOverride; + } + } + + $policyConfig = $policyJson[$policyKey] ?? null; + return $this->hasExplicitGroupPolicyConfig($policyConfig) ? $policyConfig : null; + } + + private function hasExplicitGroupPolicyConfig(mixed $policyConfig): bool { + return is_array($policyConfig) + && array_key_exists('defaultValue', $policyConfig) + && $policyConfig['defaultValue'] !== null; + } + + private function isGroupPolicyConfigCreatedBySystemAdmin(mixed $policyConfig): bool { + if (!is_array($policyConfig)) { + return false; + } + + return $this->resolveCreatedBySystemAdmin($policyConfig); + } + + private function buildStoredGroupPolicyConfig(mixed $normalizedValue, bool $allowChildOverride, bool $createdBySystemAdmin): array { + return [ + 'defaultValue' => $normalizedValue, + 'allowChildOverride' => $allowChildOverride, + 'visibleToChild' => true, + 'allowedValues' => $allowChildOverride ? [] : [$normalizedValue], + 'createdBySystemAdmin' => $createdBySystemAdmin, + 'createdByActorScope' => $createdBySystemAdmin ? 'system' : 'group', + ]; + } + + /** + * @param array $policyJson + * @param array $counts + */ + private function incrementGroupRuleCounts(array &$counts, array $policyJson): void { + foreach ($policyJson as $policyKey => $policyConfig) { + $baseKey = $this->tryResolveDelegatedOverrideBaseKey($policyKey); + if ($baseKey !== null) { + if ( + isset($counts[$baseKey]) + && !$this->hasExplicitGroupPolicyConfig($policyJson[$baseKey] ?? null) + && $this->hasExplicitGroupPolicyConfig($policyConfig) + ) { + $counts[$baseKey]['groupCount']++; + } + continue; + } + + if (!isset($counts[$policyKey]) || !$this->hasExplicitGroupPolicyConfig($policyConfig)) { + continue; + } + + $counts[$policyKey]['groupCount']++; + } + } + + /** + * @param array $policyJson + */ + private function persistGroupPermissionSet(PermissionSet $permissionSet, string $groupId, array $policyJson, \DateTime $now): void { + $permissionSet->setPolicyJson($policyJson); + $permissionSet->setUpdatedAt($now); + + if ($permissionSet->getId() > 0) { + $this->permissionSetMapper->update($permissionSet); + return; + } + + /** @var PermissionSet $permissionSet */ + $permissionSet = $this->permissionSetMapper->insert($permissionSet); + + $binding = new PermissionSetBinding(); + $binding->setPermissionSetId($permissionSet->getId()); + $binding->setTargetType('group'); + $binding->setTargetId($groupId); + $binding->setCreatedAt($now); + + $this->bindingMapper->insert($binding); + } + + #[\Override] + public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride, bool $createdBySystemAdmin = false, ?PolicyContext $context = null): void { + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $permissionSet = $this->findPermissionSetByGroupId($groupId); + $now = new \DateTime('now', new \DateTimeZone('UTC')); + + if (!$permissionSet instanceof PermissionSet) { + $permissionSet = new PermissionSet(); + $permissionSet->setName('group:' . $groupId); + $permissionSet->setScopeType('group'); + $permissionSet->setCreatedAt($now); + } + + $policyJson = $permissionSet->getDecodedPolicyJson(); + + if ($definition->supportsGroupAdminDelegation()) { + $overrideKey = $this->getDelegatedGroupAdminOverrideKey($policyKey); + $existingSystemCreatedSeed = $this->isGroupPolicyConfigCreatedBySystemAdmin($policyJson[$policyKey] ?? null); + + if ($existingSystemCreatedSeed && !$createdBySystemAdmin) { + $seedRaw = $policyJson[$policyKey]; + $effectiveContext = $context ?? new PolicyContext(); + $definition->validateGroupAdminDelegatedValue( + $normalizedValue, + $definition->normalizeValue($seedRaw['defaultValue'] ?? null), + $effectiveContext, + ); + $policyJson[$overrideKey] = $this->buildStoredGroupPolicyConfig($normalizedValue, $allowChildOverride, false); + $this->persistGroupPermissionSet($permissionSet, $groupId, $policyJson, $now); + return; + } + + // When a system admin saves, clear any stale delegated override. + unset($policyJson[$overrideKey]); + } + + $policyJson[$policyKey] = $this->buildStoredGroupPolicyConfig( + $normalizedValue, + $allowChildOverride, + $createdBySystemAdmin, + ); + + $this->persistGroupPermissionSet($permissionSet, $groupId, $policyJson, $now); + } + + #[\Override] + public function clearGroupPolicy(string $policyKey, string $groupId, bool $preserveSystemCreatedBase = false): void { + $binding = $this->findBindingByGroupId($groupId); + if (!$binding instanceof PermissionSetBinding) { + return; + } + + $permissionSet = $this->findPermissionSetByBinding($binding); + if (!$permissionSet instanceof PermissionSet) { + return; + } + + $policyJson = $permissionSet->getDecodedPolicyJson(); + $definition = $this->registry->get($policyKey); + if ($definition->supportsGroupAdminDelegation()) { + $overrideKey = $this->getDelegatedGroupAdminOverrideKey($policyKey); + $seedConfig = $policyJson[$policyKey] ?? null; + $delegatedOverrideConfig = $policyJson[$overrideKey] ?? null; + $shouldPreserveSystemSeed = $preserveSystemCreatedBase + && $this->isGroupPolicyConfigCreatedBySystemAdmin($seedConfig) + && $this->hasExplicitGroupPolicyConfig($delegatedOverrideConfig) + && !$this->isGroupPolicyConfigCreatedBySystemAdmin($delegatedOverrideConfig); + + unset($policyJson[$overrideKey]); + if (!$shouldPreserveSystemSeed) { + unset($policyJson[$policyKey]); + } + } else { + unset($policyJson[$policyKey]); + } + + if ($policyJson === []) { + $this->bindingMapper->delete($binding); + $this->permissionSetMapper->delete($permissionSet); + return; + } + + $permissionSet->setPolicyJson($policyJson); + $permissionSet->setUpdatedAt(new \DateTime('now', new \DateTimeZone('UTC'))); + $this->permissionSetMapper->update($permissionSet); + } + + #[\Override] + public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + throw new \InvalidArgumentException($this->l10n->t('A signed-in user is required to save a policy preference.')); + } + + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $this->appConfig->setUserValue($userId, $definition->getUserPreferenceKey(), $this->serializeStoredValue($normalizedValue)); + } + + #[\Override] + public function saveUserPolicy(string $policyKey, PolicyContext $context, mixed $value, bool $allowChildOverride): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + throw new \InvalidArgumentException($this->l10n->t('A target user is required to save a user policy.')); + } + + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $this->appConfig->setUserValue( + $userId, + $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()), + $this->serializeStoredUserPolicyPayload($normalizedValue, $allowChildOverride), + ); + } + + #[\Override] + public function clearUserPreference(string $policyKey, PolicyContext $context): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return; + } + + $definition = $this->registry->get($policyKey); + $this->appConfig->deleteUserValue($userId, $definition->getUserPreferenceKey()); + } + + #[\Override] + public function clearUserPolicy(string $policyKey, PolicyContext $context): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return; + } + + $definition = $this->registry->get($policyKey); + $this->appConfig->deleteUserValue($userId, $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey())); + } + + /** @return list */ + private function resolveGroupIds(PolicyContext $context): array { + $scope = $context->getActiveGroupScope(); + if ($scope !== null) { + return [$scope->groupId]; + } + + return $context->getGroups(); + } + + /** @param array $policyConfig */ + private function createGroupPolicyLayer(array $policyConfig): PolicyLayer { + $createdBySystemAdmin = $this->resolveCreatedBySystemAdmin($policyConfig); + $delegatedFromSystemCreatedSeed = isset($policyConfig['delegatedFromSystemCreatedSeed']) + && (bool)$policyConfig['delegatedFromSystemCreatedSeed']; + + $layer = (new PolicyLayer()) + ->setScope('group') + ->setValue($policyConfig['defaultValue'] ?? null) + ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false)) + ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true)) + ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : []) + ->setCreatedBySystemAdmin($createdBySystemAdmin) + ->setDelegatedFromSystemCreatedSeed($delegatedFromSystemCreatedSeed); + + return $layer; + } + + private function resolveCreatedBySystemAdmin(array $policyConfig): bool { + $createdBySystemAdmin = $policyConfig['createdBySystemAdmin'] ?? null; + if (is_bool($createdBySystemAdmin)) { + return $createdBySystemAdmin; + } + + return ($policyConfig['createdByActorScope'] ?? null) === 'system'; + } + + private function getDelegatedGroupAdminOverrideKey(string $policyKey): string { + return $policyKey . '__delegated_override'; + } + + private function tryResolveDelegatedOverrideBaseKey(string $key): ?string { + $suffix = '__delegated_override'; + if (!str_ends_with($key, $suffix)) { + return null; + } + + $baseKey = substr($key, 0, -strlen($suffix)); + try { + $definition = $this->registry->get($baseKey); + return $definition->supportsGroupAdminDelegation() ? $baseKey : null; + } catch (\InvalidArgumentException) { + return null; + } + } + + private function findBindingByGroupId(string $groupId): ?PermissionSetBinding { + try { + return $this->bindingMapper->getByTarget('group', $groupId); + } catch (DoesNotExistException) { + return null; + } + } + + private function findPermissionSetByBinding(PermissionSetBinding $binding): ?PermissionSet { + try { + return $this->permissionSetMapper->getById($binding->getPermissionSetId()); + } catch (DoesNotExistException) { + return null; + } + } + + private function findPermissionSetByGroupId(string $groupId): ?PermissionSet { + $binding = $this->findBindingByGroupId($groupId); + if (!$binding instanceof PermissionSetBinding) { + return null; + } + + return $this->findPermissionSetByBinding($binding); + } +} diff --git a/lib/Service/Policy/ValidationEffectivePolicyService.php b/lib/Service/Policy/ValidationEffectivePolicyService.php new file mode 100644 index 0000000000..315286635e --- /dev/null +++ b/lib/Service/Policy/ValidationEffectivePolicyService.php @@ -0,0 +1,108 @@ + $payload + * @return array + */ + public function appendEffectivePolicies(array $payload): array { + $requesterUserId = $this->extractRequesterUserId($payload); + $resolvedPolicyStates = $requesterUserId !== null + ? $this->policyService->resolveKnownPolicyStatesForUserId($requesterUserId) + : $this->policyService->resolveKnownPolicyStates(); + + $payload['effective_policies'] = [ + 'policies' => $this->preferPolicySnapshotWhenAvailable($resolvedPolicyStates, $payload), + ]; + + return $payload; + } + + /** + * @param array $payload + */ + private function extractRequesterUserId(array $payload): ?string { + $requestedBy = $payload['requested_by'] ?? null; + if (!is_array($requestedBy)) { + return null; + } + + $userId = $requestedBy['userId'] ?? null; + if (!is_string($userId)) { + return null; + } + + $userId = trim($userId); + + return $userId !== '' ? $userId : null; + } + + /** + * @param array> $resolvedPolicyStates + * @param array $payload + * @return array> + */ + private function preferPolicySnapshotWhenAvailable(array $resolvedPolicyStates, array $payload): array { + $metadata = $payload['metadata'] ?? null; + if (!is_array($metadata)) { + return $resolvedPolicyStates; + } + + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return $resolvedPolicyStates; + } + + $legalInformationSnapshot = $policySnapshot[LegalInformationPolicy::KEY] ?? null; + if (!is_array($legalInformationSnapshot)) { + return $resolvedPolicyStates; + } + + $effectiveValue = $legalInformationSnapshot['effectiveValue'] ?? null; + $sourceScope = $legalInformationSnapshot['sourceScope'] ?? null; + if (!is_string($effectiveValue) || !is_string($sourceScope)) { + return $resolvedPolicyStates; + } + + $resolvedPolicyStates[LegalInformationPolicy::KEY] = array_merge( + $resolvedPolicyStates[LegalInformationPolicy::KEY] ?? [ + 'policyKey' => LegalInformationPolicy::KEY, + 'effectiveValue' => '', + 'inheritedValue' => null, + 'sourceScope' => 'system', + 'visible' => true, + 'editableByCurrentActor' => false, + 'allowedValues' => [], + 'canSaveAsUserDefault' => false, + 'canUseAsRequestOverride' => false, + 'preferenceWasCleared' => false, + 'blockedBy' => null, + 'groupCount' => 0, + 'userCount' => 0, + 'everyoneCount' => 0, + ], + [ + 'policyKey' => LegalInformationPolicy::KEY, + 'effectiveValue' => $effectiveValue, + 'sourceScope' => $sourceScope, + ], + ); + + return $resolvedPolicyStates; + } +} diff --git a/lib/Service/Process/ListeningPidResolver.php b/lib/Service/Process/ListeningPidResolver.php index bd3fd37dec..e6691c25df 100644 --- a/lib/Service/Process/ListeningPidResolver.php +++ b/lib/Service/Process/ListeningPidResolver.php @@ -112,14 +112,14 @@ protected function findListeningPidsUsingProc(int $port): ?array { return []; } - $fdPaths = glob('/proc/[0-9]*/fd/[0-9]*'); - if (!is_array($fdPaths)) { + $fdPaths = $this->getProcFdPaths(); + if ($fdPaths === []) { return []; } $pids = []; foreach ($fdPaths as $fdPath) { - $target = @readlink($fdPath); + $target = $this->readProcFdTarget($fdPath); if (!is_string($target) || !preg_match('/^socket:\\[(\\d+)\\]$/', $target, $matches)) { continue; } @@ -137,6 +137,32 @@ protected function findListeningPidsUsingProc(int $port): ?array { return $pids; } + /** + * @return list + */ + protected function getProcFdPaths(): array { + $fdPaths = glob('/proc/[0-9]*/fd/[0-9]*'); + if (!is_array($fdPaths)) { + return []; + } + + return array_values($fdPaths); + } + + protected function readProcFdTarget(string $fdPath): ?string { + set_error_handler(static function (): bool { + return true; + }); + + try { + $target = readlink($fdPath); + } finally { + restore_error_handler(); + } + + return is_string($target) ? $target : null; + } + /** * @return array */ diff --git a/lib/Service/ReminderService.php b/lib/Service/ReminderService.php index 903f40e51a..9a39514972 100644 --- a/lib/Service/ReminderService.php +++ b/lib/Service/ReminderService.php @@ -9,21 +9,22 @@ namespace OCA\Libresign\Service; use DateTime; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\BackgroundJob\Reminder; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicy; +use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicyValue; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; -use OCP\IAppConfig; use OCP\IDateTimeZone; use Psr\Log\LoggerInterface; class ReminderService { public function __construct( protected IJobList $jobList, - protected IAppConfig $appConfig, + protected PolicyService $policyService, protected IDateTimeZone $dateTimeZone, protected ITimeFactory $time, protected SignRequestMapper $signRequestMapper, @@ -33,13 +34,8 @@ public function __construct( } public function getSettings(): array { - $settings = [ - 'days_before' => $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_before', 0), - 'days_between' => $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_between', 0), - 'max' => $this->appConfig->getValueInt(Application::APP_ID, 'reminder_max', 0), - 'send_timer' => $this->appConfig->getValueString(Application::APP_ID, 'reminder_send_timer', '10:00'), - 'next_run' => null, - ]; + $settings = $this->getEffectiveSettings(); + $settings['next_run'] = null; foreach ($this->jobList->getJobsIterator(Reminder::class, 1, 0) as $job) { $details = $this->jobList->getDetailsById($job->getId()); $settings['next_run'] = new \DateTime('@' . $details['last_checked'], new \DateTimeZone('UTC')); @@ -68,31 +64,28 @@ protected function saveConfig( || $daysBefore <= 0 || $max <= 0 ) { - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_before'); - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_between'); - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_max'); - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_send_timer'); - return [ + $normalized = [ 'days_before' => 0, 'days_between' => 0, 'max' => 0, 'send_timer' => '', ]; + + $this->saveSystemSettings($normalized); + return $normalized; } $sendTimer = $this->normalizeTime($sendTimer); - $this->setIfChangedInt('reminder_days_before', $daysBefore); - $this->setIfChangedInt('reminder_days_between', $daysBetween); - $this->setIfChangedInt('reminder_max', $max); - $this->setIfChangedString('reminder_send_timer', $sendTimer); - - return [ + $normalized = [ 'days_before' => $daysBefore, 'days_between' => $daysBetween, 'max' => $max, 'send_timer' => $sendTimer, ]; + + $this->saveSystemSettings($normalized); + return $normalized; } private function normalizeTime(string $time): string { @@ -102,18 +95,20 @@ private function normalizeTime(string $time): string { return $time; } - private function setIfChangedInt(string $key, int $value, int $default = 0): void { - $prev = $this->appConfig->getValueInt(Application::APP_ID, $key, $default); - if ($prev !== $value) { - $this->appConfig->setValueInt(Application::APP_ID, $key, $value); - } + /** @return array{days_before: int, days_between: int, max: int, send_timer: string} */ + private function getEffectiveSettings(): array { + $resolvedValue = $this->policyService->resolve(ReminderPolicy::KEY)->getEffectiveValue(); + return ReminderPolicyValue::normalize($resolvedValue); } - private function setIfChangedString(string $key, string $value, string $default = ''): void { - $prev = $this->appConfig->getValueString(Application::APP_ID, $key, $default); - if ($prev !== $value) { - $this->appConfig->setValueString(Application::APP_ID, $key, $value); - } + /** @param array{days_before: int, days_between: int, max: int, send_timer: string} $settings */ + private function saveSystemSettings(array $settings): void { + $allowChildOverride = $this->policyService->getSystemPolicy(ReminderPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem( + ReminderPolicy::KEY, + ReminderPolicyValue::encode($settings), + $allowChildOverride, + ); } protected function scheduleJob(string $startTime): ?DateTime { @@ -160,15 +155,19 @@ protected function getStartTime(string $startTime): ?\DateTime { } public function sendReminders(): void { - $daysBefore = $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_before', 0); + $settings = $this->getEffectiveSettings(); + + $daysBefore = $settings['days_before']; if ($daysBefore <= 0) { return; } - $daysBetween = $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_between', 0); + + $daysBetween = $settings['days_between']; if ($daysBetween <= 0) { return; } - $max = $this->appConfig->getValueInt(Application::APP_ID, 'reminder_max', 0); + + $max = $settings['max']; if ($max === 0) { return; } diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 5ee56b344b..fb9b7b3214 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\FileMapper; @@ -16,7 +15,6 @@ use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Events\SignRequestCanceledEvent; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; @@ -27,6 +25,7 @@ use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\Policy\FilePolicyApplier; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; @@ -67,6 +66,7 @@ public function __construct( protected EnvelopeFileRelocator $envelopeFileRelocator, protected FileUploadHelper $uploadHelper, protected SignRequestService $signRequestService, + protected FilePolicyApplier $filePolicyApplier, ) { } @@ -88,6 +88,8 @@ public function saveFiles(array $data): array { 'userManager' => $data['userManager'], 'status' => FileStatus::DRAFT->value, 'settings' => $data['settings'], + 'policyOverrides' => $data['policyOverrides'] ?? [], + 'policyActiveContext' => $data['policyActiveContext'] ?? null, ]; if (isset($fileData['uploadedFile'])) { @@ -114,7 +116,8 @@ public function saveFiles(array $data): array { 'signers' => $data['signers'] ?? [], 'status' => $data['status'] ?? FileStatus::DRAFT->value, 'visibleElements' => $data['visibleElements'] ?? [], - 'signatureFlow' => $data['signatureFlow'] ?? null, + 'policyOverrides' => $data['policyOverrides'] ?? [], + 'policyActiveContext' => $data['policyActiveContext'] ?? null, ]); return [ @@ -185,7 +188,13 @@ public function saveEnvelope(array $data): array { $createdNodes[] = $node; $fileData['node'] = $node; - $fileEntity = $this->createFileForEnvelope($fileData, $userManager, $envelopeSettings); + $fileEntity = $this->createFileForEnvelope( + $fileData, + $userManager, + $envelopeSettings, + $data['policyOverrides'] ?? [], + $data['policyActiveContext'] ?? null, + ); $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); $files[] = $fileEntity; } @@ -291,7 +300,13 @@ private function rollbackEnvelope(?FileEntity $envelope): void { } } - private function createFileForEnvelope(array $fileData, ?IUser $userManager, array $settings): FileEntity { + private function createFileForEnvelope( + array $fileData, + ?IUser $userManager, + array $settings, + array $policyOverrides = [], + ?array $policyActiveContext = null, + ): FileEntity { if (!isset($fileData['node'])) { throw new \InvalidArgumentException('Node not provided in file data'); } @@ -305,6 +320,8 @@ private function createFileForEnvelope(array $fileData, ?IUser $userManager, arr 'userManager' => $userManager, 'status' => FileStatus::DRAFT->value, 'settings' => $settings, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, ]); } @@ -316,7 +333,7 @@ private function createFileForEnvelope(array $fileData, ?IUser $userManager, arr public function saveFile(array $data): FileEntity { if (!empty($data['uuid'])) { $file = $this->fileMapper->getByUuid($data['uuid']); - $this->updateSignatureFlowIfAllowed($file, $data); + $this->filePolicyApplier->syncCoreFlowPolicies($file, $data); if (!empty($data['name'])) { $file->setName($data['name']); $this->fileService->update($file); @@ -332,7 +349,7 @@ public function saveFile(array $data): FileEntity { if (!is_null($fileId)) { try { $file = $this->fileMapper->getByNodeId($fileId); - $this->updateSignatureFlowIfAllowed($file, $data); + $this->filePolicyApplier->syncAllPolicies($file, $data); return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); } catch (\Throwable) { } @@ -373,54 +390,12 @@ public function saveFile(array $data): FileEntity { $file->setParentFileId($data['parentFileId']); } - $this->setSignatureFlow($file, $data); - $this->setDocMdpLevelFromGlobalConfig($file); + $this->filePolicyApplier->applyAll($file, $data); $this->fileMapper->insert($file); return $file; } - private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): void { - $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value); - $adminForcedConfig = $adminFlow !== SignatureFlow::NONE->value; - - if ($adminForcedConfig) { - $adminFlowEnum = SignatureFlow::from($adminFlow); - if ($file->getSignatureFlowEnum() !== $adminFlowEnum) { - $file->setSignatureFlowEnum($adminFlowEnum); - $this->fileService->update($file); - } - return; - } - - if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) { - $newFlow = SignatureFlow::from($data['signatureFlow']); - if ($file->getSignatureFlowEnum() !== $newFlow) { - $file->setSignatureFlowEnum($newFlow); - $this->fileService->update($file); - } - } - } - - private function setSignatureFlow(FileEntity $file, array $data): void { - $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value); - - if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) { - $file->setSignatureFlowEnum(SignatureFlow::from($data['signatureFlow'])); - } elseif ($adminFlow !== SignatureFlow::NONE->value) { - $file->setSignatureFlowEnum(SignatureFlow::from($adminFlow)); - } else { - $file->setSignatureFlowEnum(SignatureFlow::NONE); - } - } - - private function setDocMdpLevelFromGlobalConfig(FileEntity $file): void { - if ($this->docMdpConfigService->isEnabled()) { - $docmdpLevel = $this->docMdpConfigService->getLevel(); - $file->setDocmdpLevelEnum($docmdpLevel); - } - } - private function getFileMetadata(\OCP\Files\Node $node): array { $metadata = []; if ($extension = strtolower($node->getExtension())) { diff --git a/lib/Service/RequestSignatureWorkflowService.php b/lib/Service/RequestSignatureWorkflowService.php new file mode 100644 index 0000000000..b16f77a124 --- /dev/null +++ b/lib/Service/RequestSignatureWorkflowService.php @@ -0,0 +1,179 @@ +|null $policy + * @return array{policyOverrides: array, policyActiveContext: array|null} + */ + public function resolvePolicyPayload(?array $policy): array { + return [ + 'policyOverrides' => $this->extractPolicyOverrides($policy), + 'policyActiveContext' => $this->extractPolicyActiveContext($policy), + ]; + } + + /** + * @param array $file + * @param list> $files + * @param list> $signers + * @param array $settings + * @param array|null $policy + * @param list>|null $visibleElements + * @return array{file: FileEntity, children: list} + * @throws LibresignException + */ + public function createRequest( + ?IUser $user, + array $file, + array $files, + string $name, + array $settings, + array $signers, + ?int $status, + ?string $callback, + ?array $policy = null, + ?array $visibleElements = null, + ): array { + if ($file === [] && $files === []) { + throw new LibresignException($this->l10n->t('File or files parameter is required')); + } + + $resolvedPolicy = $this->resolvePolicyPayload($policy); + $data = [ + 'file' => $file, + 'name' => $name, + 'signers' => $signers, + 'callback' => $callback, + 'userManager' => $user, + 'policyOverrides' => $resolvedPolicy['policyOverrides'], + 'policyActiveContext' => $resolvedPolicy['policyActiveContext'], + 'settings' => $settings !== [] ? $settings : ($file['settings'] ?? []), + ]; + + if ($status !== null) { + $data['status'] = $status; + } + + if ($files !== []) { + $data['files'] = $files; + } + + if ($visibleElements !== null) { + $data['visibleElements'] = $visibleElements; + } + + $this->requestSignatureService->validateNewRequestToFile($data); + + if ($files !== []) { + $result = $this->requestSignatureService->saveFiles($data); + return [ + 'file' => $result['file'], + 'children' => $result['children'] ?? [], + ]; + } + + $fileEntity = $this->requestSignatureService->save($data); + + return [ + 'file' => $fileEntity, + 'children' => $this->loadChildFilesIfEnvelope($fileEntity), + ]; + } + + /** + * @param list> $signers + * @param array $file + * @param array|null $policy + * @param list>|null $visibleElements + * @param array $settings + * @return array{file: FileEntity, children: list} + */ + public function updateExistingRequest( + ?IUser $user, + array $signers, + string $uuid, + ?array $visibleElements, + array $file, + ?int $status, + ?array $policy = null, + ?string $name = null, + array $settings = [], + ): array { + $resolvedPolicy = $this->resolvePolicyPayload($policy); + $data = [ + 'uuid' => $uuid, + 'file' => $file, + 'signers' => $signers, + 'userManager' => $user, + 'visibleElements' => $visibleElements, + 'policyOverrides' => $resolvedPolicy['policyOverrides'], + 'policyActiveContext' => $resolvedPolicy['policyActiveContext'], + 'name' => $name, + 'settings' => $settings, + ]; + if ($status !== null) { + $data['status'] = $status; + } + + $this->validateHelper->validateExistingFile($data); + $this->validateHelper->validateFileStatus($data); + $this->validateHelper->validateIdentifySigners($data); + if (!empty($visibleElements)) { + $this->validateHelper->validateVisibleElements($visibleElements, ValidateHelper::TYPE_VISIBLE_ELEMENT_PDF); + } + $fileEntity = $this->requestSignatureService->save($data); + + return [ + 'file' => $fileEntity, + 'children' => $this->loadChildFilesIfEnvelope($fileEntity), + ]; + } + + /** @return list */ + private function loadChildFilesIfEnvelope(FileEntity $fileEntity): array { + return $fileEntity->getParentFileId() === null || $fileEntity->isEnvelope() + ? $this->fileMapper->getChildrenFiles($fileEntity->getId()) + : []; + } + + /** @param array|null $policy + * @return array + */ + private function extractPolicyOverrides(?array $policy): array { + $overrides = $policy['overrides'] ?? null; + + return is_array($overrides) ? $overrides : []; + } + + /** @param array|null $policy + * @return array|null + */ + private function extractPolicyActiveContext(?array $policy): ?array { + $activeContext = $policy['activeContext'] ?? null; + + return is_array($activeContext) ? $activeContext : null; + } +} diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index 3e62b9a747..8feff30120 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -8,8 +8,6 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; -use OCP\IAppConfig; use OCP\ISession; class SessionService { @@ -18,7 +16,6 @@ class SessionService { public function __construct( protected ISession $session, - protected IAppConfig $appConfig, ) { } @@ -46,8 +43,7 @@ public function getIdentifyMethodId(): ?int { return $id; } - public function resetDurationOfSignPage(): void { - $renewalInterval = $this->appConfig->getValueInt(Application::APP_ID, 'renewal_interval', self::NO_RENEWAL_INTERVAL); + public function resetDurationOfSignPage(int $renewalInterval): void { if ($renewalInterval <= self::NO_RENEWAL_INTERVAL) { return; } diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 422a7e30ae..96c73f458b 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -28,7 +28,9 @@ use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Db\UserElementMapper; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Events\SignedEventFactory; +use OCA\Libresign\Exception\FooterStampUnavailableException; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\FooterHandler; @@ -41,6 +43,10 @@ use OCA\Libresign\Service\Envelope\EnvelopeStatusDeterminer; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCA\Libresign\Service\SignRequest\StatusService; use OCP\AppFramework\Db\DoesNotExistException; @@ -121,6 +127,7 @@ public function __construct( private PfxProvider $pfxProvider, private SubjectAlternativeNameService $subjectAlternativeNameService, private SignRequestService $signRequestService, + private PolicyService $policyService, ) { } @@ -594,6 +601,22 @@ private function addCredentialsToJobArgs(array $args, SignRequestEntity $signReq return $args; } + private function runWithVolatileActiveUser(?IUser $user, callable $callback): mixed { + $currentUser = $this->userSession->getUser(); + + if ($user === null || $currentUser?->getUID() === $user->getUID()) { + return $callback(); + } + + $this->userSession->setVolatileActiveUser($user); + + try { + return $callback(); + } finally { + $this->userSession->setVolatileActiveUser($currentUser); + } + } + /** * @return DateTimeInterface|null Last signed date */ @@ -614,7 +637,11 @@ private function signSequentially(array $signRequests): ?DateTimeInterface { $this->validateDocMdpAllowsSignatures(); try { - $signedFile = $this->getEngine()->sign(); + $engine = $this->getEngine(); + $signedFile = $this->runWithVolatileActiveUser( + $this->fileToSign?->getOwner(), + fn (): File => $engine->sign(), + ); } catch (LibresignException|Exception $e) { $this->cleanupUnsignedSignedFile(); $this->recordSignatureAttempt($e); @@ -944,7 +971,10 @@ private function buildBaseSignatureParams(array $certificateData): array { } private function buildValidationUrl(string $uuid): string { - $validationSite = trim($this->appConfig->getValueString(Application::APP_ID, 'validation_site', '')); + $footerPolicy = FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue() + ); + $validationSite = trim($footerPolicy['validationSite']); if ($validationSite !== '') { return rtrim($validationSite, '/') . '/' . $uuid; } @@ -1009,7 +1039,7 @@ private function addMetadataToSignatureParams(array $signatureParams): array { } public function storeUserMetadata(array $metadata = []): self { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); + $collectMetadata = $this->isCollectMetadataEnabled(); if (!$collectMetadata || !$metadata) { return $this; } @@ -1021,6 +1051,10 @@ public function storeUserMetadata(array $metadata = []): self { return $this; } + private function isCollectMetadataEnabled(): bool { + return (bool)$this->policyService->resolve(CollectMetadataPolicy::KEY)->getEffectiveValue(); + } + /** * @return SignRequestEntity[] */ @@ -1338,6 +1372,7 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $originalContent); } $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile); + $this->footerHandler->setRequestPolicyOverrides($this->resolveFooterPolicyRequestOverridesFromFileMetadata()); $footer = $this->footerHandler ->setTemplateVar('uuid', $this->libreSignFile->getUuid()) ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [ @@ -1356,7 +1391,12 @@ protected function getPdfToSign(File $originalFile): File { try { $pdfContent = $this->pdf->applyStamp($input, $stamp); - } catch (RuntimeException $e) { + } catch (FooterStampUnavailableException $e) { + $this->logger->warning('Using original PDF because footer stamping is unavailable.', [ + 'exception' => $e, + ]); + $pdfContent = $originalContent; + } catch (RuntimeException|LibresignException $e) { throw new LibresignException($e->getMessage()); } } else { @@ -1365,6 +1405,33 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $pdfContent); } + /** @return array */ + private function resolveFooterPolicyRequestOverridesFromFileMetadata(): array { + $metadata = $this->libreSignFile->getMetadata(); + if (!is_array($metadata)) { + return []; + } + + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return []; + } + + $footerSnapshot = $policySnapshot[FooterPolicy::KEY] ?? null; + if (!is_array($footerSnapshot)) { + return []; + } + + $effectiveValue = $footerSnapshot['effectiveValue'] ?? null; + if (!is_string($effectiveValue) || trim($effectiveValue) === '') { + return []; + } + + return [ + FooterPolicy::KEY => $effectiveValue, + ]; + } + protected function getSignedFile(): ?File { $nodeId = $this->libreSignFile->getSignedNodeId(); if (!$nodeId) { @@ -1456,7 +1523,8 @@ private function createSignedFile(File $originalFile, string $content): File { $this->l10n->t('signed') . '.' . $originalFile->getExtension(), basename($originalFile->getPath()) ); - $owner = $originalFile->getOwner()->getUID(); + $owner = $originalFile->getOwner(); + $ownerUid = $owner->getUID(); $fileId = $this->libreSignFile->getId(); $extension = $originalFile->getExtension(); @@ -1464,9 +1532,12 @@ private function createSignedFile(File $originalFile, string $content): File { try { /** @var \OCP\Files\Folder */ - $parentFolder = $this->root->getUserFolder($owner)->getFirstNodeById($originalFile->getParentId()); + $parentFolder = $this->root->getUserFolder($ownerUid)->getFirstNodeById($originalFile->getParentId()); - $this->createdSignedFile = $parentFolder->newFile($uniqueFilename, $content); + $this->createdSignedFile = $this->runWithVolatileActiveUser( + $owner, + fn (): File => $parentFolder->newFile($uniqueFilename, $content), + ); return $this->createdSignedFile; } catch (NotPermittedException) { @@ -1565,7 +1636,8 @@ public function getSignerData(?IUser $user, ?SignRequestEntity $signRequest = nu public function getAvailableIdentifyMethodsFromSettings(): array { $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings(); $return = array_map(fn (array $identifyMethod): array => [ - 'mandatory' => $identifyMethod['mandatory'], + 'requirement' => IdentifyMethodRequirement::tryFrom((string)($identifyMethod['requirement'] ?? ''))?->value + ?? IdentifyMethodRequirement::OPTIONAL->value, 'identifiedAtDate' => null, 'validateCode' => false, 'method' => $identifyMethod['name'], diff --git a/lib/Service/SignRequest/ProgressService.php b/lib/Service/SignRequest/ProgressService.php index c226cb31ce..426851828a 100644 --- a/lib/Service/SignRequest/ProgressService.php +++ b/lib/Service/SignRequest/ProgressService.php @@ -471,7 +471,10 @@ public function getEnvelopeProgressForSignRequest(FileEntity $envelope, SignRequ ->getByEnvelopeChildrenAndIdentifyMethod($envelope->getId(), $signRequest->getId()); $childSignRequestsByFileId = []; foreach ($childSignRequests as $childSignRequest) { - $childSignRequestsByFileId[$childSignRequest->getFileId()] = $childSignRequest; + $fileId = $childSignRequest->getFileId(); + if ($fileId !== null) { + $childSignRequestsByFileId[$fileId] = $childSignRequest; + } } $files = array_map(function (FileEntity $child) use ($signRequest, $childSignRequestsByFileId): array { diff --git a/lib/Service/SignatureBackgroundService.php b/lib/Service/SignatureBackgroundService.php index e6a0d7534b..93e02008b2 100644 --- a/lib/Service/SignatureBackgroundService.php +++ b/lib/Service/SignatureBackgroundService.php @@ -11,8 +11,10 @@ use Exception; use Imagick; use ImagickPixel; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Files\TSimpleFile; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicy; +use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue; use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; @@ -32,6 +34,8 @@ public function __construct( private IAppConfig $appConfig, private IConfig $config, private ITempManager $tempManager, + private SignatureTextService $signatureTextService, + private PolicyService $policyService, ) { } @@ -42,6 +46,7 @@ private function getRootFolder(): ISimpleFolder { return $this->appData->newFolder('signature'); } } + public function updateImage(string $tmpFile): void { $folder = $this->getRootFolder(); $detectedMimeType = mime_content_type($tmpFile); @@ -51,13 +56,26 @@ public function updateImage(string $tmpFile): void { $content = $this->optmizeImage(file_get_contents($tmpFile)); - $this->appConfig->setValueString(Application::APP_ID, 'signature_background_type', 'custom'); + $this->saveSystemBackgroundType('custom'); $target = $folder->newFile('background.png'); $target->putContent($content); } public function getSignatureBackgroundType(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'signature_background_type', 'default'); + $stampValue = SignatureTextPolicyValue::normalize( + $this->policyService->resolve(SignatureTextPolicy::KEY)->getEffectiveValue(), + ); + $value = $stampValue['background_type'] ?? 'default'; + if (!is_string($value)) { + return 'default'; + } + + $normalized = trim(strtolower($value)); + if (!in_array($normalized, ['default', 'custom', 'deleted'], true)) { + return 'default'; + } + + return $normalized; } public function isEnabled(): bool { @@ -87,8 +105,8 @@ private function optmizeImage(string $content, float $opacity = 1): string { } private function scaleDimensions(int $width, int $height): array { - $signatureWidth = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', SignatureTextService::DEFAULT_SIGNATURE_WIDTH); - $signatureHeight = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_height', SignatureTextService::DEFAULT_SIGNATURE_HEIGHT); + $signatureWidth = $this->signatureTextService->getFullSignatureWidth(); + $signatureHeight = $this->signatureTextService->getFullSignatureHeight(); $maxWidth = $signatureWidth * self::SCALE_FACTOR; $maxHeight = $signatureHeight * self::SCALE_FACTOR; @@ -107,9 +125,29 @@ private function scaleDimensions(int $width, int $height): array { return ['width' => $returnWidth, 'height' => $returnHeight]; } + /** + * @return array{width: int, height: int, x: int, y: int} + */ + private function fitWithinBounds(int $width, int $height, int $maxWidth, int $maxHeight): array { + if ($width <= 0 || $height <= 0 || $maxWidth <= 0 || $maxHeight <= 0) { + return ['width' => 0, 'height' => 0, 'x' => 0, 'y' => 0]; + } + + $scale = min($maxWidth / $width, $maxHeight / $height); + $fitWidth = max(1, (int)floor($width * $scale)); + $fitHeight = max(1, (int)floor($height * $scale)); + + return [ + 'width' => $fitWidth, + 'height' => $fitHeight, + 'x' => (int)floor(max(0, $maxWidth - $fitWidth) / 2), + 'y' => (int)floor(max(0, $maxHeight - $fitHeight) / 2), + ]; + } + public function delete(): void { try { - $this->appConfig->setValueString(Application::APP_ID, 'signature_background_type', 'deleted'); + $this->saveSystemBackgroundType('deleted'); $file = $this->getRootFolder()->getFile('background.png'); $file->delete(); } catch (NotFoundException|NotPermittedException) { @@ -118,23 +156,49 @@ public function delete(): void { public function reset(): void { try { - $this->appConfig->deleteKey(Application::APP_ID, 'signature_background_type'); + $this->saveSystemBackgroundType('default'); $file = $this->getRootFolder()->getFile('background.png'); $file->delete(); } catch (NotFoundException|NotPermittedException) { } } + private function saveSystemBackgroundType(string $value): void { + $stampValue = SignatureTextPolicyValue::normalize( + $this->policyService->resolve(SignatureTextPolicy::KEY)->getEffectiveValue(), + ); + $stampValue['background_type'] = $value; + + $allowChildOverride = $this->policyService->getSystemPolicy(SignatureTextPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem( + SignatureTextPolicy::KEY, + SignatureTextPolicyValue::encode($stampValue), + $allowChildOverride, + ); + } + public function getImage(): ISimpleFile { try { $file = $this->getRootFolder()->getFile('background.png'); } catch (NotFoundException) { - $content = $this->optmizeImage(file_get_contents(__DIR__ . '/../../img/logo-gray.svg'), 0.15); + $content = $this->getDefaultImageBlob(); $file = new InMemoryFile('background.png', $content); } return $file; } + public function getDefaultImageBlob(): string { + return $this->optmizeImage(file_get_contents(__DIR__ . '/../../img/logo-gray.svg'), 0.15); + } + + public function getCustomImageBlob(): ?string { + try { + return $this->getRootFolder()->getFile('background.png')->getContent(); + } catch (NotFoundException) { + return null; + } + } + public function getImagePath(): string { try { $filePath = $this->getRootFolder()->getFile('background.png'); diff --git a/lib/Service/SignatureStampPreview/SignatureStampAppearanceBuilder.php b/lib/Service/SignatureStampPreview/SignatureStampAppearanceBuilder.php new file mode 100644 index 0000000000..b4ba4a0640 --- /dev/null +++ b/lib/Service/SignatureStampPreview/SignatureStampAppearanceBuilder.php @@ -0,0 +1,169 @@ +format(\DateTimeInterface::ATOM); + + $textData = $this->signatureTextService->parse($template, $context); + $parsed = trim((string)($textData['parsed'] ?? '')); + + $descFontSize = $templateFontSize + ?? (float)($textData['templateFontSize'] ?? $this->signatureTextService->getTemplateFontSize()); + $descLineHeight = $descFontSize * 1.0; + $leftPadding = max(2.0, $descFontSize * 0.15); + + $isDescriptionOnly = $renderMode === SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY; + $textStartX = $isDescriptionOnly ? $leftPadding : ((float)$width / 2.0) + $leftPadding; + $availableWidth = $isDescriptionOnly ? (float)$width : (float)$width / 2.0; + + $stream = ''; + + if ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) { + $commonName = !empty($context['SignerCommonName']) + ? (string)$context['SignerCommonName'] + : ($fallbackCommonName ?? ''); + + if ($commonName !== '') { + $nameFontSize = $signatureFontSize ?? $this->signatureTextService->getSignatureFontSize(); + $leftHalfW = (float)$width / 2.0; + $nameLines = $this->wrapTextForPdf($commonName, $leftHalfW - $leftPadding * 2, $nameFontSize); + $nameLineCount = count($nameLines); + $totalNameHeight = $nameLineCount * $nameFontSize * 1.0; + $nameStartY = ((float)$height + $totalNameHeight) / 2.0 - $nameFontSize; + $nameStartY = max(0.0, $nameStartY); + $nameY = $nameStartY; + $estimatedCharWidth = $nameFontSize * 0.52; + foreach ($nameLines as $nameLine) { + $lineWidth = strlen($nameLine) * $estimatedCharWidth; + $nameX = max($leftPadding, ($leftHalfW - $lineWidth) / 2.0); + $escaped = $this->escapePdfText($nameLine); + $stream .= "BT\n"; + $stream .= sprintf("/F1 %.2F Tf\n", $nameFontSize); + $stream .= "0 0 0 rg\n"; + $stream .= sprintf("%.2F %.2F Td\n", $nameX, $nameY); + $stream .= sprintf("(%s) Tj\n", $escaped); + $stream .= "ET\n"; + $nameY -= $nameFontSize * 1.0; + } + } + } + + $currentY = (float)$height - $descFontSize - 2.0; + foreach (explode(PHP_EOL, $parsed) as $line) { + $wrappedLines = $this->wrapTextForPdf($line, $availableWidth, $descFontSize); + foreach ($wrappedLines as $wrappedLine) { + if ($currentY < 0) { + break 2; + } + $escaped = $this->escapePdfText($wrappedLine); + $stream .= "BT\n"; + $stream .= sprintf("/F1 %.2F Tf\n", $descFontSize); + $stream .= "0 0 0 rg\n"; + $stream .= sprintf("%.2F %.2F Td\n", $textStartX, $currentY); + $stream .= sprintf("(%s) Tj\n", $escaped); + $stream .= "ET\n"; + $currentY -= $descLineHeight; + } + } + + return new SignatureAppearanceXObjectDto( + stream: $stream, + resources: [ + 'Font' => [ + 'F1' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'BaseFont' => '/Helvetica', + ], + ], + ], + ); + } + + /** + * @return string[] + */ + public function wrapTextForPdf(string $line, float $availableWidth, float $fontSize): array { + $trimmed = trim($line); + if ($trimmed === '') { + return ['']; + } + + $estimatedCharWidth = max(1.0, $fontSize * 0.52); + $maxChars = max(1, (int)floor($availableWidth / $estimatedCharWidth)); + if (strlen($trimmed) <= $maxChars) { + return [$trimmed]; + } + + $result = []; + $current = ''; + foreach (preg_split('/\s+/', $trimmed) ?: [] as $word) { + if ($word === '') { + continue; + } + + $candidate = $current === '' ? $word : $current . ' ' . $word; + if (strlen($candidate) <= $maxChars) { + $current = $candidate; + continue; + } + + if ($current !== '') { + $result[] = $current; + $current = ''; + } + + while (strlen($word) > $maxChars) { + $result[] = substr($word, 0, $maxChars); + $word = substr($word, $maxChars); + } + + $current = $word; + } + + if ($current !== '') { + $result[] = $current; + } + + return $result; + } + + public function escapePdfText(string $value): string { + $value = str_replace('\\', '\\\\', $value); + $value = str_replace('(', '\\(', $value); + $value = str_replace(')', '\\)', $value); + + return $value; + } +} diff --git a/lib/Service/SignatureStampPreview/SignatureStampPreviewNativeService.php b/lib/Service/SignatureStampPreview/SignatureStampPreviewNativeService.php new file mode 100644 index 0000000000..01b2b5c267 --- /dev/null +++ b/lib/Service/SignatureStampPreview/SignatureStampPreviewNativeService.php @@ -0,0 +1,402 @@ +appearanceBuilder->buildXObject( + width: (int)round($width), + height: (int)round($height), + renderMode: $renderMode, + context: $this->buildPreviewContext(), + template: $template, + templateFontSize: $templateFontSize, + signatureFontSize: $signatureFontSize, + fallbackCommonName: $this->signatureTextService->getPreviewSignerName(), + ); + + $contentStream = $xObject->stream; + + $backgroundJpeg = $this->resolveBackgroundJpeg($backgroundType); + $previewSignatureImage = $this->getPreviewSignatureImage($renderMode); + + return $this->buildSinglePagePdf($width, $height, $contentStream, $backgroundJpeg, $previewSignatureImage, $renderMode); + } + + /** + * @return array + */ + private function buildPreviewContext(): array { + $previewSignerName = $this->signatureTextService->getPreviewSignerName(); + + return [ + 'DocumentUUID' => self::PREVIEW_DOCUMENT_UUID, + 'IssuerCommonName' => self::PREVIEW_ISSUER_COMMON_NAME, + 'LocalSignerSignatureDateOnly' => '2026-05-20', + 'LocalSignerSignatureDateTime' => '2026-05-20T14:30:00+00:00', + 'LocalSignerTimezone' => 'UTC', + 'ServerSignatureDate' => '2026-05-20T14:30:00+00:00', + 'SignerCommonName' => $previewSignerName, + 'SignerEmail' => self::PREVIEW_SIGNER_EMAIL, + 'SignerIdentifier' => self::PREVIEW_SIGNER_IDENTIFIER, + 'SignerIP' => self::PREVIEW_SIGNER_IP, + 'SignerUserAgent' => self::PREVIEW_SIGNER_USER_AGENT, + ]; + } + + /** + * Loads the preview signature asset (PNG converted to JPEG) for placement in the preview PDF. + * + * The asset is located at img/preview_signature.png and is embedded directly into the + * preview PDF for graphic modes. This simplifies the implementation significantly compared + * to procedurally drawing Bezier curves, and makes the preview nature of the graphic clear. + * + * @return array{kind:'rgba',width:int,height:int,rgbData:string,alphaData:string}|null + */ + private function getPreviewSignatureImage(string $renderMode): ?array { + if ( + $renderMode !== SignerElementsService::RENDER_MODE_GRAPHIC_ONLY + && $renderMode !== SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION + ) { + return null; + } + + $assetPath = dirname(__DIR__, 3) . '/img/preview_signature.png'; + if (!file_exists($assetPath)) { + return null; + } + $blob = @file_get_contents($assetPath); + if (!is_string($blob) || $blob === '') { + return null; + } + + return $this->convertImageBlobToPdfRgbaImage($blob); + } + + /** + * @param array{width:int,height:int,data:string}|null $backgroundJpeg + * @param array{kind:'rgba',width:int,height:int,rgbData:string,alphaData:string}|null $previewSignatureImage + */ + private function buildSinglePagePdf(float $width, float $height, string $contentStream, ?array $backgroundJpeg, ?array $previewSignatureImage, string $renderMode): string { + $widthFormatted = number_format($width, 2, '.', ''); + $heightFormatted = number_format($height, 2, '.', ''); + $stream = ''; + $xObjectReferences = []; + $nextObjectId = 5; + + $objects = [ + 1 => '<< /Type /Catalog /Pages 2 0 R >>', + 2 => '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + 4 => '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + + if ($backgroundJpeg !== null) { + $fit = $this->fitWithinBounds( + width: $backgroundJpeg['width'], + height: $backgroundJpeg['height'], + maxWidth: (int)round($width), + maxHeight: (int)round($height), + ); + if ($fit['width'] > 0 && $fit['height'] > 0) { + $stream .= sprintf( + "q\n%d 0 0 %d %d %d cm\n/Im1 Do\nQ\n", + $fit['width'], + $fit['height'], + $fit['x'], + $fit['y'], + ); + $backgroundObjectId = $nextObjectId; + $nextObjectId += 1; + $xObjectReferences[] = '/Im1 ' . $backgroundObjectId . ' 0 R'; + $objects[$backgroundObjectId] = sprintf( + "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n%sendstream", + $backgroundJpeg['width'], + $backgroundJpeg['height'], + strlen($backgroundJpeg['data']), + $backgroundJpeg['data'], + ); + } + } + + if ($previewSignatureImage !== null) { + $maxSignatureWidth = $renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY + ? (int)round($width) + : (int)round($width / 2.0); + $fit = $this->fitWithinBounds( + width: $previewSignatureImage['width'], + height: $previewSignatureImage['height'], + maxWidth: $maxSignatureWidth, + maxHeight: (int)round($height), + allowUpscale: false, + ); + if ($fit['width'] > 0 && $fit['height'] > 0) { + $stream .= sprintf( + "q\n%d 0 0 %d %d %d cm\n/Im2 Do\nQ\n", + $fit['width'], + $fit['height'], + $fit['x'], + $fit['y'], + ); + $signatureObjectId = $nextObjectId; + $nextObjectId += 1; + + $alphaMaskObjectId = $nextObjectId; + $nextObjectId += 1; + + $xObjectReferences[] = '/Im2 ' . $signatureObjectId . ' 0 R'; + + $alphaData = gzcompress($previewSignatureImage['alphaData']) ?: ''; + $objects[$alphaMaskObjectId] = sprintf( + "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n%sendstream", + $previewSignatureImage['width'], + $previewSignatureImage['height'], + strlen($alphaData), + $alphaData, + ); + + $rgbData = gzcompress($previewSignatureImage['rgbData']) ?: ''; + $objects[$signatureObjectId] = sprintf( + "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask %d 0 R /Filter /FlateDecode /Length %d >>\nstream\n%sendstream", + $previewSignatureImage['width'], + $previewSignatureImage['height'], + $alphaMaskObjectId, + strlen($rgbData), + $rgbData, + ); + } + } + + $stream .= "q\n" . $contentStream . "Q\n"; + $contentObjectId = $nextObjectId; + + $xObjectDict = $xObjectReferences !== [] ? ' /XObject << ' . implode(' ', $xObjectReferences) . ' >>' : ''; + $objects[3] = sprintf( + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %s %s] /Resources << /Font << /F1 4 0 R >>%s >> /Contents %d 0 R >>', + $widthFormatted, + $heightFormatted, + $xObjectDict, + $contentObjectId, + ); + + $objects[$contentObjectId] = sprintf( + "<< /Length %d >>\nstream\n%sendstream", + strlen($stream), + $stream, + ); + + return $this->assemblePdf($objects); + } + + /** + * @return array{width:int,height:int,data:string}|null + */ + private function resolveBackgroundJpeg(string $backgroundType): ?array { + if ($backgroundType === 'deleted') { + return null; + } + + $blob = null; + try { + if ($backgroundType === 'default') { + $blob = $this->signatureBackgroundService->getDefaultImageBlob(); + } elseif ($backgroundType === 'custom') { + $blob = $this->signatureBackgroundService->getCustomImageBlob(); + if ($blob === null) { + $blob = $this->signatureBackgroundService->getDefaultImageBlob(); + } + } else { + $blob = $this->signatureBackgroundService->getImage()->getContent(); + } + } catch (NotFoundException|\Throwable) { + return null; + } + + if (!is_string($blob) || $blob === '') { + return null; + } + + return $this->convertImageBlobToJpeg($blob); + } + + /** + * @return array{width:int,height:int,data:string}|null + */ + private function convertImageBlobToJpeg(string $blob): ?array { + try { + $image = new Imagick(); + $image->readImageBlob($blob); + $image->setImageBackgroundColor('white'); + $image = $image->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN); + $image->setImageFormat('jpeg'); + $image->setImageCompressionQuality(85); + + $width = max(1, (int)$image->getImageWidth()); + $height = max(1, (int)$image->getImageHeight()); + $data = $image->getImageBlob(); + $image->destroy(); + + return [ + 'width' => $width, + 'height' => $height, + 'data' => $data, + ]; + } catch (\Throwable) { + return null; + } + } + + /** + * @return array{width:int,height:int,x:int,y:int} + */ + private function fitWithinBounds(int $width, int $height, int $maxWidth, int $maxHeight, bool $allowUpscale = true): array { + if ($width <= 0 || $height <= 0 || $maxWidth <= 0 || $maxHeight <= 0) { + return ['width' => 0, 'height' => 0, 'x' => 0, 'y' => 0]; + } + + $scale = min($maxWidth / $width, $maxHeight / $height); + if (!$allowUpscale) { + $scale = min(1.0, $scale); + } + $fitWidth = max(1, (int)floor($width * $scale)); + $fitHeight = max(1, (int)floor($height * $scale)); + + return [ + 'width' => $fitWidth, + 'height' => $fitHeight, + 'x' => (int)floor(max(0, $maxWidth - $fitWidth) / 2), + 'y' => (int)floor(max(0, $maxHeight - $fitHeight) / 2), + ]; + } + + /** + * @return array{kind:'rgba',width:int,height:int,rgbData:string,alphaData:string}|null + */ + private function convertImageBlobToPdfRgbaImage(string $blob): ?array { + try { + $image = new Imagick(); + $image->readImageBlob($blob); + + $width = max(1, (int)$image->getImageWidth()); + $height = max(1, (int)$image->getImageHeight()); + + $rgbPixels = $image->exportImagePixels(0, 0, $width, $height, 'RGB', Imagick::PIXEL_CHAR); + $alphaPixels = $image->exportImagePixels(0, 0, $width, $height, 'A', Imagick::PIXEL_CHAR); + + if (!is_array($rgbPixels) || !is_array($alphaPixels)) { + $image->destroy(); + return null; + } + + $rgbData = $this->pixelsToBinary($rgbPixels); + $alphaData = $this->pixelsToBinary($alphaPixels); + + $image->destroy(); + + if ($rgbData === '' || $alphaData === '') { + return null; + } + + return [ + 'kind' => 'rgba', + 'width' => $width, + 'height' => $height, + 'rgbData' => $rgbData, + 'alphaData' => $alphaData, + ]; + } catch (\Throwable) { + return null; + } + } + + /** + * @param list $pixels + */ + private function pixelsToBinary(array $pixels): string { + $data = ''; + $chunk = []; + $chunkSize = 8192; + + foreach ($pixels as $value) { + $chunk[] = (int)$value; + if (count($chunk) >= $chunkSize) { + $data .= pack('C*', ...$chunk); + $chunk = []; + } + } + + if ($chunk !== []) { + $data .= pack('C*', ...$chunk); + } + + return $data; + } + + /** + * @param array $objects + */ + private function assemblePdf(array $objects): string { + $pdf = "%PDF-1.4\n"; + $offsets = [0 => 0]; + + $objectCount = count($objects); + for ($i = 1; $i <= $objectCount; $i++) { + $offsets[$i] = strlen($pdf); + $pdf .= sprintf("%d 0 obj\n%s\nendobj\n", $i, $objects[$i]); + } + + $xrefOffset = strlen($pdf); + $pdf .= sprintf("xref\n0 %d\n", $objectCount + 1); + $pdf .= "0000000000 65535 f \n"; + for ($i = 1; $i <= $objectCount; $i++) { + $pdf .= sprintf("%010d 00000 n \n", $offsets[$i]); + } + + $pdf .= sprintf( + "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", + $objectCount + 1, + $xrefOffset, + ); + + return $pdf; + } +} diff --git a/lib/Service/SignatureTextService.php b/lib/Service/SignatureTextService.php index 283cf0b752..183f963344 100644 --- a/lib/Service/SignatureTextService.php +++ b/lib/Service/SignatureTextService.php @@ -13,8 +13,13 @@ use Imagick; use ImagickDraw; use ImagickPixel; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; +use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicy as SignatureTextPolicyProvider; +use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue; use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color; use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding; use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel; @@ -24,7 +29,6 @@ use OCA\Libresign\Vendor\Twig\Environment; use OCA\Libresign\Vendor\Twig\Error\SyntaxError; use OCA\Libresign\Vendor\Twig\Loader\FilesystemLoader; -use OCP\IAppConfig; use OCP\IDateTimeZone; use OCP\IL10N; use OCP\IRequest; @@ -34,22 +38,19 @@ use Sabre\DAV\UUIDUtil; class SignatureTextService { - public const TEMPLATE_DEFAULT_FONT_SIZE = 10; - public const SIGNATURE_DEFAULT_FONT_SIZE = 20; public const SIGNATURE_DIMENSION_MINIMUM = 1; public const FONT_SIZE_MINIMUM = 0.1; public const FRONT_SIZE_MAX = 30; - public const DEFAULT_SIGNATURE_WIDTH = 350; - public const DEFAULT_SIGNATURE_HEIGHT = 100; private const int QRCODE_SIZE = 100; + public function __construct( - private IAppConfig $appConfig, private IL10N $l10n, private IDateTimeZone $dateTimeZone, private IRequest $request, private IUserSession $userSession, private IURLGenerator $urlGenerator, protected LoggerInterface $logger, + private PolicyService $policyService, ) { } @@ -59,10 +60,10 @@ public function __construct( */ public function save( string $template, - float $templateFontSize = self::TEMPLATE_DEFAULT_FONT_SIZE, - float $signatureFontSize = self::SIGNATURE_DEFAULT_FONT_SIZE, - float $signatureWidth = self::DEFAULT_SIGNATURE_WIDTH, - float $signatureHeight = self::DEFAULT_SIGNATURE_HEIGHT, + float $templateFontSize = SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE, + float $signatureFontSize = SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE, + float $signatureWidth = SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH, + float $signatureHeight = SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT, string $renderMode = SignerElementsService::RENDER_MODE_DEFAULT, ): array { if ($templateFontSize > self::FRONT_SIZE_MAX || $templateFontSize < self::FONT_SIZE_MINIMUM) { @@ -111,12 +112,14 @@ public function save( $template = strip_tags((string)$template); $template = trim($template); $template = html_entity_decode($template); - $this->appConfig->setValueString(Application::APP_ID, 'signature_text_template', $template); - $this->appConfig->setValueFloat(Application::APP_ID, 'signature_width', $signatureWidth); - $this->appConfig->setValueFloat(Application::APP_ID, 'signature_height', $signatureHeight); - $this->appConfig->setValueFloat(Application::APP_ID, 'template_font_size', $templateFontSize); - $this->appConfig->setValueFloat(Application::APP_ID, 'signature_font_size', $signatureFontSize); - $this->appConfig->setValueString(Application::APP_ID, 'signature_render_mode', $renderMode); + $resolvedConfig = $this->getSignatureStampPolicyConfig(); + $resolvedConfig['template'] = $template; + $resolvedConfig['signature_width'] = $signatureWidth; + $resolvedConfig['signature_height'] = $signatureHeight; + $resolvedConfig['template_font_size'] = $templateFontSize; + $resolvedConfig['signature_font_size'] = $signatureFontSize; + $resolvedConfig['render_mode'] = $this->normalizePersistedRenderMode($renderMode); + $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY, SignatureTextPolicyValue::encode($resolvedConfig)); return $this->parse($template); } @@ -192,23 +195,31 @@ public function parse(string $template = '', array $context = []): array { } public function getTemplate(): string { - if ($this->appConfig->hasKey(Application::APP_ID, 'signature_text_template')) { - return $this->appConfig->getValueString(Application::APP_ID, 'signature_text_template'); - } - return $this->getDefaultTemplate(); + $config = $this->getSignatureStampPolicyConfig(); + return (string)($config['template'] ?? ''); } public function getAvailableVariables(): array { $list = [ + // TRANSLATORS Description for the template variable {{DocumentUUID}} shown in the signature template helper list. '{{DocumentUUID}}' => $this->l10n->t('Unique identifier of the signed document'), + // TRANSLATORS Description for the template variable {{IssuerCommonName}} shown in the signature template helper list. '{{IssuerCommonName}}' => $this->l10n->t('Name of the certificate issuer used for the signature.'), - '{{LocalSignerSignatureDateOnly}}' => $this->l10n->t('Date when the signer sent the request to sign (without time, in their local time zone).'), - '{{LocalSignerSignatureDateTime}}' => $this->l10n->t('Date and time when the signer sent the request to sign (in their local time zone).'), - '{{LocalSignerTimezone}}' => $this->l10n->t('Time zone of signer when sent the request to sign (in their local time zone).'), + // TRANSLATORS Description for the template variable {{LocalSignerSignatureDateOnly}} shown in the signature template helper list. + '{{LocalSignerSignatureDateOnly}}' => $this->l10n->t('Date when the signer created the signature request (without time, in their local time zone).'), + // TRANSLATORS Description for the template variable {{LocalSignerSignatureDateTime}} shown in the signature template helper list. + '{{LocalSignerSignatureDateTime}}' => $this->l10n->t('Date and time when the signer created the signature request (in their local time zone).'), + // TRANSLATORS Description for the template variable {{LocalSignerTimezone}} shown in the signature template helper list. + '{{LocalSignerTimezone}}' => $this->l10n->t('Time zone of signer when the signature request was created (in their local time zone).'), + // TRANSLATORS Description for the template variable {{ServerSignatureDate}} shown in the signature template helper list. Keep "ISO 8601" unchanged. "date filter" refers to template syntax used to format dates. '{{ServerSignatureDate}}' => $this->l10n->t('Date and time when the signature was applied on the server (ISO 8601 format). Can be formatted using the Twig date filter.'), + // TRANSLATORS Description for the template variable {{SignerCommonName}} shown in the signature template helper list. "Common Name (CN)" is the X.509 certificate subject field and "CN" must remain unchanged. '{{SignerCommonName}}' => $this->l10n->t('Common Name (CN) used to identify the document signer.'), + // TRANSLATORS Description for the template variable {{SignerEmail}} shown in the signature template helper list. '{{SignerEmail}}' => $this->l10n->t('The signer\'s email is optional and can be left blank.'), + // TRANSLATORS Description for the template variable {{SignerIdentifier}} shown in the signature template helper list. '{{SignerIdentifier}}' => $this->l10n->t('Unique information used to identify the signer (such as email, phone number, or username).'), + // TRANSLATORS Description for the template variable {{ValidationURL}} shown in the signature template helper list. '{{ValidationURL}}' => $this->l10n->t('Validation URL of the signed document.'), // TRANSLATORS This sentence is a description shown in the list of // available template variables. @@ -217,9 +228,11 @@ public function getAvailableVariables(): array { // '{{qrcode}}' => $this->l10n->t('Base64-encoded PNG QR code for the validation URL. In HTML/Twig, use . In plain-text templates, use {{ValidationURL}}.'), ]; - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); + $collectMetadata = $this->isCollectMetadataEnabled(); if ($collectMetadata) { + // TRANSLATORS Description for the template variable {{SignerIP}} shown in the signature template helper list. This variable is available only when metadata collection policy is enabled. $list['{{SignerIP}}'] = $this->l10n->t('IP address of the person who signed the document.'); + // TRANSLATORS Description for the template variable {{SignerUserAgent}} shown in the signature template helper list. This variable is available only when metadata collection policy is enabled. $list['{{SignerUserAgent}}'] = $this->l10n->t('Browser and device information of the person who signed the document.'); } return $list; @@ -444,62 +457,40 @@ private function mbWordwrap(string $text, int $width, string $break = "\n", bool return implode($break, $lines); } - public function getDefaultTemplate(): string { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); - if ($collectMetadata) { - // TRANSLATORS Variables enclosed in double curly braces {{variableName}} are template placeholders. - // - // DO NOT translate or remove these variables: - // - {{SignerCommonName}} - // - {{IssuerCommonName}} - // - {{ServerSignatureDate}} - // - {{SignerIP}} - // - {{SignerUserAgent}} - // - // Only translate the text outside the curly braces, such as: - // - "Signed with LibreSign" - // - "Issuer:" - // - "Date:" - // - "IP:" - // - "User agent:" - return $this->l10n->t( - "Signed with LibreSign\n" - . "{{SignerCommonName}}\n" - . "Issuer: {{IssuerCommonName}}\n" - . "Date: {{ServerSignatureDate}}\n" - . "IP: {{SignerIP}}\n" - . 'User agent: {{SignerUserAgent}}' - ); - } - // TRANSLATORS Variables enclosed in double curly braces {{variableName}} are template placeholders. - // - // DO NOT translate or remove these variables: - // - {{SignerCommonName}} - // - {{IssuerCommonName}} - // - {{ServerSignatureDate}} - // - // Only translate the text outside the curly braces, such as: - // - "Signed with LibreSign" - // - "Issuer:" - // - "Date:" - return $this->l10n->t( - "Signed with LibreSign\n" - . "{{SignerCommonName}}\n" - . "Issuer: {{IssuerCommonName}}\n" - . 'Date: {{ServerSignatureDate}}' - ); + /** + * @return array{ + * template: string, + * template_font_size: float, + * signature_font_size: float, + * signature_width: float, + * signature_height: float, + * background_type: 'default'|'custom'|'deleted', + * render_mode: 'default'|'graphic'|'text'|'description_only', + * } + */ + public function getDefaultSignatureStampConfig(): array { + return [ + 'template' => SignatureTextTemplate::translated($this->l10n, $this->isCollectMetadataEnabled()), + 'template_font_size' => $this->getDefaultTemplateFontSize(), + 'signature_font_size' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE, + 'signature_width' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH, + 'signature_height' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT, + 'background_type' => 'default', + 'render_mode' => 'default', + ]; } public function getFullSignatureWidth(): float { - return $this->getSanitizedDimension('signature_width', self::DEFAULT_SIGNATURE_WIDTH); + return $this->getSanitizedDimension('signature_width', SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH); } public function getFullSignatureHeight(): float { - return $this->getSanitizedDimension('signature_height', self::DEFAULT_SIGNATURE_HEIGHT); + return $this->getSanitizedDimension('signature_height', SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT); } public function getSignatureWidth(): float { - $current = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', self::DEFAULT_SIGNATURE_WIDTH); + $config = $this->getSignatureStampPolicyConfig(); + $current = (float)($config['signature_width'] ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH); if ($this->getRenderMode() === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY || !$this->getTemplate()) { return $current; } @@ -511,10 +502,10 @@ public function getSignatureHeight(): float { } private function getSanitizedDimension(string $key, float $default): float { - $value = $this->appConfig->getValueFloat(Application::APP_ID, $key, $default); + $config = $this->getSignatureStampPolicyConfig(); + $value = (float)($config[$key] ?? $default); if (!is_finite($value) || $value < self::SIGNATURE_DIMENSION_MINIMUM) { - $this->appConfig->setValueFloat(Application::APP_ID, $key, $default); - $this->logger->warning('Invalid signature dimension found in app config. Falling back to default.', [ + $this->logger->warning('Invalid signature dimension found in policy resolution. Falling back to default value in memory.', [ 'key' => $key, 'value' => $value, 'default' => $default, @@ -525,27 +516,30 @@ private function getSanitizedDimension(string $key, float $default): float { } public function getTemplateFontSize(): float { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); - if ($collectMetadata) { - return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE - 1); - } - return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE); + $config = $this->getSignatureStampPolicyConfig(); + return (float)($config['template_font_size'] ?? SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE); } public function getDefaultTemplateFontSize(): float { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); + $collectMetadata = $this->isCollectMetadataEnabled(); if ($collectMetadata) { - return self::TEMPLATE_DEFAULT_FONT_SIZE - 0.2; + return SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE - 0.2; } - return self::TEMPLATE_DEFAULT_FONT_SIZE; + return SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE; + } + + private function isCollectMetadataEnabled(): bool { + return (bool)$this->policyService->resolve(CollectMetadataPolicy::KEY)->getEffectiveValue(); } public function getSignatureFontSize(): float { - return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_font_size', self::SIGNATURE_DEFAULT_FONT_SIZE); + $config = $this->getSignatureStampPolicyConfig(); + return (float)($config['signature_font_size'] ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE); } public function getRenderMode(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'signature_render_mode', SignerElementsService::RENDER_MODE_DEFAULT); + $config = $this->getSignatureStampPolicyConfig(); + return $this->normalizeRuntimeRenderMode((string)($config['render_mode'] ?? SignerElementsService::RENDER_MODE_DEFAULT)); } public function isEnabled(): bool { @@ -553,7 +547,10 @@ public function isEnabled(): bool { } private function buildValidationUrl(string $uuid): string { - $validationSite = trim($this->appConfig->getValueString(Application::APP_ID, 'validation_site', '')); + $footerPolicy = FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue() + ); + $validationSite = trim($footerPolicy['validationSite']); if ($validationSite !== '') { return rtrim($validationSite, '/') . '/' . $uuid; } @@ -562,4 +559,86 @@ private function buildValidationUrl(string $uuid): string { 'uuid' => $uuid, ]); } + + /** + * @return array{ + * template: string, + * template_font_size: float, + * signature_font_size: float, + * signature_width: float, + * signature_height: float, + * background_type: 'default'|'custom'|'deleted', + * render_mode: 'default'|'graphic'|'text'|'description_only', + * } + */ + private function getSignatureStampPolicyConfig(): array { + $rawValue = $this->policyService->resolve(SignatureTextPolicyProvider::KEY)->getEffectiveValue(); + return $this->syncCanonicalDefaultTemplateWithCollectMetadata( + SignatureTextPolicyValue::normalize($rawValue, $this->getDefaultSignatureStampConfig()) + ); + } + + /** + * @param array{ + * template: string, + * template_font_size: float, + * signature_font_size: float, + * signature_width: float, + * signature_height: float, + * background_type: 'default'|'custom'|'deleted', + * render_mode: 'default'|'graphic'|'text'|'description_only', + * } $config + * @return array{ + * template: string, + * template_font_size: float, + * signature_font_size: float, + * signature_width: float, + * signature_height: float, + * background_type: 'default'|'custom'|'deleted', + * render_mode: 'default'|'graphic'|'text'|'description_only', + * } + */ + private function syncCanonicalDefaultTemplateWithCollectMetadata(array $config): array { + $template = (string)($config['template'] ?? ''); + $canonicalWithoutMetadata = SignatureTextTemplate::translated($this->l10n, false); + $canonicalWithMetadata = SignatureTextTemplate::translated($this->l10n, true); + + if ($template !== $canonicalWithoutMetadata && $template !== $canonicalWithMetadata) { + return $config; + } + + $config['template'] = $this->isCollectMetadataEnabled() + ? $canonicalWithMetadata + : $canonicalWithoutMetadata; + + return $config; + } + + public function getPreviewSignerName(): string { + return $this->userSession?->getUser()?->getDisplayName() ?? 'John Doe'; + } + + private function normalizeRuntimeRenderMode(string $renderMode): string { + return match ($renderMode) { + 'default' => SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, + 'graphic' => SignerElementsService::RENDER_MODE_GRAPHIC_ONLY, + 'text' => SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION, + 'description_only', + SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY, + SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION, + SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, + SignerElementsService::RENDER_MODE_GRAPHIC_ONLY => $renderMode, + default => SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, + }; + } + + private function normalizePersistedRenderMode(string $renderMode): string { + return match ($renderMode) { + SignerElementsService::RENDER_MODE_GRAPHIC_ONLY, 'graphic' => 'graphic', + SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY, 'description_only' => 'description_only', + SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION, 'text' => 'text', + SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, 'default' => 'default', + default => 'default', + }; + } } diff --git a/lib/Service/SignatureTextTemplate.php b/lib/Service/SignatureTextTemplate.php new file mode 100644 index 0000000000..b3b2621abb --- /dev/null +++ b/lib/Service/SignatureTextTemplate.php @@ -0,0 +1,62 @@ +t( + "Signed with LibreSign\n" + . "{{SignerCommonName}}\n" + . "Issuer: {{IssuerCommonName}}\n" + . "Date: {{ServerSignatureDate}}\n" + . "IP: {{SignerIP}}\n" + . 'User agent: {{SignerUserAgent}}' + ); + } + + // TRANSLATORS Variables enclosed in double curly braces {{variableName}} are template placeholders. + // + // DO NOT translate or remove these variables: + // - {{SignerCommonName}} + // - {{IssuerCommonName}} + // - {{ServerSignatureDate}} + // + // Only translate the text outside the curly braces, such as: + // - "Signed with LibreSign" + // - "Issuer:" + // - "Date:" + return $l10n->t( + "Signed with LibreSign\n" + . "{{SignerCommonName}}\n" + . "Issuer: {{IssuerCommonName}}\n" + . 'Date: {{ServerSignatureDate}}' + ); + } +} diff --git a/lib/Service/TsaValidationService.php b/lib/Service/TsaValidationService.php index 4f9204cfd1..f8c51b2a77 100644 --- a/lib/Service/TsaValidationService.php +++ b/lib/Service/TsaValidationService.php @@ -8,13 +8,14 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; class TsaValidationService { public function __construct( - private IAppConfig $appConfig, + private PolicyService $policyService, ) { } @@ -34,7 +35,9 @@ public function validateConfiguration(): void { } private function getTsaUrl(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + $rawPolicyValue = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue(); + $decoded = TsaPolicyValue::decode($rawPolicyValue); + return $decoded['url']; } private function validateTsaUrlFormat(string $tsaUrl): void { diff --git a/lib/Service/Worker/WorkerConfiguration.php b/lib/Service/Worker/WorkerConfiguration.php index f703dcfe91..323b499a19 100644 --- a/lib/Service/Worker/WorkerConfiguration.php +++ b/lib/Service/Worker/WorkerConfiguration.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Service\Worker; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Service\Policy\Provider\Worker\WorkerConfigPolicy; use OCP\IAppConfig; class WorkerConfiguration { @@ -17,6 +18,7 @@ class WorkerConfiguration { public function __construct( private IAppConfig $appConfig, + private WorkerConfigPolicy $workerConfigPolicy, ) { } @@ -26,12 +28,30 @@ public function isAsyncLocalEnabled(): bool { return false; } - $workerType = $this->appConfig->getValueString(Application::APP_ID, 'worker_type', 'local'); - return $workerType === 'local'; + $workerConfig = $this->getWorkerConfig(); + return $workerConfig['worker_type'] === 'local'; } public function getDesiredWorkerCount(): int { - $numWorkers = $this->appConfig->getValueInt(Application::APP_ID, 'parallel_workers', self::DEFAULT_WORKERS); + $workerConfig = $this->getWorkerConfig(); + $numWorkers = (int)($workerConfig['parallel_workers'] ?? self::DEFAULT_WORKERS); return max(1, min($numWorkers, self::MAX_WORKERS)); } + + /** + * @return array{worker_type: string, parallel_workers: int} + */ + private function getWorkerConfig(): array { + $rawValue = $this->appConfig->getValueString( + Application::APP_ID, + WorkerConfigPolicy::SYSTEM_APP_CONFIG_KEY, + '', + ); + + if ($rawValue === '') { + return $this->workerConfigPolicy->defaultValue(); + } + + return $this->workerConfigPolicy->normalizeValue($rawValue); + } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 411b28d16a..ae9a5e800a 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -9,95 +9,55 @@ namespace OCA\Libresign\Settings; use OCA\Libresign\AppInfo\Application; -use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; +use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\CertificatePolicyService; -use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; -use OCA\Libresign\Service\FooterService; -use OCA\Libresign\Service\IdentifyMethodService; -use OCA\Libresign\Service\SignatureBackgroundService; -use OCA\Libresign\Service\SignatureTextService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IAppConfig; +use OCP\IUserSession; use OCP\Settings\ISettings; use OCP\Util; -/** - * @psalm-import-type LibresignAdminSignatureEngine from \OCA\Libresign\ResponseDefinitions - * @psalm-import-type LibresignAdminSigningMode from \OCA\Libresign\ResponseDefinitions - * @psalm-import-type LibresignAdminWorkerType from \OCA\Libresign\ResponseDefinitions - */ class Admin implements ISettings { public const PASSWORD_PLACEHOLDER = '••••••••'; public function __construct( private IInitialState $initialState, - private IdentifyMethodService $identifyMethodService, + private AccountService $accountService, + private IUserSession $userSession, private CertificateEngineFactory $certificateEngineFactory, private CertificatePolicyService $certificatePolicyService, private IAppConfig $appConfig, - private SignatureTextService $signatureTextService, - private SignatureBackgroundService $signatureBackgroundService, - private FooterService $footerService, - private DocMdpConfigService $docMdpConfigService, + private PolicyService $policyService, ) { } #[\Override] public function getForm(): TemplateResponse { Util::addScript(Application::APP_ID, 'libresign-settings'); Util::addStyle(Application::APP_ID, 'libresign-settings'); - try { - $signatureParsed = $this->signatureTextService->parse(); - $this->initialState->provideInitialState('signature_text_parsed', $signatureParsed['parsed']); - } catch (LibresignException $e) { - $this->initialState->provideInitialState('signature_text_parsed', ''); - $this->initialState->provideInitialState('signature_text_template_error', $e->getMessage()); - } + $this->initialState->provideInitialState('config', $this->accountService->getConfig($this->userSession->getUser())); $this->initialState->provideInitialState('certificate_engine', $this->certificateEngineFactory->getEngine()->getName()); $this->initialState->provideInitialState('certificate_policies_oid', $this->certificatePolicyService->getOid()); $this->initialState->provideInitialState('certificate_policies_cps', $this->certificatePolicyService->getCps()); $this->initialState->provideInitialState('config_path', $this->appConfig->getValueString(Application::APP_ID, 'config_path')); - $this->initialState->provideInitialState('default_signature_font_size', SignatureTextService::SIGNATURE_DEFAULT_FONT_SIZE); - $this->initialState->provideInitialState('default_signature_height', SignatureTextService::DEFAULT_SIGNATURE_HEIGHT); - $this->initialState->provideInitialState('default_signature_text_template', $this->signatureTextService->getDefaultTemplate()); - $this->initialState->provideInitialState('default_signature_width', SignatureTextService::DEFAULT_SIGNATURE_WIDTH); - $this->initialState->provideInitialState('default_template_font_size', $this->signatureTextService->getDefaultTemplateFontSize()); - $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); - $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information', '')); - $this->initialState->provideInitialState('signature_available_variables', $this->signatureTextService->getAvailableVariables()); - $this->initialState->provideInitialState('signature_background_type', $this->signatureBackgroundService->getSignatureBackgroundType()); - $this->initialState->provideInitialState('signature_font_size', $this->signatureTextService->getSignatureFontSize()); - $this->initialState->provideInitialState('signature_height', $this->signatureTextService->getFullSignatureHeight()); - $this->initialState->provideInitialState('signature_preview_zoom_level', $this->appConfig->getValueFloat(Application::APP_ID, 'signature_preview_zoom_level', 100)); - $this->initialState->provideInitialState('footer_preview_zoom_level', $this->appConfig->getValueFloat(Application::APP_ID, 'footer_preview_zoom_level', 100)); - $this->initialState->provideInitialState('footer_preview_width', $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_width', 595)); - $this->initialState->provideInitialState('footer_preview_height', $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_height', 100)); - $this->initialState->provideInitialState('footer_template_variables', $this->footerService->getTemplateVariablesMetadata()); - $this->initialState->provideInitialState('footer_template', $this->footerService->getTemplate()); - $this->initialState->provideInitialState('footer_template_is_default', $this->footerService->isDefaultTemplate()); $this->initialState->provideInitialState('signature_engine', $this->getSignatureEngineInitialState()); - $this->initialState->provideInitialState('signature_render_mode', $this->signatureTextService->getRenderMode()); - $this->initialState->provideInitialState('signature_text_template', $this->signatureTextService->getTemplate()); - $this->initialState->provideInitialState('signature_width', $this->signatureTextService->getFullSignatureWidth()); - $this->initialState->provideInitialState('template_font_size', $this->signatureTextService->getTemplateFontSize()); - $this->initialState->provideInitialState('tsa_url', $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', '')); - $this->initialState->provideInitialState('tsa_policy_oid', $this->appConfig->getValueString(Application::APP_ID, 'tsa_policy_oid', '')); - $this->initialState->provideInitialState('tsa_auth_type', $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none')); - $this->initialState->provideInitialState('tsa_username', $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '')); - $this->initialState->provideInitialState('tsa_password', $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', self::PASSWORD_PLACEHOLDER)); - $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); - $this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value)); - $this->initialState->provideInitialState('signing_mode', $this->getSigningModeInitialState()); - $this->initialState->provideInitialState('worker_type', $this->getWorkerTypeInitialState()); - $this->initialState->provideInitialState('identification_documents', $this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)); - $this->initialState->provideInitialState('approval_group', $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin'])); - $this->initialState->provideInitialState('envelope_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)); - $this->initialState->provideInitialState('parallel_workers', $this->appConfig->getValueString(Application::APP_ID, 'parallel_workers', '4')); - $this->initialState->provideInitialState('show_confetti_after_signing', $this->appConfig->getValueBool(Application::APP_ID, 'show_confetti_after_signing', true)); - $this->initialState->provideInitialState('crl_external_validation_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true)); + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $this->policyService->resolveKnownPolicyStatesWithRuleCounts( + $this->policyService->getAllRuleCounts(), + ), + ]); $this->initialState->provideInitialState('ldap_extension_available', function_exists('ldap_connect')); - return new TemplateResponse(Application::APP_ID, 'admin_settings'); + + $response = new TemplateResponse(Application::APP_ID, 'admin_settings'); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedWorkerSrcDomain('blob:'); + $response->setContentSecurityPolicy($policy); + + return $response; } /** @@ -116,7 +76,6 @@ public function getPriority(): int { return 100; } - /** @return LibresignAdminSignatureEngine */ private function getSignatureEngineInitialState(): string { $engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf'); if ($engine === 'PhpNative') { @@ -125,21 +84,4 @@ private function getSignatureEngineInitialState(): string { return 'JSignPdf'; } - /** @return LibresignAdminSigningMode */ - private function getSigningModeInitialState(): string { - $mode = $this->appConfig->getValueString(Application::APP_ID, 'signing_mode', 'sync'); - if ($mode === 'async') { - return $mode; - } - return 'sync'; - } - - /** @return LibresignAdminWorkerType */ - private function getWorkerTypeInitialState(): string { - $workerType = $this->appConfig->getValueString(Application::APP_ID, 'worker_type', 'local'); - if ($workerType === 'external') { - return $workerType; - } - return 'local'; - } } diff --git a/openapi-administration.json b/openapi-administration.json index 186927ebfa..1a6177156b 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -67,6 +67,25 @@ } } }, + "AdminSignatureMethodSetting": { + "type": "object", + "required": [ + "enabled", + "label", + "name" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "Capabilities": { "type": "object", "required": [ @@ -184,12 +203,17 @@ ], "properties": { "engine": { - "type": "string" + "type": "string", + "enum": [ + "cfssl", + "none", + "openssl" + ] }, "identify_methods": { "type": "array", "items": { - "$ref": "#/components/schemas/IdentifyMethodSetting" + "$ref": "#/components/schemas/IdentifyMethodAdminSetting" } } } @@ -379,6 +403,161 @@ } } }, + "EffectivePolicyMeta": { + "type": "object", + "properties": { + "defaultSystemValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "appConfigKey": { + "type": "string" + }, + "userPreferenceKey": { + "type": "string" + }, + "resolutionMode": { + "type": "string" + }, + "supportsGroupAdminDelegation": { + "type": "boolean" + }, + "canCreateDescendantRules": { + "type": "boolean" + }, + "supportsUserPreference": { + "type": "boolean" + }, + "supportedScopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicyScope" + } + }, + "backendOnly": { + "type": "boolean" + }, + "helper": { + "type": "boolean" + }, + "parentPolicyKey": { + "type": "string" + }, + "compositeChildren": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "EffectivePolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + }, + "EffectivePolicyState": { + "type": "object", + "required": [ + "policyKey", + "effectiveValue", + "sourceScope", + "visible", + "editableByCurrentActor", + "allowedValues", + "canSaveAsUserDefault", + "canUseAsRequestOverride", + "preferenceWasCleared", + "blockedBy", + "groupCount", + "userCount", + "everyoneCount" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "meta": { + "$ref": "#/components/schemas/EffectivePolicyMeta" + }, + "sourceScope": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "editableByCurrentActor": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, + "canSaveAsUserDefault": { + "type": "boolean" + }, + "canUseAsRequestOverride": { + "type": "boolean" + }, + "preferenceWasCleared": { + "type": "boolean" + }, + "blockedBy": { + "type": "string", + "nullable": true + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "everyoneCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, "EngineHandler": { "type": "object", "required": [ @@ -426,24 +605,6 @@ } } }, - "ErrorStatusResponse": { - "type": "object", - "required": [ - "status", - "message" - ], - "properties": { - "status": { - "type": "string", - "enum": [ - "error" - ] - }, - "message": { - "type": "string" - } - } - }, "FailureStatusResponse": { "type": "object", "required": [ @@ -462,31 +623,6 @@ } } }, - "FooterTemplateResponse": { - "type": "object", - "required": [ - "template", - "isDefault", - "preview_width", - "preview_height" - ], - "properties": { - "template": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "preview_width": { - "type": "integer", - "format": "int64" - }, - "preview_height": { - "type": "integer", - "format": "int64" - } - } - }, "HasRootCertResponse": { "type": "object", "required": [ @@ -498,13 +634,13 @@ } } }, - "IdentifyMethodSetting": { + "IdentifyMethodAdminSetting": { "type": "object", "required": [ "name", "friendly_name", "enabled", - "mandatory" + "requirement" ], "properties": { "name": { @@ -516,14 +652,35 @@ "enabled": { "type": "boolean" }, - "mandatory": { + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "can_create_account": { "type": "boolean" }, + "test_url": { + "type": "string" + }, "signatureMethods": { - "$ref": "#/components/schemas/SignatureMethods" + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AdminSignatureMethodSetting" + } } } }, + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, "MessageResponse": { "type": "object", "required": [ @@ -559,6 +716,14 @@ } } }, + "PolicyScope": { + "type": "string", + "enum": [ + "system", + "group", + "user" + ] + }, "PolicySection": { "type": "object", "required": [ @@ -582,38 +747,6 @@ } } }, - "ReminderSettings": { - "type": "object", - "required": [ - "days_before", - "days_between", - "max", - "send_timer" - ], - "properties": { - "days_before": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "days_between": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "max": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "send_timer": { - "type": "string" - }, - "next_run": { - "type": "string" - } - } - }, "RootCertificate": { "type": "object", "required": [ @@ -643,167 +776,94 @@ "type": "string" }, "value": { - "type": "string" + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, - "SignatureMethod": { + "SuccessStatusResponse": { "type": "object", "required": [ - "enabled", - "label", - "name" + "status" ], "properties": { - "enabled": { - "type": "boolean" - }, - "label": { - "type": "string" - }, - "name": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "success" + ] } } }, - "SignatureMethodEmailToken": { + "SystemPolicyResponse": { "type": "object", "required": [ - "label", - "identifyMethod", - "needCode", - "hasConfirmCode", - "blurredEmail", - "hashOfEmail" + "policy" ], "properties": { - "label": { - "type": "string" - }, - "identifyMethod": { - "type": "string", - "enum": [ - "email", - "account" - ] - }, - "needCode": { - "type": "boolean" - }, - "hasConfirmCode": { - "type": "boolean" - }, - "blurredEmail": { - "type": "string" - }, - "hashOfEmail": { - "type": "string" + "policy": { + "$ref": "#/components/schemas/SystemPolicyState" } } }, - "SignatureMethodPassword": { + "SystemPolicyState": { "type": "object", "required": [ - "label", - "name", - "hasSignatureFile" + "policyKey", + "scope", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues" ], "properties": { - "label": { + "policyKey": { "type": "string" }, - "name": { - "type": "string" + "scope": { + "type": "string", + "enum": [ + "system", + "global" + ] }, - "hasSignatureFile": { + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { "type": "boolean" - } - } - }, - "SignatureMethods": { - "type": "object", - "properties": { - "clickToSign": { - "$ref": "#/components/schemas/SignatureMethod" }, - "emailToken": { - "$ref": "#/components/schemas/SignatureMethodEmailToken" + "visibleToChild": { + "type": "boolean" }, - "password": { - "$ref": "#/components/schemas/SignatureMethodPassword" + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } } } }, - "SignatureTemplateSettingsResponse": { - "type": "object", - "required": [ - "default_signature_text_template", - "signature_available_variables" - ], - "properties": { - "default_signature_text_template": { - "type": "string" + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" }, - "signature_available_variables": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "SignatureTextSettingsResponse": { - "type": "object", - "required": [ - "template", - "parsed", - "templateFontSize", - "signatureFontSize", - "signatureWidth", - "signatureHeight", - "renderMode" - ], - "properties": { - "template": { - "type": "string" - }, - "parsed": { - "type": "string" - }, - "templateFontSize": { - "type": "number", - "format": "double" - }, - "signatureFontSize": { - "type": "number", - "format": "double" - }, - "signatureWidth": { - "type": "number", - "format": "double" - }, - "signatureHeight": { - "type": "number", - "format": "double" - }, - "renderMode": { - "type": "string" - } - } - }, - "SuccessStatusResponse": { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string", - "enum": [ - "success" - ] + { + "$ref": "#/components/schemas/EffectivePolicyResponse" } - } + ] } } }, @@ -1406,80 +1466,6 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/disable-hate-limit": { - "get": { - "operationId": "admin-disable-hate-limit", - "summary": "Disable hate limit to current session", - "description": "This will disable hate limit to current session.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "type": "object" - } - } - } - } - } - } - } - } - } - } - }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { "post": { "operationId": "admin-signature-background-save", @@ -1636,78 +1622,6 @@ } } }, - "patch": { - "operationId": "admin-signature-background-reset", - "summary": "Reset the background image to be the default of LibreSign", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "Image reseted to default", - "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/SuccessStatusResponse" - } - } - } - } - } - } - } - } - } - }, "delete": { "operationId": "admin-signature-background-delete", "summary": "Delete background image", @@ -1781,10 +1695,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { "post": { - "operationId": "admin-signature-text-save", - "summary": "Save signature text service", + "operationId": "admin-save-certificate-policy", + "summary": "Upload new certificate policy PDF for this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1797,54 +1711,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "template" - ], - "properties": { - "template": { - "type": "string", - "description": "Template to signature text" - }, - "templateFontSize": { - "type": "number", - "format": "double", - "default": 10, - "description": "Font size used when print the parsed text of this template at PDF file" - }, - "signatureFontSize": { - "type": "number", - "format": "double", - "default": 20, - "description": "Font size used when the signature mode is SIGNAME_AND_DESCRIPTION" - }, - "signatureWidth": { - "type": "number", - "format": "double", - "default": 350, - "description": "Signature box width, minimum 1" - }, - "signatureHeight": { - "type": "number", - "format": "double", - "default": 100, - "description": "Signature box height, minimum 1" - }, - "renderMode": { - "type": "string", - "default": "GRAPHIC_AND_DESCRIPTION", - "description": "Signature render mode" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -1891,7 +1757,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/CertificatePolicyResponse" } } } @@ -1900,8 +1766,8 @@ } } }, - "400": { - "description": "Bad request", + "422": { + "description": "Upload or validation error", "content": { "application/json": { "schema": { @@ -1921,7 +1787,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -1932,9 +1798,9 @@ } } }, - "get": { - "operationId": "admin-signature-text-get", - "summary": "Get parsed signature text service", + "delete": { + "operationId": "admin-delete-certificate-policy", + "summary": "Delete certificate policy of this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1960,24 +1826,6 @@ "default": "v1" } }, - { - "name": "template", - "in": "query", - "description": "Template to signature text", - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "context", - "in": "query", - "description": "Context for parsing the template", - "schema": { - "type": "string", - "default": "" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -2011,7 +1859,18 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } } } } @@ -2020,1559 +1879,8 @@ } } }, - "400": { - "description": "Bad request", - "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/ErrorResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { - "get": { - "operationId": "admin-get-signature-settings", - "summary": "Get signature settings", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/SignatureTemplateSettingsResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signer-name": { - "get": { - "operationId": "admin-signer-name", - "summary": "Convert signer name as image", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "name": "width", - "in": "query", - "description": "Image width,", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "height", - "in": "query", - "description": "Image height", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "text", - "in": "query", - "description": "Text to be added to image", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "fontSize", - "in": "query", - "description": "Font size of text", - "required": true, - "schema": { - "type": "number", - "format": "double" - } - }, - { - "name": "isDarkTheme", - "in": "query", - "description": "Color of text, white if is tark theme and black if not", - "required": true, - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - }, - { - "name": "align", - "in": "query", - "description": "Align of text: left, center or right", - "required": true, - "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": "OK", - "headers": { - "Content-Disposition": { - "schema": { - "type": "string", - "enum": [ - "inline; filename=\"signer-name.png\"" - ] - } - } - }, - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", - "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/ErrorResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { - "post": { - "operationId": "admin-save-certificate-policy", - "summary": "Update certificate policy of this instance", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/CertificatePolicyResponse" - } - } - } - } - } - } - } - }, - "422": { - "description": "Not found", - "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/FailureStatusResponse" - } - } - } - } - } - } - } - } - } - }, - "delete": { - "operationId": "admin-delete-certificate-policy", - "summary": "Delete certificate policy of this instance", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "type": "object" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { - "post": { - "operationId": "admin-update-oid", - "summary": "Update OID", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "oid" - ], - "properties": { - "oid": { - "type": "string", - "description": "OID is a unique numeric identifier for certificate policies in digital certificates." - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/SuccessStatusResponse" - } - } - } - } - } - } - } - }, - "422": { - "description": "Validation error", - "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/FailureStatusResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { - "get": { - "operationId": "admin-reminder-fetch", - "summary": "Get reminder settings", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/ReminderSettings" - } - } - } - } - } - } - } - } - } - }, - "post": { - "operationId": "admin-reminder-save", - "summary": "Save reminder", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "daysBefore", - "daysBetween", - "max", - "sendTimer" - ], - "properties": { - "daysBefore": { - "type": "integer", - "format": "int64", - "description": "First reminder after (days)" - }, - "daysBetween": { - "type": "integer", - "format": "int64", - "description": "Days between reminders" - }, - "max": { - "type": "integer", - "format": "int64", - "description": "Max reminders per signer" - }, - "sendTimer": { - "type": "string", - "description": "Send time (HH:mm)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/ReminderSettings" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { - "post": { - "operationId": "admin-set-tsa-config", - "summary": "Set TSA configuration values with proper sensitive data handling", - "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tsa_url": { - "type": "string", - "nullable": true, - "description": "TSA server URL (required for saving)" - }, - "tsa_policy_oid": { - "type": "string", - "nullable": true, - "description": "TSA policy OID" - }, - "tsa_auth_type": { - "type": "string", - "nullable": true, - "description": "Authentication type (none|basic), defaults to 'none'" - }, - "tsa_username": { - "type": "string", - "nullable": true, - "description": "Username for basic authentication" - }, - "tsa_password": { - "type": "string", - "nullable": true, - "description": "Password for basic authentication (stored as sensitive data)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/SuccessStatusResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Validation error", - "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/ErrorStatusResponse" - } - } - } - } - } - } - } - } - } - }, - "delete": { - "operationId": "admin-delete-tsa-config", - "summary": "Delete TSA configuration", - "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/SuccessStatusResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template": { - "get": { - "operationId": "admin-get-footer-template", - "summary": "Get footer template", - "description": "Returns the current footer template if set, otherwise returns the default template.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "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/FooterTemplateResponse" - } - } - } - } - } - } - } - } - } - }, - "post": { - "operationId": "admin-save-footer-template", - "summary": "Save footer template and render preview", - "description": "Saves the footer template and returns the rendered PDF preview.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "template": { - "type": "string", - "default": "", - "description": "The Twig template to save (empty to reset to default)" - }, - "width": { - "type": "integer", - "format": "int64", - "default": 595, - "description": "Width of preview in points (default: 595 - A4 width)" - }, - "height": { - "type": "integer", - "format": "int64", - "default": 50, - "description": "Height of preview in points (default: 50)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", - "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/ErrorResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": { - "post": { - "operationId": "admin-set-signing-mode-config", - "summary": "Set signing mode configuration", - "description": "Configure whether document signing should be synchronous or asynchronous\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "mode" - ], - "properties": { - "mode": { - "type": "string", - "description": "Signing mode: \"sync\" or \"async\"" - }, - "workerType": { - "type": "string", - "nullable": true, - "description": "Worker type when async: \"local\" or \"external\" (optional)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "Settings saved", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid parameters", - "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/ErrorResponse" - } - } - } - } - } - } - } - }, - "500": { - "description": "Internal server error", - "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/ErrorResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/groups-request-sign/config": { - "post": { - "operationId": "admin-set-groups-request-sign-config", - "summary": "Persist groups allowed to request signatures as typed app config array", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "groups": { - "type": "array", - "default": [], - "description": "List of group IDs allowed to request signatures", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "Settings saved", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "500": { - "description": "Internal server error", - "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/ErrorResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": { - "post": { - "operationId": "admin-set-signature-flow-config", - "summary": "Set signature flow configuration", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to force a signature flow for all documents" - }, - "mode": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "Configuration saved successfully", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid signature flow mode provided", - "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/ErrorResponse" - } - } - } - } - } - } - } - }, - "500": { - "description": "Internal server error", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -3592,7 +1900,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -3604,10 +1912,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { "post": { - "operationId": "admin-set-doc-mdp-config", - "summary": "Configure DocMDP signature restrictions", + "operationId": "admin-update-oid", + "summary": "Update OID", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -3627,18 +1935,12 @@ "schema": { "type": "object", "required": [ - "enabled" + "oid" ], "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to enable DocMDP restrictions" - }, - "defaultLevel": { - "type": "integer", - "format": "int64", - "default": 2, - "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" + "oid": { + "type": "string", + "description": "OID is a unique numeric identifier for certificate policies in digital certificates." } } } @@ -3671,37 +1973,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid DocMDP level provided", + "description": "OK", "content": { "application/json": { "schema": { @@ -3721,7 +1993,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -3730,8 +2002,8 @@ } } }, - "500": { - "description": "Internal server error", + "422": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -3751,7 +2023,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -4207,6 +2479,245 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + "get": { + "operationId": "policy-get-system", + "summary": "Read explicit system policy configuration", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read from the system layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/SystemPolicyResponse" + } + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist. Null resets the policy to its default system value.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether lower layers may override this system default." + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the system layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { "get": { "operationId": "setting-has-root-cert", diff --git a/openapi-full.json b/openapi-full.json index 5e60e26445..3cae9ba3bd 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -20,6 +20,25 @@ } }, "schemas": { + "AccountCapabilitySettings": { + "type": "object", + "required": [ + "canRequestSign", + "hasSignatureFile", + "isApprover" + ], + "properties": { + "canRequestSign": { + "type": "boolean" + }, + "hasSignatureFile": { + "type": "boolean" + }, + "isApprover": { + "type": "boolean" + } + } + }, "AccountMeResponse": { "type": "object", "required": [ @@ -208,19 +227,24 @@ "PhpNative" ] }, - "AdminSigningMode": { - "type": "string", - "enum": [ - "sync", - "async" - ] - }, - "AdminWorkerType": { - "type": "string", - "enum": [ - "local", - "external" - ] + "AdminSignatureMethodSetting": { + "type": "object", + "required": [ + "enabled", + "label", + "name" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + } }, "Capabilities": { "type": "object", @@ -339,12 +363,17 @@ ], "properties": { "engine": { - "type": "string" + "type": "string", + "enum": [ + "cfssl", + "none", + "openssl" + ] }, "identify_methods": { "type": "array", "items": { - "$ref": "#/components/schemas/IdentifyMethodSetting" + "$ref": "#/components/schemas/IdentifyMethodAdminSetting" } } } @@ -932,7 +961,7 @@ "DynamicMetadataRecord": { "type": "object", "additionalProperties": { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "object" } }, "DynamicMetadataScalar": { @@ -955,23 +984,173 @@ ] }, "DynamicMetadataValue": { - "anyOf": [ - { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "object" + }, + "EffectivePoliciesResponse": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + } + }, + "EffectivePolicyMeta": { + "type": "object", + "properties": { + "defaultSystemValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" }, - { + "appConfigKey": { + "type": "string" + }, + "userPreferenceKey": { + "type": "string" + }, + "resolutionMode": { + "type": "string" + }, + "supportsGroupAdminDelegation": { + "type": "boolean" + }, + "canCreateDescendantRules": { + "type": "boolean" + }, + "supportsUserPreference": { + "type": "boolean" + }, + "supportedScopes": { "type": "array", "items": { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "$ref": "#/components/schemas/PolicyScope" } }, - { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "backendOnly": { + "type": "boolean" }, - { + "helper": { + "type": "boolean" + }, + "parentPolicyKey": { + "type": "string" + }, + "compositeChildren": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "EffectivePolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + }, + "EffectivePolicyState": { + "type": "object", + "required": [ + "policyKey", + "effectiveValue", + "sourceScope", + "visible", + "editableByCurrentActor", + "allowedValues", + "canSaveAsUserDefault", + "canUseAsRequestOverride", + "preferenceWasCleared", + "blockedBy", + "groupCount", + "userCount", + "everyoneCount" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "meta": { + "$ref": "#/components/schemas/EffectivePolicyMeta" + }, + "sourceScope": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "editableByCurrentActor": { + "type": "boolean" + }, + "allowedValues": { "type": "array", "items": { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, + "canSaveAsUserDefault": { + "type": "boolean" + }, + "canUseAsRequestOverride": { + "type": "boolean" + }, + "preferenceWasCleared": { + "type": "boolean" + }, + "blockedBy": { + "type": "string", + "nullable": true + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "everyoneCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" } } ] @@ -1474,8 +1653,10 @@ "required": [ "template", "isDefault", + "template_variables", "preview_width", - "preview_height" + "preview_height", + "preview_zoom" ], "properties": { "template": { @@ -1484,6 +1665,26 @@ "isDefault": { "type": "boolean" }, + "template_variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "example": { + "type": "string" + }, + "default": { + "type": "string" + } + } + } + }, "preview_width": { "type": "integer", "format": "int64" @@ -1491,9 +1692,99 @@ "preview_height": { "type": "integer", "format": "int64" + }, + "preview_zoom": { + "type": "integer", + "format": "int64" + } + } + }, + "GroupPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/GroupPolicyState" + } + } + }, + "GroupPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues", + "deletableByCurrentActor" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "group" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, + "deletableByCurrentActor": { + "type": "boolean" } } }, + "GroupPolicyWriteRequest": { + "type": "object", + "required": [ + "value", + "allowChildOverride" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "GroupPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/GroupPolicyResponse" + } + ] + }, "HasRootCertResponse": { "type": "object", "required": [ @@ -1624,6 +1915,7 @@ "sms", "telegram", "whatsapp", + "whatsappbusiness", "xmpp" ] }, @@ -1655,7 +1947,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1667,26 +1959,25 @@ "sms", "telegram", "whatsapp", + "whatsappbusiness", "xmpp" ] }, "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } }, - "IdentifyMethodSetting": { + "IdentifyMethodAdminSetting": { "type": "object", "required": [ "name", "friendly_name", "enabled", - "mandatory" + "requirement" ], "properties": { "name": { @@ -1698,26 +1989,78 @@ "enabled": { "type": "boolean" }, - "mandatory": { + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "can_create_account": { "type": "boolean" }, + "test_url": { + "type": "string" + }, "signatureMethods": { - "$ref": "#/components/schemas/SignatureMethods" + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AdminSignatureMethodSetting" + } } } }, - "InfoMessage": { + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, + "IdentifyMethodSetting": { "type": "object", "required": [ - "type", - "message" + "name", + "friendly_name", + "enabled", + "requirement" ], "properties": { - "type": { - "type": "string", - "enum": [ - "info" - ] + "name": { + "type": "string" + }, + "friendly_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "signatureMethods": { + "$ref": "#/components/schemas/SignatureMethods" + } + } + }, + "InfoMessage": { + "type": "object", + "required": [ + "type", + "message" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "info" + ] }, "message": { "type": "string" @@ -1781,7 +2124,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1790,10 +2133,8 @@ "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } } @@ -1907,6 +2248,14 @@ } } }, + "PolicyScope": { + "type": "string", + "enum": [ + "system", + "group", + "user" + ] + }, "PolicySection": { "type": "object", "required": [ @@ -1922,6 +2271,175 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotIdentificationDocumentsEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "$ref": "#/components/schemas/PolicySnapshotIdentificationDocumentsValue" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotIdentificationDocumentsValue": { + "type": "object", + "required": [ + "enabled", + "approvers" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "approvers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PolicySnapshotIdentifyMethodFactor": { + "type": "object", + "required": [ + "name", + "enabled" + ], + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "signatureMethods": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PolicySnapshotSignatureMethodSetting" + } + }, + "signatureMethodEnabled": { + "type": "string" + }, + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "friendly_name": { + "type": "string" + } + } + }, + "PolicySnapshotIdentifyMethodsEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodFactor" + } + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotLegalInformationEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotSignatureMethodSetting": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminSignatureMethodSetting" + }, + { + "type": "object", + "properties": { + "identifyMethod": { + "type": "string", + "nullable": true + }, + "needCode": { + "type": "boolean" + }, + "hasConfirmCode": { + "type": "boolean" + }, + "blurredEmail": { + "type": "string" + }, + "hashOfEmail": { + "type": "string" + }, + "blurredIdentifier": { + "type": "string" + }, + "hashOfIdentifier": { + "type": "string" + }, + "hasSignatureFile": { + "type": "boolean" + } + } + } + ] + }, "ProgressError": { "type": "object", "required": [ @@ -2179,7 +2697,18 @@ "type": "string" }, "value": { - "type": "string" + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, @@ -2347,63 +2876,6 @@ } } }, - "SignatureTemplateSettingsResponse": { - "type": "object", - "required": [ - "default_signature_text_template", - "signature_available_variables" - ], - "properties": { - "default_signature_text_template": { - "type": "string" - }, - "signature_available_variables": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "SignatureTextSettingsResponse": { - "type": "object", - "required": [ - "template", - "parsed", - "templateFontSize", - "signatureFontSize", - "signatureWidth", - "signatureHeight", - "renderMode" - ], - "properties": { - "template": { - "type": "string" - }, - "parsed": { - "type": "string" - }, - "templateFontSize": { - "type": "number", - "format": "double" - }, - "signatureFontSize": { - "type": "number", - "format": "double" - }, - "signatureWidth": { - "type": "number", - "format": "double" - }, - "signatureHeight": { - "type": "number", - "format": "double" - }, - "renderMode": { - "type": "string" - } - } - }, "SignerCertificateInfo": { "type": "object", "properties": { @@ -2619,6 +3091,77 @@ } } }, + "SystemPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/SystemPolicyState" + } + } + }, + "SystemPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "system", + "global" + ] + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "SystemPolicyWriteRequest": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -2696,14 +3239,66 @@ } } }, - "ValidateMetadata": { + "UserPolicyResponse": { "type": "object", "required": [ - "extension", - "p" + "policy" ], "properties": { - "extension": { + "policy": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + }, + "UserPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "user_policy" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "UserPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/UserPolicyResponse" + } + ] + }, + "ValidateMetadata": { + "type": "object", + "required": [ + "extension", + "p" + ], + "properties": { + "extension": { "type": "string" }, "p": { @@ -2733,6 +3328,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2741,6 +3339,29 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "legal_information": { + "$ref": "#/components/schemas/PolicySnapshotLegalInformationEntry" + }, + "identification_documents": { + "$ref": "#/components/schemas/PolicySnapshotIdentificationDocumentsEntry" + }, + "identify_methods": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodsEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -2949,6 +3570,21 @@ } } }, + "ValidatedFileResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ValidatedFile" + }, + { + "type": "object", + "properties": { + "effective_policies": { + "$ref": "#/components/schemas/EffectivePoliciesResponse" + } + } + } + ] + }, "ValidationPage": { "type": "object", "required": [ @@ -4207,119 +4843,6 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template/preview-pdf": { - "post": { - "operationId": "admin-footer-template-preview-pdf", - "summary": "Preview footer template as PDF", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "template": { - "type": "string", - "default": "", - "description": "Template to preview" - }, - "width": { - "type": "integer", - "format": "int64", - "default": 595, - "description": "Width of preview in points (default: 595 - A4 width)" - }, - "height": { - "type": "integer", - "format": "int64", - "default": 50, - "description": "Height of preview in points (default: 50)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", - "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/ErrorResponse" - } - } - } - } - } - } - } - } - } - } - }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/validate/uuid/{uuid}": { "get": { "operationId": "file-validate-uuid", @@ -4431,7 +4954,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ValidatedFile" + "$ref": "#/components/schemas/ValidatedFileResponse" } } } @@ -4585,7 +5108,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ValidatedFile" + "$ref": "#/components/schemas/ValidatedFileResponse" } } } @@ -4690,7 +5213,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ValidatedFile" + "$ref": "#/components/schemas/ValidatedFileResponse" } } } @@ -5422,7 +5945,7 @@ "post": { "operationId": "file-save", "summary": "Send a file", - "description": "Send a new file to Nextcloud and return the fileId to request signature. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'.\n**Note on multiple file uploads:** PHP has a limit on the number of files that can be uploaded in a single request (max_file_uploads directive, default 20). When uploading many files (more than 20), consider uploading them sequentially in multiple requests or use individual file uploads like the Files app does.", + "description": "Send a new file to Nextcloud and return the fileId used to create a signature request. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'.\n**Note on multiple file uploads:** PHP has a limit on the number of files that can be uploaded in a single request (max_file_uploads directive, default 20). When uploading many files (more than 20), consider uploading them sequentially in multiple requests or use individual file uploads like the Files app does.", "tags": [ "file" ], @@ -6455,15 +6978,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs": { - "post": { - "operationId": "id_docs-add-files", - "summary": "Add identification documents to user profile", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/footer-template": { + "get": { + "operationId": "footer_template-get-footer-template", + "summary": "Get footer template", + "description": "Returns the current footer template if set, otherwise returns the default template.", "tags": [ - "id_docs" + "footer_template" ], "security": [ - {}, { "bearer_auth": [] }, @@ -6471,28 +6994,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "files" - ], - "properties": { - "files": { - "type": "array", - "description": "The list of files to add to profile", - "items": { - "$ref": "#/components/schemas/IdDocs" - } - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -6519,7 +7020,7 @@ ], "responses": { "200": { - "description": "Certificate saved with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -6538,7 +7039,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/FooterTemplateResponse" + } } } } @@ -6546,8 +7049,8 @@ } } }, - "401": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6567,7 +7070,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsUploadErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6578,14 +7081,14 @@ } } }, - "get": { - "operationId": "id_docs-list-unauthenticated-signer-documents", - "summary": "List files of unauthenticated account", + "post": { + "operationId": "footer_template-save-footer-template", + "summary": "Save footer template and render preview", + "description": "Saves the footer template and returns the rendered PDF preview.", "tags": [ - "id_docs" + "footer_template" ], "security": [ - {}, { "bearer_auth": [] }, @@ -6593,6 +7096,35 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "string", + "default": "", + "description": "The Twig template to save (empty to reset to default)" + }, + "width": { + "type": "integer", + "format": "int64", + "default": 595, + "description": "Width of preview in points (default: 595 - A4 width)" + }, + "height": { + "type": "integer", + "format": "int64", + "default": 50, + "description": "Height of preview in points (default: 50)" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -6606,45 +7138,6 @@ "default": "v1" } }, - { - "name": "userId", - "in": "query", - "description": "User ID to filter by", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "signRequestId", - "in": "query", - "description": "Sign request ID to filter by", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "page", - "in": "query", - "description": "the number of page to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "length", - "in": "query", - "description": "Total of elements to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -6658,7 +7151,18 @@ ], "responses": { "200": { - "description": "Certificate saved with success", + "description": "OK", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -6678,7 +7182,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsListResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6687,8 +7191,8 @@ } } }, - "404": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6708,7 +7212,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6720,15 +7224,14 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/{nodeId}": { - "delete": { - "operationId": "id_docs-delete", - "summary": "Delete file from account", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/footer-template/preview-pdf": { + "post": { + "operationId": "footer_template-preview-pdf", + "summary": "Preview footer template as PDF", "tags": [ - "id_docs" + "footer_template" ], "security": [ - {}, { "bearer_auth": [] }, @@ -6736,6 +7239,40 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "string", + "default": "", + "description": "Template to preview" + }, + "width": { + "type": "integer", + "format": "int64", + "default": 595, + "description": "Width of preview in points (default: 595 - A4 width)" + }, + "height": { + "type": "integer", + "format": "int64", + "default": 50, + "description": "Height of preview in points (default: 50)" + }, + "writeQrcodeOnFooter": { + "type": "boolean", + "nullable": true, + "description": "Whether to force QR code rendering in footer preview (null uses policy)" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -6749,25 +7286,6 @@ "default": "v1" } }, - { - "name": "nodeId", - "in": "path", - "description": "the nodeId of file to be delete", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "uuid", - "in": "query", - "description": "Sign request UUID for unauthenticated access", - "schema": { - "type": "string", - "nullable": true - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -6781,7 +7299,18 @@ ], "responses": { "200": { - "description": "File deleted with success", + "description": "OK", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -6800,7 +7329,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } } } } @@ -6808,8 +7339,8 @@ } } }, - "401": { - "description": "Failure to delete file from account", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6829,7 +7360,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessagesResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6841,14 +7372,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/approval/list": { - "get": { - "operationId": "id_docs-list-to-approval", - "summary": "List files that need to be approved", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs": { + "post": { + "operationId": "id_docs-add-files", + "summary": "Add identification documents to user profile", "tags": [ "id_docs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -6856,6 +7388,28 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "type": "array", + "description": "The list of files to add to profile", + "items": { + "$ref": "#/components/schemas/IdDocs" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -6869,63 +7423,6 @@ "default": "v1" } }, - { - "name": "userId", - "in": "query", - "description": "User ID to filter by", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "signRequestId", - "in": "query", - "description": "Sign request ID to filter by", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "page", - "in": "query", - "description": "the number of page to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "length", - "in": "query", - "description": "Total of elements to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "sortBy", - "in": "query", - "description": "Sort field (e.g., 'owner', 'file_type', 'status')", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "sortOrder", - "in": "query", - "description": "Sort order (ASC or DESC)", - "schema": { - "type": "string", - "nullable": true - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -6939,7 +7436,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Certificate saved with success", "content": { "application/json": { "schema": { @@ -6958,9 +7455,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "$ref": "#/components/schemas/IdDocsApprovalListResponse" - } + "data": {} } } } @@ -6968,8 +7463,8 @@ } } }, - "404": { - "description": "Account not found", + "401": { + "description": "No file provided or other problem with provided file", "content": { "application/json": { "schema": { @@ -6989,7 +7484,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/IdDocsUploadErrorResponse" } } } @@ -6999,17 +7494,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/identify-account/search": { + }, "get": { - "operationId": "identify-search", - "summary": "List possible signers", - "description": "Used to identify who can sign the document. The return of this endpoint is related with Administration Settiongs > LibreSign > Identify method.", + "operationId": "id_docs-list-unauthenticated-signer-documents", + "summary": "List files of unauthenticated account", "tags": [ - "identify" + "id_docs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -7031,41 +7524,42 @@ } }, { - "name": "search", + "name": "userId", "in": "query", - "description": "search params", + "description": "User ID to filter by", "schema": { "type": "string", - "default": "" + "nullable": true } }, { - "name": "method", + "name": "signRequestId", "in": "query", - "description": "filter by method (email, account, sms, signal, telegram, whatsapp, xmpp)", + "description": "Sign request ID to filter by", "schema": { - "type": "string", - "default": "" + "type": "integer", + "format": "int64", + "nullable": true } }, { "name": "page", "in": "query", - "description": "the number of page to return. Default: 1", + "description": "the number of page to return", "schema": { "type": "integer", "format": "int64", - "default": 1 + "nullable": true } }, { - "name": "limit", + "name": "length", "in": "query", - "description": "Total of elements to return. Default: 25", + "description": "Total of elements to return", "schema": { "type": "integer", "format": "int64", - "default": 25 + "nullable": true } }, { @@ -7101,7 +7595,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdentifyAccountsResponse" + "$ref": "#/components/schemas/IdDocsListResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "No file provided or other problem with provided file", + "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/MessageResponse" } } } @@ -7113,14 +7637,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signers": { - "post": { - "operationId": "notify-signers", - "summary": "Notify signers of a file", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/{nodeId}": { + "delete": { + "operationId": "id_docs-delete", + "summary": "Delete file from account", "tags": [ - "notify" + "id_docs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -7128,42 +7653,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "fileId", - "signers" - ], - "properties": { - "fileId": { - "type": "integer", - "format": "int64", - "description": "The identifier value of LibreSign file" - }, - "signers": { - "type": "array", - "description": "Signers data", - "items": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - } - } - } - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -7177,6 +7666,25 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "the nodeId of file to be delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "uuid", + "in": "query", + "description": "Sign request UUID for unauthenticated access", + "schema": { + "type": "string", + "nullable": true + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -7190,7 +7698,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "File deleted with success", "content": { "application/json": { "schema": { @@ -7209,9 +7717,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "$ref": "#/components/schemas/MessageResponse" - } + "data": {} } } } @@ -7220,7 +7726,7 @@ } }, "401": { - "description": "Unauthorized", + "description": "Failure to delete file from account", "content": { "application/json": { "schema": { @@ -7240,7 +7746,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$ref": "#/components/schemas/MessagesResponse" } } } @@ -7252,12 +7758,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signer": { - "post": { - "operationId": "notify-signer", - "summary": "Notify a signer of a file", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/approval/list": { + "get": { + "operationId": "id_docs-list-to-approval", + "summary": "List files that need to be approved", "tags": [ - "notify" + "id_docs" ], "security": [ { @@ -7267,32 +7773,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "fileId", - "signRequestId" - ], - "properties": { - "fileId": { - "type": "integer", - "format": "int64", - "description": "The identifier value of LibreSign file" - }, - "signRequestId": { - "type": "integer", - "format": "int64", - "description": "The sign request id" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -7306,6 +7786,63 @@ "default": "v1" } }, + { + "name": "userId", + "in": "query", + "description": "User ID to filter by", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "signRequestId", + "in": "query", + "description": "Sign request ID to filter by", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "page", + "in": "query", + "description": "the number of page to return", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "length", + "in": "query", + "description": "Total of elements to return", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Sort field (e.g., 'owner', 'file_type', 'status')", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort order (ASC or DESC)", + "schema": { + "type": "string", + "nullable": true + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -7339,7 +7876,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/IdDocsApprovalListResponse" } } } @@ -7348,8 +7885,8 @@ } } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Account not found", "content": { "application/json": { "schema": { @@ -7369,7 +7906,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -7381,12 +7918,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/notification": { - "delete": { - "operationId": "notify-notification-dismiss", - "summary": "Dismiss a specific notification", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/identify-account/search": { + "get": { + "operationId": "identify-search", + "summary": "List possible signers", + "description": "Used to identify who can be invited to sign a document. The response follows Administration Settings > LibreSign > Identify method.", "tags": [ - "notify" + "identify" ], "security": [ { @@ -7410,41 +7948,41 @@ } }, { - "name": "objectType", + "name": "search", "in": "query", - "description": "The type of object", - "required": true, + "description": "search params", "schema": { - "type": "string" + "type": "string", + "default": "" } }, { - "name": "objectId", + "name": "method", "in": "query", - "description": "The identifier value of LibreSign file", - "required": true, + "description": "filter by method (email, account, sms, signal, telegram, whatsapp, whatsappbusiness, xmpp)", "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "default": "" } }, { - "name": "subject", + "name": "page", "in": "query", - "description": "The subject of notification", - "required": true, + "description": "the number of page to return. Default: 1", "schema": { - "type": "string" + "type": "integer", + "format": "int64", + "default": 1 } }, { - "name": "timestamp", + "name": "limit", "in": "query", - "description": "Timestamp of notification to dismiss", - "required": true, + "description": "Total of elements to return. Default: 25", "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "default": 25 } }, { @@ -7460,7 +7998,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Certificate saved with success", "content": { "application/json": { "schema": { @@ -7480,7 +8018,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/IdentifyAccountsResponse" } } } @@ -7492,13 +8030,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signers": { "post": { - "operationId": "request_signature-request-signature", - "summary": "Request signature", - "description": "Request that a file be signed by a list of signers. Each signer in the signers array can optionally include a 'signingOrder' field to control the order of signatures when ordered signing flow is enabled. The returned `data` always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.", + "operationId": "notify-signers", + "summary": "Notify signers of a file", "tags": [ - "signing" + "notify" ], "security": [ { @@ -7509,59 +8046,35 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "fileId", + "signers" + ], "properties": { - "signers": { - "type": "array", - "default": [], - "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status", - "items": { - "$ref": "#/components/schemas/NewSigner" - } - }, - "name": { - "type": "string", - "default": "", - "description": "The name of file to sign" - }, - "settings": { - "$ref": "#/components/schemas/FolderSettings", - "default": [], - "description": "Settings to define how and where the file should be stored" - }, - "file": { - "$ref": "#/components/schemas/NewFile", - "default": [], - "description": "File object. Supports nodeId, url, base64 or path." + "fileId": { + "type": "integer", + "format": "int64", + "description": "The identifier value of LibreSign file" }, - "files": { + "signers": { "type": "array", - "default": [], - "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", + "description": "Signers data", "items": { - "$ref": "#/components/schemas/NewFile" + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } } - }, - "callback": { - "type": "string", - "nullable": true, - "description": "URL that will receive a POST after the document is signed" - }, - "status": { - "type": "integer", - "format": "int64", - "nullable": true, - "default": 1, - "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" - }, - "signatureFlow": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" } } } @@ -7614,7 +8127,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -7623,7 +8136,7 @@ } } }, - "422": { + "401": { "description": "Unauthorized", "content": { "application/json": { @@ -7644,14 +8157,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/DangerMessagesResponse" } } } @@ -7661,13 +8167,14 @@ } } } - }, - "patch": { - "operationId": "request_signature-update-signature-request", - "summary": "Updates signatures data", - "description": "It is necessary to inform the UUID of the file and a list of signers.", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signer": { + "post": { + "operationId": "notify-signer", + "summary": "Notify a signer of a file", "tags": [ - "signing" + "notify" ], "security": [ { @@ -7678,67 +8185,25 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "fileId", + "signRequestId" + ], "properties": { - "signers": { - "type": "array", - "nullable": true, - "default": [], - "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.", - "items": { - "$ref": "#/components/schemas/NewSigner" - } - }, - "uuid": { - "type": "string", - "nullable": true, - "description": "UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID." - }, - "visibleElements": { - "type": "array", - "nullable": true, - "description": "Visible elements on document", - "items": { - "$ref": "#/components/schemas/VisibleElement" - } - }, - "file": { - "$ref": "#/components/schemas/NewFile", - "nullable": true, - "description": "File object. Supports nodeId, url, base64 or path when creating a new request." - }, - "status": { + "fileId": { "type": "integer", "format": "int64", - "nullable": true, - "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" - }, - "signatureFlow": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" - }, - "name": { - "type": "string", - "nullable": true, - "description": "The name of file to sign" - }, - "settings": { - "$ref": "#/components/schemas/FolderSettings", - "default": [], - "description": "Settings to define how and where the file should be stored" + "description": "The identifier value of LibreSign file" }, - "files": { - "type": "array", - "default": [], - "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", - "items": { - "$ref": "#/components/schemas/NewFile" - } + "signRequestId": { + "type": "integer", + "format": "int64", + "description": "The sign request id" } } } @@ -7791,7 +8256,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -7800,7 +8265,7 @@ } } }, - "422": { + "401": { "description": "Unauthorized", "content": { "application/json": { @@ -7821,14 +8286,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/DangerMessagesResponse" } } } @@ -7840,13 +8298,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/notification": { "delete": { - "operationId": "request_signature-remove-signer", - "summary": "Delete sign request", - "description": "You can only request exclusion as any sign", + "operationId": "notify-notification-dismiss", + "summary": "Dismiss a specific notification", "tags": [ - "signing" + "notify" ], "security": [ { @@ -7870,9 +8327,18 @@ } }, { - "name": "fileId", - "in": "path", - "description": "LibreSign file ID", + "name": "objectType", + "in": "query", + "description": "The type of object", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "query", + "description": "The identifier value of LibreSign file", "required": true, "schema": { "type": "integer", @@ -7880,9 +8346,18 @@ } }, { - "name": "signRequestId", - "in": "path", - "description": "The sign request id", + "name": "subject", + "in": "query", + "description": "The subject of notification", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "timestamp", + "in": "query", + "description": "Timestamp of notification to dismiss", "required": true, "schema": { "type": "integer", @@ -7922,7 +8397,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "type": "object" } } } @@ -7930,39 +8405,52 @@ } } } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": { + "get": { + "operationId": "policy-effective", + "summary": "Effective policies bootstrap", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] }, - "401": { - "description": "Failed", - "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/MessageResponse" - } - } - } - } - } - } + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" } }, - "422": { - "description": "Failed", + { + "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": "OK", "content": { "application/json": { "schema": { @@ -7982,7 +8470,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/EffectivePoliciesResponse" } } } @@ -7994,13 +8482,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": { - "delete": { - "operationId": "request_signature-delete-signature-request", - "summary": "Delete sign request", - "description": "You can only request exclusion as any sign", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": { + "get": { + "operationId": "policy-get-group", + "summary": "Read a group-level policy value", "tags": [ - "signing" + "policy" ], "security": [ { @@ -8024,13 +8511,23 @@ } }, { - "name": "fileId", + "name": "groupId", "in": "path", - "description": "LibreSign file ID", + "description": "Group identifier that receives the policy binding.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8066,37 +8563,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" - } - } - } - } - } - } - } - }, - "401": { - "description": "Failed", - "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/MessageResponse" + "$ref": "#/components/schemas/GroupPolicyResponse" } } } @@ -8105,8 +8572,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8126,7 +8593,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8137,14 +8604,13 @@ } } }, - "post": { - "operationId": "sign_file-sign-by-file-id", - "summary": "Sign a file using file Id", + "put": { + "operationId": "policy-set-group", + "summary": "Save a group-level policy value", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8153,41 +8619,42 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "method" - ], "properties": { - "method": { - "type": "string", - "description": "Signature method" - }, - "elements": { - "type": "object", - "default": {}, - "description": "List of visible elements", - "additionalProperties": { - "type": "object" - } - }, - "identifyValue": { - "type": "string", - "default": "", - "description": "Identify value" - }, - "token": { - "type": "string", - "default": "", - "description": "Token, commonly send by email" + "value": { + "nullable": true, + "description": "Policy value to persist for the group.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] }, - "async": { + "allowChildOverride": { "type": "boolean", "default": false, - "description": "Execute signing asynchronously when possible" + "description": "Whether users and requests below this group may override the group default." } } } @@ -8208,13 +8675,23 @@ } }, { - "name": "fileId", + "name": "groupId", "in": "path", - "description": "Id of LibreSign file", + "description": "Group identifier that receives the policy binding.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the group layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8250,7 +8727,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -8259,8 +8736,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8280,7 +8757,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" } } } @@ -8290,17 +8797,14 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": { - "post": { - "operationId": "sign_file-sign-by-signer-uuid", - "summary": "Sign a file using file UUID", + }, + "delete": { + "operationId": "policy-clear-group", + "summary": "Clear a group-level policy value", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8308,48 +8812,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "method" - ], - "properties": { - "method": { - "type": "string", - "description": "Signature method" - }, - "elements": { - "type": "object", - "default": {}, - "description": "List of visible elements", - "additionalProperties": { - "type": "object" - } - }, - "identifyValue": { - "type": "string", - "default": "", - "description": "Identify value" - }, - "token": { - "type": "string", - "default": "", - "description": "Token, commonly send by email" - }, - "async": { - "type": "boolean", - "default": false, - "description": "Execute signing asynchronously when possible" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -8364,12 +8826,23 @@ } }, { - "name": "uuid", + "name": "groupId", "in": "path", - "description": "UUID of LibreSign file", + "description": "Group identifier that receives the policy binding.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8405,7 +8878,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -8414,8 +8887,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8435,7 +8908,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" } } } @@ -8447,15 +8950,14 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": { - "post": { - "operationId": "sign_file-sign-renew", - "summary": "Renew the signature method", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/by-policy/group/{policyKey}": { + "get": { + "operationId": "policy-list-group-policies", + "summary": "List all explicit group-level policy values for a policy key", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8477,20 +8979,13 @@ } }, { - "name": "uuid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "method", + "name": "policyKey", "in": "path", - "description": "Signature method", + "description": "Policy identifier to list group rules.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8526,7 +9021,18 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupPolicyState" + } + } + } } } } @@ -8538,15 +9044,14 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/code": { - "post": { - "operationId": "sign_file-request-code-by-signer-uuid", - "summary": "Get code to sign the document using UUID", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": { + "get": { + "operationId": "policy-get-user-policy-for-user", + "summary": "Read an explicit user-level policy for a target user (admin scope)", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8554,37 +9059,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "identifyMethod": { - "type": "string", - "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" - }, - "signMethod": { - "type": "string", - "nullable": true, - "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" - }, - "identify": { - "type": "string", - "nullable": true, - "description": "Identify value, i.e. the signer email, account or phone number" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -8599,12 +9073,23 @@ } }, { - "name": "uuid", + "name": "userId", "in": "path", - "description": "UUID of LibreSign file", + "description": "Target user identifier that receives the policy assignment.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8640,7 +9125,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/UserPolicyResponse" } } } @@ -8649,8 +9134,8 @@ } } }, - "422": { - "description": "Error", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8670,7 +9155,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8680,17 +9165,14 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": { - "post": { - "operationId": "sign_file-request-code-by-file-id", - "summary": "Get code to sign the document using FileID", + }, + "put": { + "operationId": "policy-set-user-policy-for-user", + "summary": "Save an explicit user policy for a target user (admin scope)", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8705,24 +9187,36 @@ "schema": { "type": "object", "properties": { - "identifyMethod": { - "type": "string", - "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" - }, - "signMethod": { - "type": "string", + "value": { "nullable": true, - "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + "description": "Policy value to persist as assigned target user policy.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] }, - "identify": { - "type": "string", - "nullable": true, - "description": "Identify value, i.e. the signer email, account or phone number" + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." } } } @@ -8743,13 +9237,23 @@ } }, { - "name": "fileId", + "name": "userId", "in": "path", - "description": "Id of LibreSign file", + "description": "Target user identifier that receives the policy assignment.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the target user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8785,7 +9289,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -8794,8 +9298,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8815,7 +9319,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" } } } @@ -8825,17 +9359,14 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { - "post": { - "operationId": "signature_elements-create-signature-element", - "summary": "Create signature element", + }, + "delete": { + "operationId": "policy-clear-user-policy-for-user", + "summary": "Clear an explicit user policy for a target user (admin scope)", "tags": [ - "signature_elements" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8843,28 +9374,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "elements" - ], - "properties": { - "elements": { - "type": "object", - "description": "Element object", - "additionalProperties": { - "type": "object" - } - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -8878,6 +9387,26 @@ "default": "v1" } }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment removal.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the target user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -8911,7 +9440,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -8920,8 +9449,8 @@ } } }, - "422": { - "description": "Invalid data", + "400": { + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -8941,7 +9470,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" } } } @@ -8951,15 +9510,16 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/by-policy/user/{policyKey}": { "get": { - "operationId": "signature_elements-get-signature-elements", - "summary": "Get signature elements", + "operationId": "policy-list-user-policies", + "summary": "List all explicit user-level policy values for a policy key", "tags": [ - "signature_elements" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8980,6 +9540,16 @@ "default": "v1" } }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to list user rules.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9013,37 +9583,18 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsResponse" - } - } - } - } - } - } - } - }, - "404": { - "description": "Invalid data", - "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/MessageResponse" + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + } } } } @@ -9055,15 +9606,14 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": { - "get": { - "operationId": "signature_elements-preview-signature-element", - "summary": "Get preview of signature elements of", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": { + "put": { + "operationId": "policy-set-user-preference", + "summary": "Save a user policy preference", "tags": [ - "signature_elements" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9071,6 +9621,44 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist as the current user's default.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -9085,13 +9673,13 @@ } }, { - "name": "nodeId", + "name": "policyKey", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "Policy identifier to persist for the current user.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -9109,16 +9697,35 @@ "200": { "description": "OK", "content": { - "*/*": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } } } } }, - "404": { - "description": "Invalid data", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -9138,7 +9745,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -9148,14 +9755,12 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { - "get": { - "operationId": "signature_elements-get-signature-element", - "summary": "Get signature element of signer", + }, + "delete": { + "operationId": "policy-clear-user-preference", + "summary": "Clear a user policy preference", "tags": [ - "signature_elements" + "policy" ], "security": [ { @@ -9179,13 +9784,13 @@ } }, { - "name": "nodeId", + "name": "policyKey", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "Policy identifier to clear for the current user.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -9221,7 +9826,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElement" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -9230,8 +9835,8 @@ } } }, - "404": { - "description": "Invalid data", + "400": { + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -9251,7 +9856,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -9261,15 +9866,17 @@ } } } - }, - "patch": { - "operationId": "signature_elements-patch-signature-element", - "summary": "Update signature element", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { + "post": { + "operationId": "request_signature-request-signature", + "summary": "Request signature", + "description": "Request that a file be signed by a list of signers. Each signer in the signers array can optionally include a 'signingOrder' field to control the order of signatures when ordered signing flow is enabled. The returned `data` always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.", "tags": [ - "signature_elements" + "signing" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9284,15 +9891,53 @@ "schema": { "type": "object", "properties": { - "type": { + "signers": { + "type": "array", + "default": [], + "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status", + "items": { + "$ref": "#/components/schemas/NewSigner" + } + }, + "name": { "type": "string", "default": "", - "description": "The type of signature element" + "description": "The name of file to sign" + }, + "settings": { + "$ref": "#/components/schemas/FolderSettings", + "default": [], + "description": "Settings to define how and where the file should be stored" }, "file": { + "$ref": "#/components/schemas/NewFile", + "default": [], + "description": "File object. Supports nodeId, url, base64 or path." + }, + "files": { + "type": "array", + "default": [], + "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", + "items": { + "$ref": "#/components/schemas/NewFile" + } + }, + "callback": { + "type": "string", + "nullable": true, + "description": "URL that will receive a POST after the document is signed" + }, + "status": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": 1, + "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" + }, + "policy": { "type": "object", - "default": {}, - "description": "Element object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", "additionalProperties": { "type": "object" } @@ -9315,16 +9960,6 @@ "default": "v1" } }, - { - "name": "nodeId", - "in": "path", - "description": "Node id of a Nextcloud file", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -9358,7 +9993,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -9368,7 +10003,7 @@ } }, "422": { - "description": "Error", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9388,7 +10023,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -9399,14 +10041,14 @@ } } }, - "delete": { - "operationId": "signature_elements-delete-signature-element", - "summary": "Delete signature element", + "patch": { + "operationId": "request_signature-update-signature-request", + "summary": "Updates signatures data", + "description": "It is necessary to inform the UUID of the file and a list of signers.", "tags": [ - "signature_elements" + "signing" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9414,6 +10056,77 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signers": { + "type": "array", + "nullable": true, + "default": [], + "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.", + "items": { + "$ref": "#/components/schemas/NewSigner" + } + }, + "uuid": { + "type": "string", + "nullable": true, + "description": "UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID." + }, + "visibleElements": { + "type": "array", + "nullable": true, + "description": "Visible elements on document", + "items": { + "$ref": "#/components/schemas/VisibleElement" + } + }, + "file": { + "$ref": "#/components/schemas/NewFile", + "nullable": true, + "description": "File object. Supports nodeId, url, base64 or path when creating a new request." + }, + "status": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" + }, + "policy": { + "type": "object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } + }, + "name": { + "type": "string", + "nullable": true, + "description": "The name of file to sign" + }, + "settings": { + "$ref": "#/components/schemas/FolderSettings", + "default": [], + "description": "Settings to define how and where the file should be stored" + }, + "files": { + "type": "array", + "default": [], + "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", + "items": { + "$ref": "#/components/schemas/NewFile" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -9427,16 +10140,6 @@ "default": "v1" } }, - { - "name": "nodeId", - "in": "path", - "description": "Node id of a Nextcloud file", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -9470,7 +10173,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -9479,8 +10182,8 @@ } } }, - "404": { - "description": "Not found", + "422": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9500,7 +10203,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -9512,13 +10222,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { - "post": { - "operationId": "admin-generate-certificate-cfssl", - "summary": "Generate certificate using CFSSL engine", - "description": "This endpoint requires admin access", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": { + "delete": { + "operationId": "request_signature-remove-signer", + "summary": "Delete sign request", + "description": "You can only request exclusion as any sign", "tags": [ - "admin" + "signing" ], "security": [ { @@ -9528,68 +10238,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "rootCert" - ], - "properties": { - "rootCert": { - "type": "object", - "description": "fields of root certificate", - "required": [ - "commonName", - "names" - ], - "properties": { - "commonName": { - "type": "string" - }, - "names": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - } - } - } - }, - "cfsslUri": { - "type": "string", - "default": "", - "description": "URI of CFSSL API" - }, - "configPath": { - "type": "string", - "default": "", - "description": "Path of config files of CFSSL" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -9603,6 +10251,26 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "LibreSign file ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "signRequestId", + "in": "path", + "description": "The sign request id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9636,7 +10304,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9646,7 +10314,7 @@ } }, "401": { - "description": "Account not found", + "description": "Failed", "content": { "application/json": { "schema": { @@ -9674,140 +10342,9 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/openssl": { - "post": { - "operationId": "admin-generate-certificate-open-ssl", - "summary": "Generate certificate using OpenSSL engine", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "rootCert" - ], - "properties": { - "rootCert": { - "type": "object", - "description": "fields of root certificate", - "required": [ - "commonName", - "names" - ], - "properties": { - "commonName": { - "type": "string" - }, - "names": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - } - } - } - }, - "configPath": { - "type": "string", - "default": "", - "description": "Path of config files of CFSSL" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } }, - { - "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": "OK", - "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/EngineHandlerResponse" - } - } - } - } - } - } - } - }, - "401": { - "description": "Account not found", + "422": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9827,7 +10364,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ActionErrorResponse" } } } @@ -9839,13 +10376,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": { - "post": { - "operationId": "admin-set-certificate-engine", - "summary": "Set certificate engine", - "description": "Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed\nThis endpoint requires admin access", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": { + "delete": { + "operationId": "request_signature-delete-signature-request", + "summary": "Delete sign request", + "description": "You can only request exclusion as any sign", "tags": [ - "admin" + "signing" ], "security": [ { @@ -9855,25 +10392,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "engine" - ], - "properties": { - "engine": { - "type": "string", - "description": "The certificate engine to use (openssl, cfssl, or none)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -9887,6 +10405,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "LibreSign file ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9920,7 +10448,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificateEngineConfigResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9929,8 +10457,8 @@ } } }, - "400": { - "description": "Invalid engine", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9958,53 +10486,9 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate": { - "get": { - "operationId": "admin-load-certificate", - "summary": "Load certificate data", - "description": "Return all data of root certificate and a field called `generated` with a boolean value.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } }, - { - "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": "OK", + "422": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -10024,7 +10508,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificateDataGenerated" + "$ref": "#/components/schemas/ActionErrorResponse" } } } @@ -10034,17 +10518,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/configure-check": { - "get": { - "operationId": "admin-configure-check", - "summary": "Check the configuration of LibreSign", - "description": "Return the status of necessary configuration and tips to fix the problems.\nThis endpoint requires admin access", + }, + "post": { + "operationId": "sign_file-sign-by-file-id", + "summary": "Sign a file using file Id", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10052,6 +10534,48 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "method" + ], + "properties": { + "method": { + "type": "string", + "description": "Signature method" + }, + "elements": { + "type": "object", + "default": {}, + "description": "List of visible elements", + "additionalProperties": { + "type": "object" + } + }, + "identifyValue": { + "type": "string", + "default": "", + "description": "Identify value" + }, + "token": { + "type": "string", + "default": "", + "description": "Token, commonly send by email" + }, + "async": { + "type": "boolean", + "default": false, + "description": "Execute signing asynchronously when possible" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10065,6 +10589,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "Id of LibreSign file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10098,7 +10632,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ConfigureChecksResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -10106,53 +10640,9 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/disable-hate-limit": { - "get": { - "operationId": "admin-disable-hate-limit", - "summary": "Disable hate limit to current session", - "description": "This will disable hate limit to current session.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } }, - { - "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": "OK", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -10172,7 +10662,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -10184,15 +10674,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": { "post": { - "operationId": "admin-signature-background-save", - "summary": "Add custom background image", - "description": "This endpoint requires admin access", + "operationId": "sign_file-sign-by-signer-uuid", + "summary": "Sign a file using file UUID", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10200,6 +10690,48 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "method" + ], + "properties": { + "method": { + "type": "string", + "description": "Signature method" + }, + "elements": { + "type": "object", + "default": {}, + "description": "List of visible elements", + "additionalProperties": { + "type": "object" + } + }, + "identifyValue": { + "type": "string", + "default": "", + "description": "Identify value" + }, + "token": { + "type": "string", + "default": "", + "description": "Token, commonly send by email" + }, + "async": { + "type": "boolean", + "default": false, + "description": "Execute signing asynchronously when possible" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10213,6 +10745,15 @@ "default": "v1" } }, + { + "name": "uuid", + "in": "path", + "description": "UUID of LibreSign file", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10246,7 +10787,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -10276,7 +10817,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -10286,15 +10827,17 @@ } } } - }, - "get": { - "operationId": "admin-signature-background-get", - "summary": "Get custom background image", - "description": "This endpoint requires admin access", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": { + "post": { + "operationId": "sign_file-sign-renew", + "summary": "Renew the signature method", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10315,6 +10858,23 @@ "default": "v1" } }, + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "method", + "in": "path", + "description": "Signature method", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10328,26 +10888,47 @@ ], "responses": { "200": { - "description": "Image returned", + "description": "OK", "content": { - "*/*": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + } } } } } } - }, - "patch": { - "operationId": "admin-signature-background-reset", - "summary": "Reset the background image to be the default of LibreSign", - "description": "This endpoint requires admin access", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/code": { + "post": { + "operationId": "sign_file-request-code-by-signer-uuid", + "summary": "Get code to sign the document using UUID", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10355,6 +10936,37 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "identifyMethod": { + "type": "string", + "nullable": true, + "enum": [ + "account", + "email" + ], + "description": "Identify signer method" + }, + "signMethod": { + "type": "string", + "nullable": true, + "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + }, + "identify": { + "type": "string", + "nullable": true, + "description": "Identify value, i.e. the signer email, account or phone number" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10368,6 +10980,15 @@ "default": "v1" } }, + { + "name": "uuid", + "in": "path", + "description": "UUID of LibreSign file", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10381,7 +11002,37 @@ ], "responses": { "200": { - "description": "Image reseted to default", + "description": "OK", + "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/MessageResponse" + } + } + } + } + } + } + } + }, + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -10401,7 +11052,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10411,15 +11062,17 @@ } } } - }, - "delete": { - "operationId": "admin-signature-background-delete", - "summary": "Delete background image", - "description": "This endpoint requires admin access", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": { + "post": { + "operationId": "sign_file-request-code-by-file-id", + "summary": "Get code to sign the document using FileID", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10427,6 +11080,37 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "identifyMethod": { + "type": "string", + "nullable": true, + "enum": [ + "account", + "email" + ], + "description": "Identify signer method" + }, + "signMethod": { + "type": "string", + "nullable": true, + "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + }, + "identify": { + "type": "string", + "nullable": true, + "description": "Identify value, i.e. the signer email, account or phone number" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10440,6 +11124,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "Id of LibreSign file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10453,7 +11147,7 @@ ], "responses": { "200": { - "description": "Deleted with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -10473,7 +11167,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/MessageResponse" + } + } + } + } + } + } + } + }, + "422": { + "description": "Error", + "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/MessageResponse" } } } @@ -10485,15 +11209,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { "post": { - "operationId": "admin-signature-text-save", - "summary": "Save signature text service", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-create-signature-element", + "summary": "Create signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10508,41 +11232,15 @@ "schema": { "type": "object", "required": [ - "template" + "elements" ], "properties": { - "template": { - "type": "string", - "description": "Template to signature text" - }, - "templateFontSize": { - "type": "number", - "format": "double", - "default": 10, - "description": "Font size used when print the parsed text of this template at PDF file" - }, - "signatureFontSize": { - "type": "number", - "format": "double", - "default": 20, - "description": "Font size used when the signature mode is SIGNAME_AND_DESCRIPTION" - }, - "signatureWidth": { - "type": "number", - "format": "double", - "default": 350, - "description": "Signature box width, minimum 1" - }, - "signatureHeight": { - "type": "number", - "format": "double", - "default": 100, - "description": "Signature box height, minimum 1" - }, - "renderMode": { - "type": "string", - "default": "GRAPHIC_AND_DESCRIPTION", - "description": "Signature render mode" + "elements": { + "type": "object", + "description": "Element object", + "additionalProperties": { + "type": "object" + } } } } @@ -10595,7 +11293,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/UserElementsMessageResponse" } } } @@ -10604,8 +11302,8 @@ } } }, - "400": { - "description": "Bad request", + "422": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10625,7 +11323,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10637,13 +11335,13 @@ } }, "get": { - "operationId": "admin-signature-text-get", - "summary": "Get parsed signature text service", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-get-signature-elements", + "summary": "Get signature elements", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10664,24 +11362,6 @@ "default": "v1" } }, - { - "name": "template", - "in": "query", - "description": "Template to signature text", - "schema": { - "type": "string", - "default": "" - } - }, - { - "name": "context", - "in": "query", - "description": "Context for parsing the template", - "schema": { - "type": "string", - "default": "" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -10715,7 +11395,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/UserElementsResponse" } } } @@ -10724,8 +11404,8 @@ } } }, - "400": { - "description": "Bad request", + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10745,7 +11425,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10757,15 +11437,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": { "get": { - "operationId": "admin-get-signature-settings", - "summary": "Get signature settings", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-preview-signature-element", + "summary": "Get preview of signature elements of", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10786,6 +11466,16 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10800,6 +11490,17 @@ "responses": { "200": { "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10819,7 +11520,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTemplateSettingsResponse" + "type": "object" } } } @@ -10831,13 +11532,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signer-name": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { "get": { - "operationId": "admin-signer-name", - "summary": "Convert signer name as image", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-get-signature-element", + "summary": "Get signature element of signer", "tags": [ - "admin" + "signature_elements" ], "security": [ { @@ -10861,101 +11561,59 @@ } }, { - "name": "width", - "in": "query", - "description": "Image width,", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "height", - "in": "query", - "description": "Image height", + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", "required": true, "schema": { "type": "integer", "format": "int64" } }, - { - "name": "text", - "in": "query", - "description": "Text to be added to image", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "fontSize", - "in": "query", - "description": "Font size of text", - "required": true, - "schema": { - "type": "number", - "format": "double" - } - }, - { - "name": "isDarkTheme", - "in": "query", - "description": "Color of text, white if is tark theme and black if not", - "required": true, - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - }, - { - "name": "align", - "in": "query", - "description": "Align of text: left, center or right", - "required": true, - "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": "OK", - "headers": { - "Content-Disposition": { - "schema": { - "type": "string", - "enum": [ - "inline; filename=\"signer-name.png\"" - ] - } - } - }, + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { - "image/png": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/UserElement" + } + } + } + } } } } }, - "400": { - "description": "Bad request", + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10975,7 +11633,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10985,17 +11643,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { - "post": { - "operationId": "admin-save-certificate-policy", - "summary": "Update certificate policy of this instance", - "description": "This endpoint requires admin access", + }, + "patch": { + "operationId": "signature_elements-patch-signature-element", + "summary": "Update signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -11003,6 +11659,31 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "", + "description": "The type of signature element" + }, + "file": { + "type": "object", + "default": {}, + "description": "Element object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -11016,6 +11697,16 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -11049,7 +11740,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificatePolicyResponse" + "$ref": "#/components/schemas/UserElementsMessageResponse" } } } @@ -11059,7 +11750,7 @@ } }, "422": { - "description": "Not found", + "description": "Error", "content": { "application/json": { "schema": { @@ -11079,7 +11770,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11091,13 +11782,13 @@ } }, "delete": { - "operationId": "admin-delete-certificate-policy", - "summary": "Delete certificate policy of this instance", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-delete-signature-element", + "summary": "Delete signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -11118,6 +11809,16 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -11151,7 +11852,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/MessageResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "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/MessageResponse" } } } @@ -11163,13 +11894,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature-stamp/preview-pdf": { "post": { - "operationId": "admin-update-oid", - "summary": "Update OID", - "description": "This endpoint requires admin access", + "operationId": "signature_stamp_preview-preview-pdf", + "summary": "Render a preview PDF of the signature stamp with the provided configuration", "tags": [ - "admin" + "signature_stamp_preview" ], "security": [ { @@ -11180,18 +11910,44 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "oid" - ], "properties": { - "oid": { + "template": { "type": "string", - "description": "OID is a unique numeric identifier for certificate policies in digital certificates." + "default": "", + "description": "Signature text template (Twig syntax)" + }, + "templateFontSize": { + "type": "number", + "format": "double", + "description": "Font size for template text in pt" + }, + "signatureFontSize": { + "type": "number", + "format": "double", + "description": "Font size for signer name in pt" + }, + "signatureWidth": { + "type": "number", + "format": "double", + "description": "Stamp width in mm" + }, + "signatureHeight": { + "type": "number", + "format": "double", + "description": "Stamp height in mm" + }, + "renderMode": { + "type": "string", + "description": "Render mode: default, text, graphic, description_only" + }, + "backgroundType": { + "type": "string", + "description": "Background: default, custom, deleted" } } } @@ -11224,7 +11980,18 @@ ], "responses": { "200": { - "description": "OK", + "description": "Preview PDF", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -11244,7 +12011,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11254,7 +12021,7 @@ } }, "422": { - "description": "Validation error", + "description": "Rendering error", "content": { "application/json": { "schema": { @@ -11274,7 +12041,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11286,10 +12053,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { - "get": { - "operationId": "admin-reminder-fetch", - "summary": "Get reminder settings", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { + "post": { + "operationId": "admin-generate-certificate-cfssl", + "summary": "Generate certificate using CFSSL engine", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11301,7 +12068,69 @@ { "basic_auth": [] } - ], + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rootCert" + ], + "properties": { + "rootCert": { + "type": "object", + "description": "fields of root certificate", + "required": [ + "commonName", + "names" + ], + "properties": { + "commonName": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + } + } + }, + "cfsslUri": { + "type": "string", + "default": "", + "description": "URI of CFSSL API" + }, + "configPath": { + "type": "string", + "default": "", + "description": "Path of config files of CFSSL" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -11348,7 +12177,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/EngineHandlerResponse" } } } @@ -11356,88 +12185,9 @@ } } } - } - } - }, - "post": { - "operationId": "admin-reminder-save", - "summary": "Save reminder", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "daysBefore", - "daysBetween", - "max", - "sendTimer" - ], - "properties": { - "daysBefore": { - "type": "integer", - "format": "int64", - "description": "First reminder after (days)" - }, - "daysBetween": { - "type": "integer", - "format": "int64", - "description": "Days between reminders" - }, - "max": { - "type": "integer", - "format": "int64", - "description": "Max reminders per signer" - }, - "sendTimer": { - "type": "string", - "description": "Send time (HH:mm)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } }, - { - "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": "OK", + "401": { + "description": "Account not found", "content": { "application/json": { "schema": { @@ -11457,7 +12207,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11469,11 +12219,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/openssl": { "post": { - "operationId": "admin-set-tsa-config", - "summary": "Set TSA configuration values with proper sensitive data handling", - "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access", + "operationId": "admin-generate-certificate-open-ssl", + "summary": "Generate certificate using OpenSSL engine", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -11486,36 +12236,56 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "rootCert" + ], "properties": { - "tsa_url": { - "type": "string", - "nullable": true, - "description": "TSA server URL (required for saving)" - }, - "tsa_policy_oid": { - "type": "string", - "nullable": true, - "description": "TSA policy OID" - }, - "tsa_auth_type": { - "type": "string", - "nullable": true, - "description": "Authentication type (none|basic), defaults to 'none'" - }, - "tsa_username": { - "type": "string", - "nullable": true, - "description": "Username for basic authentication" + "rootCert": { + "type": "object", + "description": "fields of root certificate", + "required": [ + "commonName", + "names" + ], + "properties": { + "commonName": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + } + } }, - "tsa_password": { + "configPath": { "type": "string", - "nullable": true, - "description": "Password for basic authentication (stored as sensitive data)" + "default": "", + "description": "Path of config files of CFSSL" } } } @@ -11568,7 +12338,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/EngineHandlerResponse" } } } @@ -11577,8 +12347,8 @@ } } }, - "400": { - "description": "Validation error", + "401": { + "description": "Account not found", "content": { "application/json": { "schema": { @@ -11598,7 +12368,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11608,11 +12378,13 @@ } } } - }, - "delete": { - "operationId": "admin-delete-tsa-config", - "summary": "Delete TSA configuration", - "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": { + "post": { + "operationId": "admin-set-certificate-engine", + "summary": "Set certificate engine", + "description": "Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed\nThis endpoint requires admin access", "tags": [ "admin" ], @@ -11624,6 +12396,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "engine" + ], + "properties": { + "engine": { + "type": "string", + "description": "The certificate engine to use (openssl, cfssl, or none)" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -11670,7 +12461,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/CertificateEngineConfigResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid engine", + "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/MessageResponse" } } } @@ -11682,11 +12503,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate": { "get": { - "operationId": "admin-get-footer-template", - "summary": "Get footer template", - "description": "Returns the current footer template if set, otherwise returns the default template.\nThis endpoint requires admin access", + "operationId": "admin-load-certificate", + "summary": "Load certificate data", + "description": "Return all data of root certificate and a field called `generated` with a boolean value.\nThis endpoint requires admin access", "tags": [ "admin" ], @@ -11744,7 +12565,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FooterTemplateResponse" + "$ref": "#/components/schemas/CertificateDataGenerated" } } } @@ -11754,51 +12575,24 @@ } } } - }, - "post": { - "operationId": "admin-save-footer-template", - "summary": "Save footer template and render preview", - "description": "Saves the footer template and returns the rendered PDF preview.\nThis endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "template": { - "type": "string", - "default": "", - "description": "The Twig template to save (empty to reset to default)" - }, - "width": { - "type": "integer", - "format": "int64", - "default": 595, - "description": "Width of preview in points (default: 595 - A4 width)" - }, - "height": { - "type": "integer", - "format": "int64", - "default": 50, - "description": "Height of preview in points (default: 50)" - } - } - } - } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/configure-check": { + "get": { + "operationId": "admin-configure-check", + "summary": "Check the configuration of LibreSign", + "description": "Return the status of necessary configuration and tips to fix the problems.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] } - }, + ], "parameters": [ { "name": "apiVersion", @@ -11826,17 +12620,6 @@ "responses": { "200": { "description": "OK", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", "content": { "application/json": { "schema": { @@ -11856,7 +12639,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ConfigureChecksResponse" } } } @@ -11868,11 +12651,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { "post": { - "operationId": "admin-set-signing-mode-config", - "summary": "Set signing mode configuration", - "description": "Configure whether document signing should be synchronous or asynchronous\nThis endpoint requires admin access", + "operationId": "admin-signature-background-save", + "summary": "Add custom background image", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -11884,30 +12667,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "mode" - ], - "properties": { - "mode": { - "type": "string", - "description": "Signing mode: \"sync\" or \"async\"" - }, - "workerType": { - "type": "string", - "nullable": true, - "description": "Worker type when async: \"local\" or \"external\" (optional)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -11934,7 +12693,7 @@ ], "responses": { "200": { - "description": "Settings saved", + "description": "OK", "content": { "application/json": { "schema": { @@ -11954,7 +12713,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -11963,8 +12722,8 @@ } } }, - "400": { - "description": "Invalid parameters", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -11984,7 +12743,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -11992,9 +12751,104 @@ } } } + } + } + }, + "get": { + "operationId": "admin-signature-background-get", + "summary": "Get custom background image", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "Image returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "delete": { + "operationId": "admin-signature-background-delete", + "summary": "Delete background image", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] }, - "500": { - "description": "Internal server error", + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "Deleted with success", "content": { "application/json": { "schema": { @@ -12014,7 +12868,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -12026,10 +12880,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/groups-request-sign/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { "post": { - "operationId": "admin-set-groups-request-sign-config", - "summary": "Persist groups allowed to request signatures as typed app config array", + "operationId": "admin-save-certificate-policy", + "summary": "Upload new certificate policy PDF for this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -12042,26 +12896,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "groups": { - "type": "array", - "default": [], - "description": "List of group IDs allowed to request signatures", - "items": { - "type": "string" - } - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12088,7 +12922,7 @@ ], "responses": { "200": { - "description": "Settings saved", + "description": "OK", "content": { "application/json": { "schema": { @@ -12108,7 +12942,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/CertificatePolicyResponse" } } } @@ -12117,8 +12951,8 @@ } } }, - "500": { - "description": "Internal server error", + "422": { + "description": "Upload or validation error", "content": { "application/json": { "schema": { @@ -12138,7 +12972,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -12148,12 +12982,10 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": { - "post": { - "operationId": "admin-set-signature-flow-config", - "summary": "Set signature flow configuration", + }, + "delete": { + "operationId": "admin-delete-certificate-policy", + "summary": "Delete certificate policy of this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -12166,30 +12998,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to force a signature flow for all documents" - }, - "mode": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12216,37 +13024,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid signature flow mode provided", + "description": "OK", "content": { "application/json": { "schema": { @@ -12266,7 +13044,18 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } } } } @@ -12275,8 +13064,8 @@ } } }, - "500": { - "description": "Internal server error", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -12296,7 +13085,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -12308,10 +13097,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { "post": { - "operationId": "admin-set-doc-mdp-config", - "summary": "Configure DocMDP signature restrictions", + "operationId": "admin-update-oid", + "summary": "Update OID", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -12331,18 +13120,12 @@ "schema": { "type": "object", "required": [ - "enabled" + "oid" ], "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to enable DocMDP restrictions" - }, - "defaultLevel": { - "type": "integer", - "format": "int64", - "default": 2, - "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" + "oid": { + "type": "string", + "description": "OID is a unique numeric identifier for certificate policies in digital certificates." } } } @@ -12375,37 +13158,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid DocMDP level provided", + "description": "OK", "content": { "application/json": { "schema": { @@ -12425,7 +13178,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -12434,8 +13187,8 @@ } } }, - "500": { - "description": "Internal server error", + "422": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -12455,7 +13208,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -12911,6 +13664,245 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + "get": { + "operationId": "policy-get-system", + "summary": "Read explicit system policy configuration", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read from the system layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/SystemPolicyResponse" + } + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist. Null resets the policy to its default system value.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether lower layers may override this system default." + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the system layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { "get": { "operationId": "setting-has-root-cert", diff --git a/openapi.json b/openapi.json index 6bd9cd965c..fefc8ded48 100644 --- a/openapi.json +++ b/openapi.json @@ -154,6 +154,25 @@ } } }, + "AdminSignatureMethodSetting": { + "type": "object", + "required": [ + "enabled", + "label", + "name" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "Capabilities": { "type": "object", "required": [ @@ -597,49 +616,174 @@ } ] }, - "DynamicMetadataRecord": { + "DynamicMetadataValue": { + "type": "object" + }, + "EffectivePoliciesResponse": { "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } } }, - "DynamicMetadataScalar": { - "nullable": true, - "anyOf": [ - { + "EffectivePolicyMeta": { + "type": "object", + "properties": { + "defaultSystemValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "appConfigKey": { "type": "string" }, - { - "type": "integer", - "format": "int64" + "userPreferenceKey": { + "type": "string" }, - { - "type": "number", - "format": "double" + "resolutionMode": { + "type": "string" }, - { + "supportsGroupAdminDelegation": { + "type": "boolean" + }, + "canCreateDescendantRules": { + "type": "boolean" + }, + "supportsUserPreference": { + "type": "boolean" + }, + "supportedScopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicyScope" + } + }, + "backendOnly": { "type": "boolean" + }, + "helper": { + "type": "boolean" + }, + "parentPolicyKey": { + "type": "string" + }, + "compositeChildren": { + "type": "array", + "items": { + "type": "string" + } } - ] + } }, - "DynamicMetadataValue": { - "anyOf": [ - { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "EffectivePolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + }, + "EffectivePolicyState": { + "type": "object", + "required": [ + "policyKey", + "effectiveValue", + "sourceScope", + "visible", + "editableByCurrentActor", + "allowedValues", + "canSaveAsUserDefault", + "canUseAsRequestOverride", + "preferenceWasCleared", + "blockedBy", + "groupCount", + "userCount", + "everyoneCount" + ], + "properties": { + "policyKey": { + "type": "string" }, - { + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "meta": { + "$ref": "#/components/schemas/EffectivePolicyMeta" + }, + "sourceScope": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "editableByCurrentActor": { + "type": "boolean" + }, + "allowedValues": { "type": "array", "items": { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "$ref": "#/components/schemas/EffectivePolicyValue" } }, + "canSaveAsUserDefault": { + "type": "boolean" + }, + "canUseAsRequestOverride": { + "type": "boolean" + }, + "preferenceWasCleared": { + "type": "boolean" + }, + "blockedBy": { + "type": "string", + "nullable": true + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "everyoneCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "boolean" }, { - "type": "array", - "items": { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" } } ] @@ -1065,6 +1209,128 @@ } } }, + "FooterTemplateResponse": { + "type": "object", + "required": [ + "template", + "isDefault", + "template_variables", + "preview_width", + "preview_height", + "preview_zoom" + ], + "properties": { + "template": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "template_variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "example": { + "type": "string" + }, + "default": { + "type": "string" + } + } + } + }, + "preview_width": { + "type": "integer", + "format": "int64" + }, + "preview_height": { + "type": "integer", + "format": "int64" + }, + "preview_zoom": { + "type": "integer", + "format": "int64" + } + } + }, + "GroupPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/GroupPolicyState" + } + } + }, + "GroupPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues", + "deletableByCurrentActor" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "group" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, + "deletableByCurrentActor": { + "type": "boolean" + } + } + }, + "GroupPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/GroupPolicyResponse" + } + ] + }, "IdDocs": { "type": "object", "required": [ @@ -1184,6 +1450,7 @@ "sms", "telegram", "whatsapp", + "whatsappbusiness", "xmpp" ] }, @@ -1215,7 +1482,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1227,19 +1494,25 @@ "sms", "telegram", "whatsapp", + "whatsappbusiness", "xmpp" ] }, "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } }, + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, "InfoMessage": { "type": "object", "required": [ @@ -1315,7 +1588,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1324,10 +1597,8 @@ "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } } @@ -1441,70 +1712,247 @@ } } }, - "ProgressError": { + "PolicyScope": { + "type": "string", + "enum": [ + "system", + "group", + "user" + ] + }, + "PolicySnapshotEntry": { "type": "object", "required": [ - "message" + "effectiveValue", + "sourceScope" ], "properties": { - "message": { + "effectiveValue": { "type": "string" }, - "code": { - "type": "integer", - "format": "int64" - }, - "timestamp": { - "type": "string" - }, - "fileId": { - "type": "integer", - "format": "int64" - }, - "signRequestId": { - "type": "integer", - "format": "int64" - }, - "signRequestUuid": { + "sourceScope": { "type": "string" } } }, - "ProgressFile": { + "PolicySnapshotIdentificationDocumentsEntry": { "type": "object", "required": [ - "id", - "name", - "status", - "statusText" + "effectiveValue", + "sourceScope" ], "properties": { - "id": { - "type": "integer", - "format": "int64" + "effectiveValue": { + "$ref": "#/components/schemas/PolicySnapshotIdentificationDocumentsValue" }, - "name": { - "type": "string" - }, - "status": { - "type": "integer", - "format": "int64" - }, - "statusText": { + "sourceScope": { "type": "string" - }, - "error": { - "$ref": "#/components/schemas/ProgressError" } } }, - "ProgressPayload": { + "PolicySnapshotIdentificationDocumentsValue": { "type": "object", "required": [ - "total", - "signed", - "inProgress", - "pending" + "enabled", + "approvers" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "approvers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PolicySnapshotIdentifyMethodFactor": { + "type": "object", + "required": [ + "name", + "enabled" + ], + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "signatureMethods": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PolicySnapshotSignatureMethodSetting" + } + }, + "signatureMethodEnabled": { + "type": "string" + }, + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "friendly_name": { + "type": "string" + } + } + }, + "PolicySnapshotIdentifyMethodsEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodFactor" + } + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotLegalInformationEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotSignatureMethodSetting": { + "allOf": [ + { + "$ref": "#/components/schemas/AdminSignatureMethodSetting" + }, + { + "type": "object", + "properties": { + "identifyMethod": { + "type": "string", + "nullable": true + }, + "needCode": { + "type": "boolean" + }, + "hasConfirmCode": { + "type": "boolean" + }, + "blurredEmail": { + "type": "string" + }, + "hashOfEmail": { + "type": "string" + }, + "blurredIdentifier": { + "type": "string" + }, + "hashOfIdentifier": { + "type": "string" + }, + "hasSignatureFile": { + "type": "boolean" + } + } + } + ] + }, + "ProgressError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "integer", + "format": "int64" + }, + "timestamp": { + "type": "string" + }, + "fileId": { + "type": "integer", + "format": "int64" + }, + "signRequestId": { + "type": "integer", + "format": "int64" + }, + "signRequestUuid": { + "type": "string" + } + } + }, + "ProgressFile": { + "type": "object", + "required": [ + "id", + "name", + "status", + "statusText" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/ProgressError" + } + } + }, + "ProgressPayload": { + "type": "object", + "required": [ + "total", + "signed", + "inProgress", + "pending" ], "properties": { "total": { @@ -2002,6 +2450,16 @@ } } }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -2079,6 +2537,58 @@ } } }, + "UserPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + }, + "UserPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "user_policy" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "UserPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/UserPolicyResponse" + } + ] + }, "ValidateMetadata": { "type": "object", "required": [ @@ -2116,6 +2626,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2124,6 +2637,29 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "legal_information": { + "$ref": "#/components/schemas/PolicySnapshotLegalInformationEntry" + }, + "identification_documents": { + "$ref": "#/components/schemas/PolicySnapshotIdentificationDocumentsEntry" + }, + "identify_methods": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodsEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -2332,6 +2868,21 @@ } } }, + "ValidatedFileResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ValidatedFile" + }, + { + "type": "object", + "properties": { + "effective_policies": { + "$ref": "#/components/schemas/EffectivePoliciesResponse" + } + } + } + ] + }, "ValidationPage": { "type": "object", "required": [ @@ -3590,129 +4141,16 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template/preview-pdf": { - "post": { - "operationId": "admin-footer-template-preview-pdf", - "summary": "Preview footer template as PDF", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/validate/uuid/{uuid}": { + "get": { + "operationId": "file-validate-uuid", + "summary": "Validate a file using Uuid", + "description": "Validate a file returning file data. The response always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.", "tags": [ - "admin" + "file" ], "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "template": { - "type": "string", - "default": "", - "description": "Template to preview" - }, - "width": { - "type": "integer", - "format": "int64", - "default": 595, - "description": "Width of preview in points (default: 595 - A4 width)" - }, - "height": { - "type": "integer", - "format": "int64", - "default": 50, - "description": "Height of preview in points (default: 50)" - } - } - } - } - } - }, - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "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": "OK", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", - "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/ErrorResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/validate/uuid/{uuid}": { - "get": { - "operationId": "file-validate-uuid", - "summary": "Validate a file using Uuid", - "description": "Validate a file returning file data. The response always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.", - "tags": [ - "file" - ], - "security": [ - {}, + {}, { "bearer_auth": [] }, @@ -3814,7 +4252,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ValidatedFile" + "$ref": "#/components/schemas/ValidatedFileResponse" } } } @@ -3968,7 +4406,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ValidatedFile" + "$ref": "#/components/schemas/ValidatedFileResponse" } } } @@ -4073,7 +4511,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ValidatedFile" + "$ref": "#/components/schemas/ValidatedFileResponse" } } } @@ -4805,7 +5243,7 @@ "post": { "operationId": "file-save", "summary": "Send a file", - "description": "Send a new file to Nextcloud and return the fileId to request signature. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'.\n**Note on multiple file uploads:** PHP has a limit on the number of files that can be uploaded in a single request (max_file_uploads directive, default 20). When uploading many files (more than 20), consider uploading them sequentially in multiple requests or use individual file uploads like the Files app does.", + "description": "Send a new file to Nextcloud and return the fileId used to create a signature request. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'.\n**Note on multiple file uploads:** PHP has a limit on the number of files that can be uploaded in a single request (max_file_uploads directive, default 20). When uploading many files (more than 20), consider uploading them sequentially in multiple requests or use individual file uploads like the Files app does.", "tags": [ "file" ], @@ -5838,15 +6276,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs": { - "post": { - "operationId": "id_docs-add-files", - "summary": "Add identification documents to user profile", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/footer-template": { + "get": { + "operationId": "footer_template-get-footer-template", + "summary": "Get footer template", + "description": "Returns the current footer template if set, otherwise returns the default template.", "tags": [ - "id_docs" + "footer_template" ], "security": [ - {}, { "bearer_auth": [] }, @@ -5854,28 +6292,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "files" - ], - "properties": { - "files": { - "type": "array", - "description": "The list of files to add to profile", - "items": { - "$ref": "#/components/schemas/IdDocs" - } - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -5902,7 +6318,7 @@ ], "responses": { "200": { - "description": "Certificate saved with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -5921,7 +6337,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/FooterTemplateResponse" + } } } } @@ -5929,8 +6347,8 @@ } } }, - "401": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -5950,7 +6368,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsUploadErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -5961,14 +6379,14 @@ } } }, - "get": { - "operationId": "id_docs-list-unauthenticated-signer-documents", - "summary": "List files of unauthenticated account", + "post": { + "operationId": "footer_template-save-footer-template", + "summary": "Save footer template and render preview", + "description": "Saves the footer template and returns the rendered PDF preview.", "tags": [ - "id_docs" + "footer_template" ], "security": [ - {}, { "bearer_auth": [] }, @@ -5976,6 +6394,35 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "string", + "default": "", + "description": "The Twig template to save (empty to reset to default)" + }, + "width": { + "type": "integer", + "format": "int64", + "default": 595, + "description": "Width of preview in points (default: 595 - A4 width)" + }, + "height": { + "type": "integer", + "format": "int64", + "default": 50, + "description": "Height of preview in points (default: 50)" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -5989,45 +6436,6 @@ "default": "v1" } }, - { - "name": "userId", - "in": "query", - "description": "User ID to filter by", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "signRequestId", - "in": "query", - "description": "Sign request ID to filter by", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "page", - "in": "query", - "description": "the number of page to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "length", - "in": "query", - "description": "Total of elements to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -6041,7 +6449,18 @@ ], "responses": { "200": { - "description": "Certificate saved with success", + "description": "OK", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -6061,7 +6480,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsListResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6070,8 +6489,8 @@ } } }, - "404": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6091,7 +6510,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6103,15 +6522,14 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/{nodeId}": { - "delete": { - "operationId": "id_docs-delete", - "summary": "Delete file from account", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/footer-template/preview-pdf": { + "post": { + "operationId": "footer_template-preview-pdf", + "summary": "Preview footer template as PDF", "tags": [ - "id_docs" + "footer_template" ], "security": [ - {}, { "bearer_auth": [] }, @@ -6119,36 +6537,51 @@ "basic_auth": [] } ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "name": "nodeId", - "in": "path", - "description": "the nodeId of file to be delete", - "required": true, - "schema": { - "type": "integer", - "format": "int64" + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "string", + "default": "", + "description": "Template to preview" + }, + "width": { + "type": "integer", + "format": "int64", + "default": 595, + "description": "Width of preview in points (default: 595 - A4 width)" + }, + "height": { + "type": "integer", + "format": "int64", + "default": 50, + "description": "Height of preview in points (default: 50)" + }, + "writeQrcodeOnFooter": { + "type": "boolean", + "nullable": true, + "description": "Whether to force QR code rendering in footer preview (null uses policy)" + } + } + } } - }, + } + }, + "parameters": [ { - "name": "uuid", - "in": "query", - "description": "Sign request UUID for unauthenticated access", + "name": "apiVersion", + "in": "path", + "required": true, "schema": { "type": "string", - "nullable": true + "enum": [ + "v1" + ], + "default": "v1" } }, { @@ -6164,7 +6597,18 @@ ], "responses": { "200": { - "description": "File deleted with success", + "description": "OK", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -6183,7 +6627,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } } } } @@ -6191,8 +6637,8 @@ } } }, - "401": { - "description": "Failure to delete file from account", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6212,7 +6658,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessagesResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6224,14 +6670,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/approval/list": { - "get": { - "operationId": "id_docs-list-to-approval", - "summary": "List files that need to be approved", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs": { + "post": { + "operationId": "id_docs-add-files", + "summary": "Add identification documents to user profile", "tags": [ "id_docs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -6239,6 +6686,28 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "type": "array", + "description": "The list of files to add to profile", + "items": { + "$ref": "#/components/schemas/IdDocs" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -6252,63 +6721,6 @@ "default": "v1" } }, - { - "name": "userId", - "in": "query", - "description": "User ID to filter by", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "signRequestId", - "in": "query", - "description": "Sign request ID to filter by", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "page", - "in": "query", - "description": "the number of page to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "length", - "in": "query", - "description": "Total of elements to return", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "sortBy", - "in": "query", - "description": "Sort field (e.g., 'owner', 'file_type', 'status')", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "sortOrder", - "in": "query", - "description": "Sort order (ASC or DESC)", - "schema": { - "type": "string", - "nullable": true - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -6322,7 +6734,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Certificate saved with success", "content": { "application/json": { "schema": { @@ -6341,9 +6753,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "$ref": "#/components/schemas/IdDocsApprovalListResponse" - } + "data": {} } } } @@ -6351,8 +6761,8 @@ } } }, - "404": { - "description": "Account not found", + "401": { + "description": "No file provided or other problem with provided file", "content": { "application/json": { "schema": { @@ -6372,7 +6782,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/IdDocsUploadErrorResponse" } } } @@ -6382,17 +6792,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/identify-account/search": { + }, "get": { - "operationId": "identify-search", - "summary": "List possible signers", - "description": "Used to identify who can sign the document. The return of this endpoint is related with Administration Settiongs > LibreSign > Identify method.", + "operationId": "id_docs-list-unauthenticated-signer-documents", + "summary": "List files of unauthenticated account", "tags": [ - "identify" + "id_docs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -6414,41 +6822,42 @@ } }, { - "name": "search", + "name": "userId", "in": "query", - "description": "search params", + "description": "User ID to filter by", "schema": { "type": "string", - "default": "" + "nullable": true } }, { - "name": "method", + "name": "signRequestId", "in": "query", - "description": "filter by method (email, account, sms, signal, telegram, whatsapp, xmpp)", + "description": "Sign request ID to filter by", "schema": { - "type": "string", - "default": "" + "type": "integer", + "format": "int64", + "nullable": true } }, { "name": "page", "in": "query", - "description": "the number of page to return. Default: 1", + "description": "the number of page to return", "schema": { "type": "integer", "format": "int64", - "default": 1 + "nullable": true } }, { - "name": "limit", + "name": "length", "in": "query", - "description": "Total of elements to return. Default: 25", + "description": "Total of elements to return", "schema": { "type": "integer", "format": "int64", - "default": 25 + "nullable": true } }, { @@ -6484,7 +6893,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdentifyAccountsResponse" + "$ref": "#/components/schemas/IdDocsListResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "No file provided or other problem with provided file", + "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/MessageResponse" } } } @@ -6496,14 +6935,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signers": { - "post": { - "operationId": "notify-signers", - "summary": "Notify signers of a file", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/{nodeId}": { + "delete": { + "operationId": "id_docs-delete", + "summary": "Delete file from account", "tags": [ - "notify" + "id_docs" ], "security": [ + {}, { "bearer_auth": [] }, @@ -6511,33 +6951,1703 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "fileId", - "signers" + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "nodeId", + "in": "path", + "description": "the nodeId of file to be delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "uuid", + "in": "query", + "description": "Sign request UUID for unauthenticated access", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "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": "File deleted with success", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Failure to delete file from account", + "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/MessagesResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/id-docs/approval/list": { + "get": { + "operationId": "id_docs-list-to-approval", + "summary": "List files that need to be approved", + "tags": [ + "id_docs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "userId", + "in": "query", + "description": "User ID to filter by", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "signRequestId", + "in": "query", + "description": "Sign request ID to filter by", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "page", + "in": "query", + "description": "the number of page to return", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "length", + "in": "query", + "description": "Total of elements to return", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Sort field (e.g., 'owner', 'file_type', 'status')", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort order (ASC or DESC)", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "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": "OK", + "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/IdDocsApprovalListResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "Account not found", + "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/MessageResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/identify-account/search": { + "get": { + "operationId": "identify-search", + "summary": "List possible signers", + "description": "Used to identify who can be invited to sign a document. The response follows Administration Settings > LibreSign > Identify method.", + "tags": [ + "identify" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "search", + "in": "query", + "description": "search params", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "method", + "in": "query", + "description": "filter by method (email, account, sms, signal, telegram, whatsapp, whatsappbusiness, xmpp)", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "page", + "in": "query", + "description": "the number of page to return. Default: 1", + "schema": { + "type": "integer", + "format": "int64", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "Total of elements to return. Default: 25", + "schema": { + "type": "integer", + "format": "int64", + "default": 25 + } + }, + { + "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": "Certificate saved with success", + "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/IdentifyAccountsResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signers": { + "post": { + "operationId": "notify-signers", + "summary": "Notify signers of a file", + "tags": [ + "notify" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fileId", + "signers" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64", + "description": "The identifier value of LibreSign file" + }, + "signers": { + "type": "array", + "description": "Signers data", + "items": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "OK", + "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/MessageResponse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "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/DangerMessagesResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signer": { + "post": { + "operationId": "notify-signer", + "summary": "Notify a signer of a file", + "tags": [ + "notify" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fileId", + "signRequestId" ], "properties": { - "fileId": { - "type": "integer", - "format": "int64", - "description": "The identifier value of LibreSign file" + "fileId": { + "type": "integer", + "format": "int64", + "description": "The identifier value of LibreSign file" + }, + "signRequestId": { + "type": "integer", + "format": "int64", + "description": "The sign request id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "OK", + "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/MessageResponse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "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/DangerMessagesResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/notification": { + "delete": { + "operationId": "notify-notification-dismiss", + "summary": "Dismiss a specific notification", + "tags": [ + "notify" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "objectType", + "in": "query", + "description": "The type of object", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "query", + "description": "The identifier value of LibreSign file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "subject", + "in": "query", + "description": "The subject of notification", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "timestamp", + "in": "query", + "description": "Timestamp of notification to dismiss", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "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": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": { + "get": { + "operationId": "policy-effective", + "summary": "Effective policies bootstrap", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "OK", + "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/EffectivePoliciesResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": { + "get": { + "operationId": "policy-get-group", + "summary": "Read a group-level policy value", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/GroupPolicyResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "policy-set-group", + "summary": "Save a group-level policy value", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist for the group.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether users and requests below this group may override the group default." + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the group layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/GroupPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "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/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "policy-clear-group", + "summary": "Clear a group-level policy value", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/GroupPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "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/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/by-policy/group/{policyKey}": { + "get": { + "operationId": "policy-list-group-policies", + "summary": "List all explicit group-level policy values for a policy key", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to list group rules.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupPolicyState" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": { + "get": { + "operationId": "policy-get-user-policy-for-user", + "summary": "Read an explicit user-level policy for a target user (admin scope)", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "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/UserPolicyResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "policy-set-user-policy-for-user", + "summary": "Save an explicit user policy for a target user (admin scope)", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist as assigned target user policy.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] }, - "signers": { - "type": "array", - "description": "Signers data", - "items": { + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the target user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { "type": "object", "required": [ - "email" + "meta", + "data" ], "properties": { - "email": { - "type": "string" + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/UserPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "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/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" } } } @@ -6546,7 +8656,22 @@ } } } - }, + } + }, + "delete": { + "operationId": "policy-clear-user-policy-for-user", + "summary": "Clear an explicit user policy for a target user (admin scope)", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], "parameters": [ { "name": "apiVersion", @@ -6560,6 +8685,26 @@ "default": "v1" } }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment removal.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the target user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -6593,7 +8738,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -6602,8 +8747,8 @@ } } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -6623,7 +8768,131 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/by-policy/user/{policyKey}": { + "get": { + "operationId": "policy-list-user-policies", + "summary": "List all explicit user-level policy values for a policy key", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to list user rules.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "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": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + } } } } @@ -6635,12 +8904,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/signer": { - "post": { - "operationId": "notify-signer", - "summary": "Notify a signer of a file", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": { + "put": { + "operationId": "policy-set-user-preference", + "summary": "Save a user policy preference", "tags": [ - "notify" + "policy" ], "security": [ { @@ -6651,25 +8920,37 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "fileId", - "signRequestId" - ], "properties": { - "fileId": { - "type": "integer", - "format": "int64", - "description": "The identifier value of LibreSign file" - }, - "signRequestId": { - "type": "integer", - "format": "int64", - "description": "The sign request id" + "value": { + "nullable": true, + "description": "Policy value to persist as the current user's default.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] } } } @@ -6689,6 +8970,16 @@ "default": "v1" } }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the current user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -6722,7 +9013,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -6731,8 +9022,8 @@ } } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -6752,7 +9043,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6762,14 +9053,12 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/notify/notification": { + }, "delete": { - "operationId": "notify-notification-dismiss", - "summary": "Dismiss a specific notification", + "operationId": "policy-clear-user-preference", + "summary": "Clear a user policy preference", "tags": [ - "notify" + "policy" ], "security": [ { @@ -6793,41 +9082,13 @@ } }, { - "name": "objectType", - "in": "query", - "description": "The type of object", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "objectId", - "in": "query", - "description": "The identifier value of LibreSign file", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "subject", - "in": "query", - "description": "The subject of notification", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "timestamp", - "in": "query", - "description": "Timestamp of notification to dismiss", + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the current user.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -6863,7 +9124,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "User-scope not supported", + "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/ErrorResponse" } } } @@ -6941,10 +9232,13 @@ "default": 1, "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, - "signatureFlow": { - "type": "string", + "policy": { + "type": "object", "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } } } } @@ -7100,10 +9394,13 @@ "nullable": true, "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, - "signatureFlow": { - "type": "string", + "policy": { + "type": "object", "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } }, "name": { "type": "string", @@ -8894,6 +11191,165 @@ } } } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature-stamp/preview-pdf": { + "post": { + "operationId": "signature_stamp_preview-preview-pdf", + "summary": "Render a preview PDF of the signature stamp with the provided configuration", + "tags": [ + "signature_stamp_preview" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "string", + "default": "", + "description": "Signature text template (Twig syntax)" + }, + "templateFontSize": { + "type": "number", + "format": "double", + "description": "Font size for template text in pt" + }, + "signatureFontSize": { + "type": "number", + "format": "double", + "description": "Font size for signer name in pt" + }, + "signatureWidth": { + "type": "number", + "format": "double", + "description": "Stamp width in mm" + }, + "signatureHeight": { + "type": "number", + "format": "double", + "description": "Stamp height in mm" + }, + "renderMode": { + "type": "string", + "description": "Render mode: default, text, graphic, description_only" + }, + "backgroundType": { + "type": "string", + "description": "Background: default, custom, deleted" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "Preview PDF", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "Forbidden", + "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/ErrorResponse" + } + } + } + } + } + } + } + }, + "422": { + "description": "Rendering error", + "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/ErrorResponse" + } + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/package-lock.json b/package-lock.json index eeb4d7446e..b1416e9791 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "vuedraggable": "^4.1.0" }, "devDependencies": { + "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.2.1", @@ -178,6 +179,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", @@ -186,118 +201,1488 @@ "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "dev": true, "license": "MIT", "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, "license": "MIT", "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/types": "^7.29.7" + "@babel/helper-plugin-utils": "^7.27.1" }, - "bin": { - "parser": "bin/babel-parser.js" + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/runtime": { @@ -1481,6 +2866,21 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, + "node_modules/@nextcloud/babel-config": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/babel-config/-/babel-config-1.3.0.tgz", + "integrity": "sha512-qk4mBJahzp2mkiizU9RbeABa6JhqSwR43SXptNQhM3kpxAuP2OAQQhomYnxog/XfFcYExZzOkgRBPlcLEoik0w==", + "dev": true, + "license": "AGPL-3.0-or-later", + "engines": { + "node": "^20 || ^22 || ^24" + }, + "peerDependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/preset-env": "^7.27.2" + } + }, "node_modules/@nextcloud/browser-storage": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.5.0.tgz", @@ -5282,6 +6682,51 @@ "axios": "0.x || 1.x" } }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -10292,6 +11737,14 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -12649,6 +14102,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -12670,6 +14145,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/rehype-external-links": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", @@ -15086,6 +16602,54 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/unicorn-magic": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", diff --git a/package.json b/package.json index e2fb26b832..e3d1a2911a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "vite build", "dev": "vite build --mode development", - "watch": "vite build --mode development --watch", + "watch": "CHOKIDAR_USEPOLLING=1 CHOKIDAR_INTERVAL=1000 vite build --mode development --watch", "serve": "vite dev", "ts:check": "vue-tsc --noEmit", "typescript:generate": "npx openapi-typescript -t", @@ -76,6 +76,7 @@ "npm": "^11.3.0" }, "devDependencies": { + "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.2.1", diff --git a/playwright.config.ts b/playwright.config.ts index bbf3e6ec18..b51590882c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,6 +33,12 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('./apps/libresign')`. */ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + /* Force E2E execution in English regardless of container/user locale. */ + locale: 'en-US', + extraHTTPHeaders: { + 'Accept-Language': 'en-US,en;q=0.9', + }, + /* Ignore HTTPS errors on local self-signed certificates */ ignoreHTTPSErrors: true, diff --git a/playwright/e2e/confetti-after-signing-policy.spec.ts b/playwright/e2e/confetti-after-signing-policy.spec.ts new file mode 100644 index 0000000000..15251e9c49 --- /dev/null +++ b/playwright/e2e/confetti-after-signing-policy.spec.ts @@ -0,0 +1,129 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { configureOpenSsl, deleteAppConfig, setAppConfig, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' + +async function sortByCreatedAtDescending(page: Page) { + const createdAtTh = page.getByRole('columnheader', { name: 'Created at' }) + const sortDirection = await createdAtTh.evaluate((element: HTMLElement) => element.ariaSort) + if (sortDirection !== 'descending') { + await page.getByRole('button', { name: 'Created at' }).click() + if (sortDirection === 'none') { + await page.getByRole('button', { name: 'Created at' }).click() + } + } +} + +async function runSelfSigningFlow(page: Page): Promise { + await page.goto('./apps/libresign') + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + await page.getByRole('button', { name: 'Add signer' }).click() + await page.getByPlaceholder('Account').click() + await page.getByPlaceholder('Account').fill('a') + await page.locator('.account-or-email__option__title').filter({ hasText: /^admin$/ }).click() + await page.getByRole('button', { name: 'Save' }).click() + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + + await page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() + await sortByCreatedAtDescending(page) + + const uniqueName = `confetti-policy-${Date.now()}.pdf` + const firstRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') + .filter({ hasText: 'small_valid' }) + .first() + await firstRow.getByRole('button', { name: 'Actions' }).click() + await page.getByRole('menuitem', { name: 'Rename' }).click() + const fileNameInput = page.getByLabel('File name') + await fileNameInput.fill(uniqueName) + await fileNameInput.press('Enter') + await expect(fileNameInput).toBeHidden({ timeout: 10000 }) + + const filesSearch = page.getByRole('searchbox', { name: /Search here/i }).first() + if (await filesSearch.isVisible({ timeout: 2000 }).catch(() => false)) { + await filesSearch.fill(uniqueName) + } + + const targetRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row').filter({ hasText: uniqueName }) + await expect(targetRow).toBeVisible({ timeout: 20000 }) + await targetRow.getByRole('button', { name: 'Actions' }).click() + await page.getByRole('menuitem', { name: 'Sign' }).click() + + await page.waitForURL('**/f/sign/**/pdf') + await expect(page.getByLabel('PDF document to sign')).toBeVisible({ timeout: 15_000 }) + const signButton = page.locator('.sign-pdf-sidebar .button-wrapper').getByRole('button', { name: 'Sign document' }) + await expect(signButton).toBeVisible({ timeout: 15_000 }) + await signButton.click({ force: true }) + + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + if (!signResponse.ok()) { + throw new Error(`Sign API failed with status ${signResponse.status()}: ${signResponseBody}`) + } + + await expect(page.getByText('This document is valid')).toBeVisible() +} + +function confettiCanvasLocator(page: Page) { + return page.locator('canvas[style*="position: fixed"][style*="z-index: 1000"][style*="pointer-events: none"]') +} + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +test.beforeEach(async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setCertificateEngine(page.request, 'openssl') + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') + await setSystemPolicy( + page.request, + 'identify_methods', + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), + ) +}) + +test('shows confetti after signing when policy is enabled', async ({ page }) => { + await setSystemPolicy(page.request, 'show_confetti_after_signing', JSON.stringify(true)) + + await runSelfSigningFlow(page) + + await expect.poll(async () => confettiCanvasLocator(page).count(), { timeout: 10000 }).toBeGreaterThan(0) +}) + +test('does not show confetti after signing when policy is disabled', async ({ page }) => { + await setSystemPolicy(page.request, 'show_confetti_after_signing', JSON.stringify(false)) + + await runSelfSigningFlow(page) + + await expect(confettiCanvasLocator(page)).toHaveCount(0) +}) diff --git a/playwright/e2e/delete-pending-request.spec.ts b/playwright/e2e/delete-pending-request.spec.ts index 90ad529562..473607a15f 100644 --- a/playwright/e2e/delete-pending-request.spec.ts +++ b/playwright/e2e/delete-pending-request.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' test('delete pending signature request', async ({ page }) => { await login( @@ -22,17 +22,19 @@ test('delete pending signature request', async ({ page }) => { L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, - { name: 'email', enabled: false, mandatory: false }, - ]), + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), ) await page.goto('./apps/libresign') + await expect(page.getByRole('button', { name: 'Upload from URL' })).toBeVisible({ timeout: 20000 }) await page.getByRole('button', { name: 'Upload from URL' }).click() await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') await page.getByRole('button', { name: 'Send' }).click() @@ -58,7 +60,7 @@ test('delete pending signature request', async ({ page }) => { // The most recently uploaded document is first — rename it to a unique name // so it can be unambiguously identified regardless of other documents in the list. // NcActionButton inside NcActions renders as role="menuitem", not role="button". - const uniqueName = `delete-pending-test-${Date.now()}` + const uniqueName = `delete-pending-test-${Date.now()}.pdf` const firstRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') .filter({ hasText: 'small_valid' }) .first() @@ -70,6 +72,7 @@ test('delete pending signature request', async ({ page }) => { // Find the row by its unique name and assert the status const targetRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') .filter({ hasText: uniqueName }) + await expect(targetRow).toBeVisible({ timeout: 20000 }) await expect(targetRow.locator('.status-chip__text')).toHaveText('Ready to sign') // Delete it @@ -77,11 +80,16 @@ test('delete pending signature request', async ({ page }) => { await page.getByRole('menuitem', { name: 'Delete' }).click() // Confirm the deletion in the dialog - await expect(page.getByRole('dialog', { name: 'Confirm' })).toBeVisible() + const confirmDialog = page.getByRole('dialog', { name: 'Confirm' }) + await expect(confirmDialog).toBeVisible() await expect(page.getByText('The signature request will be deleted. Do you confirm this action?')).toBeVisible() - await page.getByRole('button', { name: 'Ok' }).click() + await Promise.all([ + page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes('/apps/libresign/api/v1/file/file_id/') && response.ok()), + confirmDialog.getByRole('button', { name: 'Ok', exact: true }).click(), + ]) - // The specific row we deleted must disappear from the list - await expect(targetRow).toBeHidden() + // The list updates asynchronously after the backend deletion completes. + await page.reload() + await expect(targetRow).toBeHidden({ timeout: 20000 }) }) diff --git a/playwright/e2e/envelope-validation-multi-file-bug.spec.ts b/playwright/e2e/envelope-validation-multi-file-bug.spec.ts index f4fbb15842..1e4170e0f6 100644 --- a/playwright/e2e/envelope-validation-multi-file-bug.spec.ts +++ b/playwright/e2e/envelope-validation-multi-file-bug.spec.ts @@ -7,8 +7,13 @@ import { expect, test } from '@playwright/test' import type { APIRequestContext, Page } from '@playwright/test' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' + import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { getSmallValidPdfBase64 } from '../support/pdf-fixtures' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() type EnvelopeSigningScenario = { envelopeName: string @@ -33,14 +38,25 @@ type OcsEnvelopeResponse = { files?: OcsEnvelopeChildFile[] } +/** + * + */ function buildSigningScenario(): EnvelopeSigningScenario { + const runId = Date.now() return { - envelopeName: `Envelope Validation Bug - ${Date.now()}`, - signerEmail: 'signer-validation@libresign.coop', + envelopeName: `Envelope Validation Bug - ${runId}`, + signerEmail: `signer-validation-${runId}@libresign.coop`, signerName: 'Validation Tester', } } +/** + * + * @param request + * @param method + * @param path + * @param body + */ async function requestLibreSignApiAsAdmin( request: APIRequestContext, method: 'POST' | 'PATCH', @@ -69,6 +85,10 @@ async function requestLibreSignApiAsAdmin( return response.json() as Promise<{ ocs: { data: OcsEnvelopeResponse } }> } +/** + * + * @param request + */ async function enableEnvelopeScenario(request: APIRequestContext) { await configureOpenSsl(request, 'LibreSign Test', { C: 'BR', @@ -78,26 +98,31 @@ async function enableEnvelopeScenario(request: APIRequestContext) { L: 'Rio de Janeiro', }) - await setAppConfig(request, 'libresign', 'envelope_enabled', '1') - await setAppConfig( + await setSystemPolicy(request, 'envelope_enabled', '1') + await setSystemPolicy( request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, - ]), + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true }, emailToken: { enabled: false } } }, + ], + }), ) + await setSystemPolicy(request, 'make_validation_url_private', '0') } +/** + * + * @param request + * @param scenario + */ async function createEnvelopeWithMultipleFiles( request: APIRequestContext, scenario: EnvelopeSigningScenario, ) { - const pdfResponse = await request.get('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf', { - failOnStatusCode: true, - }) - const pdfBase64 = Buffer.from(await pdfResponse.body()).toString('base64') + const pdfBase64 = await getSmallValidPdfBase64() const createResponse = await requestLibreSignApiAsAdmin(request, 'POST', '/request-signature', { name: scenario.envelopeName, @@ -130,11 +155,15 @@ async function createEnvelopeWithMultipleFiles( return envelope } +/** + * + * @param signerEmail + */ async function waitForSignerInvitationLink(signerEmail: string) { const email = await waitForEmailTo( createMailpitClient(), signerEmail, - 'LibreSign: There is a file for you to sign', + 'LibreSign: A document is ready for your signature', ) const signLink = extractSignLink(email.Text) if (!signLink) { @@ -143,34 +172,75 @@ async function waitForSignerInvitationLink(signerEmail: string) { return signLink } +/** + * + * @param page + * @param signLink + */ async function openInvitationAsExternalSigner(page: Page, signLink: string) { // API setup runs as admin. Clear cookies so the browser behaves like the real external signer. await page.context().clearCookies() - await page.goto(signLink) + await page.goto('about:blank') + + const baseCandidates = signLink.startsWith('/index.php/') + ? [signLink, signLink.replace(/^\/index\.php/, '')] + : [signLink, `/index.php${signLink.startsWith('/') ? '' : '/'}${signLink}`] + + const signLinkCandidates = [...new Set(baseCandidates.flatMap((candidate) => { + const withoutPdfSuffix = candidate.replace(/\/pdf(?=$|[?#])/, '') + return candidate === withoutPdfSuffix ? [candidate] : [candidate, withoutPdfSuffix] + }))] + + for (const candidate of signLinkCandidates) { + try { + await page.goto(candidate, { waitUntil: 'commit', timeout: 15_000 }) + } catch { + continue + } + + const currentUrl = page.url() + if (currentUrl.includes('/login') || /\/p\/error(?:$|[/?#])/.test(currentUrl)) { + continue + } + + if (currentUrl.includes('/apps/libresign/p/sign/')) { + return + } + } + + throw new Error(`Invitation link redirected to login instead of public sign page: ${page.url()}`) } +/** + * + * @param page + */ async function defineClickToSignature(page: Page) { // Wait for click-to-sign button - await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeVisible({ timeout: 5_000 }) + await expect(page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' })).toBeVisible({ timeout: 15_000 }) } +/** + * + * @param page + */ async function finishSigning(page: Page) { - const signButton = page.getByRole('button', { name: 'Sign the document.' }) - await signButton.scrollIntoViewIfNeeded() - await signButton.click() - await page.getByRole('button', { name: 'Sign document' }).click() + const signButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) + await expect(signButton).toBeVisible({ timeout: 15_000 }) + await signButton.click({ force: true }) + const confirmSignButton = page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }) + await expect(confirmSignButton).toBeVisible({ timeout: 15_000 }) + await confirmSignButton.click() } test('validation screen should display all data correctly for envelope with 2 files', async ({ page }) => { const scenario = buildSigningScenario() - const mailpit = createMailpitClient() await test.step('Given the system is configured to allow envelope signing via e-mail', async () => { await enableEnvelopeScenario(page.request) }) await test.step('And an envelope with two files is created', async () => { - await mailpit.deleteMessages() await createEnvelopeWithMultipleFiles(page.request, scenario) }) @@ -189,11 +259,13 @@ test('validation screen should display all data correctly for envelope with 2 fi await page.waitForURL('**/validation/**') // Verify envelope information section is visible - await expect(page.getByText('Envelope information')).toBeVisible() + const envelopeInformationSection = page.locator('.section').filter({ + has: page.getByRole('heading', { name: 'Envelope information' }), + }).first() + await expect(envelopeInformationSection).toBeVisible() // Verify envelope name is displayed - const envelopeName = page.locator('h2.app-sidebar-header__mainname') - await expect(envelopeName).toHaveText(scenario.envelopeName) + await expect(envelopeInformationSection.getByText(scenario.envelopeName)).toBeVisible() // Verify documents in envelope section exists await expect(page.getByText('Documents in this envelope')).toBeVisible() @@ -203,8 +275,6 @@ test('validation screen should display all data correctly for envelope with 2 fi // Get the documents list const documentsList = page.locator('ul.documents-list li.document-item') const documentsCount = await documentsList.count() - - console.log(`Found ${documentsCount} documents in the list`) expect(documentsCount).toBe(2) // Success message should be visible diff --git a/playwright/e2e/files-new-signature-request.spec.ts b/playwright/e2e/files-new-signature-request.spec.ts index 862f45242e..20119615aa 100644 --- a/playwright/e2e/files-new-signature-request.spec.ts +++ b/playwright/e2e/files-new-signature-request.spec.ts @@ -4,10 +4,18 @@ */ import { expect, test } from '@playwright/test' + import { login } from '../support/nc-login' +import { ensureFilesHomeInitialized, waitForFilesNewMenuEntry } from '../support/nc-files' import { configureOpenSsl } from '../support/nc-provisioning' +import { getSmallValidPdfBuffer } from '../support/pdf-fixtures' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() test('new signature request opens LibreSign tab and does not duplicate file row', async ({ page }) => { + test.slow() + await login( page.request, process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', @@ -22,17 +30,22 @@ test('new signature request opens LibreSign tab and does not duplicate file row' L: 'Rio de Janeiro', }) + await ensureFilesHomeInitialized(page.request) + const uniqueName = `libresign-upload-${Date.now()}.pdf` - const pdfResponse = await page.request.get('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf', { - failOnStatusCode: true, - }) - const pdfBuffer = Buffer.from(await pdfResponse.body()) + const pdfBuffer = await getSmallValidPdfBuffer() await page.goto('./apps/files') + await expect(page.getByRole('heading', { name: 'All files' })).toBeVisible({ timeout: 15000 }) + await waitForFilesNewMenuEntry(page, 'libresign-request') - await page.getByRole('button', { name: 'New' }).click() - const newSignatureRequestEntry = page.getByText('New signature request', { exact: true }) - await expect(newSignatureRequestEntry).toBeVisible() + const newButton = page.getByRole('button', { name: 'New' }) + await expect(newButton).toBeVisible({ timeout: 15000 }) + await newButton.click() + const newMenu = page.getByRole('menu', { name: 'New' }) + await expect(newMenu).toBeVisible() + const newSignatureRequestEntry = newMenu.getByRole('menuitem', { name: 'New signature request' }) + await expect(newSignatureRequestEntry).toBeVisible({ timeout: 15000 }) const fileChooserPromise = page.waitForEvent('filechooser') await newSignatureRequestEntry.click() const chooser = await fileChooserPromise @@ -43,8 +56,8 @@ test('new signature request opens LibreSign tab and does not duplicate file row' }) const libresignTab = page.getByRole('tab', { name: 'LibreSign' }) - await expect(libresignTab).toHaveAttribute('aria-selected', 'true') - await expect(page.getByRole('button', { name: 'Add signer' })).toBeVisible() + await expect(libresignTab).toHaveAttribute('aria-selected', 'true', { timeout: 15000 }) + await expect(page.getByRole('button', { name: 'Add signer' })).toBeVisible({ timeout: 15000 }) const filesTable = page.getByRole('table', { name: /List of your files and folders/i, diff --git a/playwright/e2e/files-open-in-libresign-context-menu.spec.ts b/playwright/e2e/files-open-in-libresign-context-menu.spec.ts index d9b716d6ed..b0dda2270b 100644 --- a/playwright/e2e/files-open-in-libresign-context-menu.spec.ts +++ b/playwright/e2e/files-open-in-libresign-context-menu.spec.ts @@ -4,10 +4,18 @@ */ import { expect, test } from '@playwright/test' + import { login } from '../support/nc-login' +import { ensureFilesHomeInitialized, uploadFileToFilesApp, waitForFilesAction } from '../support/nc-files' import { configureOpenSsl } from '../support/nc-provisioning' +import { getSmallValidPdfBuffer } from '../support/pdf-fixtures' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() test('open PDF in LibreSign from Files context menu', async ({ page }) => { + test.slow() + await login( page.request, process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', @@ -22,15 +30,15 @@ test('open PDF in LibreSign from Files context menu', async ({ page }) => { L: 'Rio de Janeiro', }) + await ensureFilesHomeInitialized(page.request) + const fileName = `libresign-context-menu-${Date.now()}.pdf` - const pdfResponse = await page.request.get('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf', { - failOnStatusCode: true, - }) - const pdfBuffer = Buffer.from(await pdfResponse.body()) + const pdfBuffer = await getSmallValidPdfBuffer() await page.goto('./apps/files') - - await page.locator('input[type="file"]').first().setInputFiles({ + await expect(page.getByRole('heading', { name: 'All files' })).toBeVisible({ timeout: 15000 }) + await waitForFilesAction(page, 'open-in-libresign') + await uploadFileToFilesApp(page, { name: fileName, mimeType: 'application/pdf', buffer: pdfBuffer, @@ -39,17 +47,23 @@ test('open PDF in LibreSign from Files context menu', async ({ page }) => { const filesTable = page.getByRole('table', { name: /List of your files and folders/i, }) - const fileRow = filesTable.getByRole('row', { name: new RegExp(fileName) }) - await expect(fileRow).toBeVisible({ timeout: 15000 }) + const fileCheckbox = filesTable.getByRole('checkbox', { + name: `Toggle selection for file "${fileName}"`, + }) + await expect(fileCheckbox).toHaveCount(1, { timeout: 30000 }) + const fileRow = fileCheckbox.locator('xpath=ancestor::tr[1]') + await expect(fileRow).toBeVisible({ timeout: 30000 }) await fileRow.click({ button: 'right' }) - const openInLibreSignAction = page.getByRole('menuitem', { name: 'Open in LibreSign' }) - await expect(openInLibreSignAction).toBeVisible() + const contextMenu = page.getByRole('menu').last() + await expect(contextMenu).toBeVisible({ timeout: 15000 }) + const openInLibreSignAction = contextMenu.getByRole('menuitem', { name: 'Open in LibreSign' }) + await expect(openInLibreSignAction).toBeVisible({ timeout: 15000 }) await openInLibreSignAction.click() const libresignTab = page.getByRole('tab', { name: 'LibreSign' }) - await expect(libresignTab).toHaveAttribute('aria-selected', 'true') - await expect(page.getByRole('button', { name: 'Add signer' })).toBeVisible() - await expect(page.locator('.app-sidebar-header__mainname')).toHaveText(fileName) + await expect(libresignTab).toHaveAttribute('aria-selected', 'true', { timeout: 15000 }) + await expect(page.getByRole('button', { name: 'Add signer' })).toBeVisible({ timeout: 15000 }) + await expect(page.locator('.app-sidebar-header__mainname')).toHaveText(fileName, { timeout: 15000 }) }) diff --git a/playwright/e2e/footer-policy-hierarchy-ui.spec.ts b/playwright/e2e/footer-policy-hierarchy-ui.spec.ts new file mode 100644 index 0000000000..3aa8b76d8a --- /dev/null +++ b/playwright/e2e/footer-policy-hierarchy-ui.spec.ts @@ -0,0 +1,426 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext, type Locator, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { + configureOpenSsl, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, + setUserLanguage, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, + setGroupPolicyEntry, + setSystemPolicyEntry, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + endUserRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + endUserRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const DEFAULT_TEST_PASSWORD = '123456' + +const GROUP_ID = 'libresign-footer-ui-flow-group' +const END_USER = 'signer1' +const FOOTER_POLICY_KEY = 'add_footer' +const REQUEST_SIGN_POLICY_KEY = 'groups_request_sign' +const REQUEST_SIGN_GROUP_POLICY_VALUE = JSON.stringify({ + allowGroups: [GROUP_ID], + denyGroups: [], +}) +const SYSTEM_FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +function buildFooterPolicyValue(template: string): string { + return JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: true, + footerTemplate: template, + }) +} + +function normalizeFooterPolicyValue(value: unknown): Record { + if (typeof value === 'string') { + return JSON.parse(value) as Record + } + + if (value && typeof value === 'object') { + return value as Record + } + + return {} +} + +function getTrimmedFooterTemplate(value: unknown): string { + const parsed = normalizeFooterPolicyValue(value) + const template = parsed.footerTemplate + return typeof template === 'string' ? template.trim() : '' +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getTargetFieldPattern(kind: 'group' | 'user'): RegExp { + const labels = kind === 'group' + ? ['Scope groups', 'Target groups'] + : ['Scope accounts', 'Target users'] + + return new RegExp(`^(?:${labels.map(escapeRegExp).join('|')})$`, 'i') +} + +async function deleteGroupPolicyEntry( + ctx: APIRequestContext, + groupId: string, + policyKey: string, +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`) + expect([200, 404, 500]).toContain(response.httpStatus) +} + +async function deleteUserPolicyEntry( + ctx: APIRequestContext, + userId: string, + policyKey: string, +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`) + expect([200, 404, 500]).toContain(response.httpStatus) +} + +async function setUserPolicyEntry( + ctx: APIRequestContext, + userId: string, + policyKey: string, + value: string, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest(ctx, 'PUT', `/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`, { + value, + allowChildOverride, + }) + expect(response.httpStatus, `setUserPolicyEntry(${userId}/${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +async function resetFooterHierarchyState( + adminRequestContext: APIRequestContext, + endUserRequestContext: APIRequestContext, +): Promise { + await clearUserPolicyPreference(endUserRequestContext, FOOTER_POLICY_KEY, [200, 401, 500]) + await deleteUserPolicyEntry(adminRequestContext, END_USER, FOOTER_POLICY_KEY) + await deleteGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, SYSTEM_FOOTER_DISABLED_VALUE, true) +} + +async function waitForPolicyRequest(page: Page, method: 'PUT' | 'DELETE', urlPart: string, action: () => Promise) { + const requestPromise = page.waitForRequest((request) => { + return request.method() === method + && request.url().includes(urlPart) + }) + + await action() + return requestPromise +} + +async function openFooterPolicyDialog(page: Page): Promise { + await page.goto('/apps/libresign/f/policies') + await expect(page).toHaveURL(/\/apps\/libresign\/f\/policies/, { timeout: 20000 }) + + const searchField = page.getByRole('textbox', { name: 'Search settings' }) + await expect(searchField).toBeVisible({ timeout: 20000 }) + await searchField.fill('Signature footer') + + const configureButton = page.getByRole('button', { name: 'Configure setting' }).first() + await expect(configureButton).toBeVisible({ timeout: 20000 }) + await configureButton.click() + + const dialog = page.getByRole('dialog', { name: 'Signature footer' }).first() + await expect(dialog).toBeVisible({ timeout: 20000 }) + return dialog +} + +async function openCreateRuleEditor(dialog: Locator, scopeName: 'Group' | 'User'): Promise { + await dialog.getByRole('button', { name: 'Create rule' }).click() + + const scopeDialog = dialog.page().getByRole('dialog').last() + await expect(scopeDialog).toBeVisible({ timeout: 10000 }) + await scopeDialog.getByRole('option', { name: new RegExp(`^${scopeName}`) }).click() +} + +async function selectTarget(dialogScope: Locator, kind: 'group' | 'user', target: string): Promise { + const page = dialogScope.page() + const labelPattern = getTargetFieldPattern(kind) + const combobox = dialogScope.getByRole('combobox', { name: labelPattern }).first() + const labeledInput = dialogScope.getByLabel(labelPattern).first() + const targetInput = await combobox.count() ? combobox : labeledInput + const selectedTarget = dialogScope.locator('.vs__selected').filter({ hasText: new RegExp(escapeRegExp(target), 'i') }).first() + const submitButton = dialogScope.getByRole('button', { + name: /Create rule|Create policy rule|Save changes|Save policy rule changes|Save rule changes/i, + }).last() + + const isSelectionConfirmed = async () => { + if (await selectedTarget.isVisible({ timeout: 1000 }).catch(() => false)) { + return true + } + + if (await submitButton.isVisible({ timeout: 1000 }).catch(() => false)) { + return submitButton.isEnabled().catch(() => false) + } + + return false + } + + await expect(targetInput).toBeVisible({ timeout: 8000 }) + if (await isSelectionConfirmed()) { + return + } + + await targetInput.click() + if (await isSelectionConfirmed()) { + return + } + + const searchInput = targetInput.locator('input, textarea, [contenteditable="true"]').first() + if (await searchInput.isVisible({ timeout: 1000 }).catch(() => false)) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await searchInput.fill(target) + + const matchingOption = page.getByRole('option', { name: new RegExp(escapeRegExp(target), 'i') }).first() + const matchingVisible = await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false) + if (matchingVisible) { + await matchingOption.click() + } else { + const floatingOption = page.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li').filter({ hasText: new RegExp(escapeRegExp(target), 'i') }).first() + if (await floatingOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await floatingOption.click() + } else { + await searchInput.press('ArrowDown') + await searchInput.press('Enter') + } + } + + await page.keyboard.press('Escape').catch(() => {}) + await searchInput.press('Tab').catch(() => {}) + if (await isSelectionConfirmed()) { + break + } + } + await expect.poll(isSelectionConfirmed, { timeout: 8000 }).toBe(true) + } else { + await page.keyboard.type(target) + const matchingOption = page.getByRole('option', { name: new RegExp(escapeRegExp(target), 'i') }).first() + if (await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)) { + await matchingOption.click() + } else { + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + } + await page.keyboard.press('Tab').catch(() => {}) + await expect.poll(isSelectionConfirmed, { timeout: 8000 }).toBe(true) + } + + await expect(page.locator('ul[role="listbox"].vs__dropdown-menu--floating')).toHaveCount(0) +} + +async function ensureCheckboxEnabled(scope: Page | Locator, checkboxLabel: string): Promise { + const checkbox = scope.getByRole('checkbox', { name: checkboxLabel }).first() + await expect(checkbox).toBeVisible({ timeout: 10000 }) + const checked = await checkbox.isChecked().catch(() => false) + if (!checked) { + await checkbox.setChecked(true, { force: true }) + } + await expect(checkbox).toBeChecked() +} + +async function ensureFooterTemplateEditorVisible(scope: Page | Locator): Promise { + await ensureCheckboxEnabled(scope, 'Add visible footer with signature details') + await ensureCheckboxEnabled(scope, 'Customize footer template') + + const editorContainer = scope.locator('.code-editor').first() + const footerTemplateField = editorContainer.locator('.cm-content[contenteditable="true"]').first() + await expect(footerTemplateField).toBeVisible({ timeout: 10000 }) + return footerTemplateField +} + +async function createFooterRuleViaUi( + page: Page, + scopeName: 'Group' | 'User', + target: string, + template: string, + requestUrlPart: string, +): Promise { + const dialog = await openFooterPolicyDialog(page) + await openCreateRuleEditor(dialog, scopeName) + + const createRuleDialog = page.getByRole('dialog', { name: 'Create rule' }).last() + await expect(createRuleDialog).toBeVisible({ timeout: 10000 }) + + if (scopeName === 'Group') { + await selectTarget(createRuleDialog, 'group', target) + } else { + await selectTarget(createRuleDialog, 'user', target) + } + + const footerTemplateField = await ensureFooterTemplateEditorVisible(createRuleDialog) + await footerTemplateField.click() + await footerTemplateField.press('Control+a') + await footerTemplateField.fill(template) + + await waitForPolicyRequest(page, 'PUT', requestUrlPart, async () => { + await page.getByRole('button', { name: 'Create rule' }).last().click() + }) + await dialog.getByRole('button', { name: 'Close' }).first().click() + await expect(dialog).toBeHidden({ timeout: 10000 }) +} + +async function expectFooterTemplateValue(page: Page, expectedValue: string): Promise { + const footerTemplateField = await ensureFooterTemplateEditorVisible(page) + await expect.poll(async () => { + const text = await footerTemplateField.textContent() + return (text ?? '').trim() + }, { timeout: 10000 }).toContain(expectedValue) +} + +test.beforeEach(async ({ page, adminRequestContext, endUserRequestContext }) => { + await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, END_USER, GROUP_ID) + await setUserLanguage(adminRequestContext, ADMIN_USER, 'en') + await setUserLanguage(adminRequestContext, END_USER, 'en') + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, REQUEST_SIGN_POLICY_KEY, REQUEST_SIGN_GROUP_POLICY_VALUE, true) + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + await resetFooterHierarchyState(adminRequestContext, endUserRequestContext) +}) + +test.afterEach(async ({ adminRequestContext, endUserRequestContext }) => { + await resetFooterHierarchyState(adminRequestContext, endUserRequestContext) + await deleteGroupPolicyEntry(adminRequestContext, GROUP_ID, REQUEST_SIGN_POLICY_KEY) +}) + +test('footer hierarchy works through policies and preferences UI', async ({ page, adminRequestContext, endUserRequestContext }) => { + const uniqueId = Date.now() + const groupTemplate = `
Group footer ${uniqueId}
` + const userTemplate = `
User footer ${uniqueId}
` + const adminUserTemplate = `
Admin override ${uniqueId}
` + + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + await createFooterRuleViaUi( + page, + 'Group', + GROUP_ID, + groupTemplate, + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${FOOTER_POLICY_KEY}`, + ) + + let effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(groupTemplate) + expect(effectivePolicy?.sourceScope).toBe('group') + + await login(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await page.goto('/apps/libresign/f/preferences') + await expect(page).toHaveURL(/\/apps\/libresign\/f\/preferences/, { timeout: 20000 }) + await expectFooterTemplateValue(page, groupTemplate) + + await waitForPolicyRequest(page, 'PUT', `/apps/libresign/api/v1/policies/user/${FOOTER_POLICY_KEY}`, async () => { + const footerTemplateField = await ensureFooterTemplateEditorVisible(page) + await footerTemplateField.click() + await footerTemplateField.press('Control+a') + await footerTemplateField.fill(userTemplate) + await footerTemplateField.press('Tab') + }) + await expect(page.getByText('Preference saved', { exact: true })).toBeVisible({ timeout: 20000 }) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(userTemplate) + expect(effectivePolicy?.sourceScope).toBe('user') + await expectFooterTemplateValue(page, userTemplate) + + await waitForPolicyRequest(page, 'DELETE', `/apps/libresign/api/v1/policies/user/${FOOTER_POLICY_KEY}`, async () => { + await page.getByRole('button', { name: 'Reset to default' }).first().click() + }) + await expectFooterTemplateValue(page, groupTemplate) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(groupTemplate) + expect(effectivePolicy?.sourceScope).toBe('group') + + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + await setUserPolicyEntry(adminRequestContext, END_USER, FOOTER_POLICY_KEY, buildFooterPolicyValue(adminUserTemplate), true) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(adminUserTemplate) + expect(effectivePolicy?.sourceScope).toBe('user_policy') + + await login(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await page.goto('/apps/libresign/f/preferences') + await expectFooterTemplateValue(page, adminUserTemplate) + + await deleteUserPolicyEntry(adminRequestContext, END_USER, FOOTER_POLICY_KEY) + + await page.reload() + await expectFooterTemplateValue(page, groupTemplate) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(groupTemplate) + expect(effectivePolicy?.sourceScope).toBe('group') + await expect(page.getByText('Preference saved', { exact: true })).toHaveCount(0) +}) diff --git a/playwright/e2e/footer-reset-persistence.spec.ts b/playwright/e2e/footer-reset-persistence.spec.ts new file mode 100644 index 0000000000..80033878f8 --- /dev/null +++ b/playwright/e2e/footer-reset-persistence.spec.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' +import { + bootstrapLibreSignAdmin, + ensureFooterTemplateEnabled, + fillTemplateEditor, + openSystemFooterRuleEditor, +} from '../support/footer-policy-workbench' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +useRequestSignPolicyGuard() + +async function waitForFooterTemplateRequest(page: Page, action: () => Promise) { + const requestPromise = page.waitForRequest((request) => { + return request.method() === 'POST' && /footer-template\/preview-pdf/.test(request.url()) + }) + + await action() + const request = await requestPromise + return request.postDataJSON() as { + template: string + width: number + height: number + } +} + +async function saveRule(page: Page, ruleDialog: Locator): Promise { + const saveButton = ruleDialog.getByRole('button', { name: /Create rule|Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 10000 }) + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + const saveResponsePromise = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/add_footer') + }) + await saveButton.click() + const saveResponse = await saveResponsePromise + await expect(saveResponse.status()).toBe(200) +} + +test('footer template persists after reset and page reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + let ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + const templateEditor = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + + // Save custom template + const customTemplate = `
E2E_TEST_${Date.now()}
` + await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, customTemplate) + }) + await expect(templateEditor).toContainText('E2E_TEST_') + + // Click reset template to inherited default + const resetButton = ruleDialog.getByRole('button', { name: /Reset template to inherited default/i }).first() + await expect(resetButton).toBeVisible({ timeout: 10000 }) + await waitForFooterTemplateRequest(page, async () => { + await resetButton.click() + }) + + // Persist rule and verify reset survives reload + await saveRule(page, ruleDialog) + + await page.reload() + ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + const templateAfterReload = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(templateAfterReload).toBeVisible({ timeout: 10000 }) + await expect(templateAfterReload).not.toContainText('E2E_TEST_') +}) diff --git a/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts b/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts index 97dc556a6b..863947870b 100644 --- a/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts +++ b/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts @@ -4,41 +4,58 @@ */ import { devices, expect, test } from '@playwright/test' -import { login } from '../support/nc-login' -import { setAppConfig } from '../support/nc-provisioning' +import { + bootstrapLibreSignAdmin, + ensureFooterTemplateEnabled, + openSystemFooterRuleEditor, +} from '../support/footer-policy-workbench' test.use({ ...devices['Pixel 7'], }) test('PDF viewer allows horizontal scrolling on mobile viewport', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) - - await setAppConfig( - page.request, - 'libresign', 'add_footer', '1', - ) - - await setAppConfig( - page.request, - 'libresign', 'footer_template_is_default', '0', - ) - - await page.goto('./settings/admin/libresign') - const pdfRoot = page.locator('.footer-template-section .pdf-elements-root').first() + await bootstrapLibreSignAdmin(page) + const ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + + const pdfRoot = ruleDialog.locator('.signature-footer-rule-editor__preview .signature-footer-rule-editor__preview-frame').first() await expect(pdfRoot).toBeVisible({ timeout: 15000 }) - // Check that overflow-x is set to auto (not hidden). + const widthField = ruleDialog.getByRole('spinbutton', { name: 'Width' }).first() + await expect(widthField).toBeVisible({ timeout: 10000 }) + await widthField.fill('900') + await widthField.press('Tab') + + // Wait for CSS changes to be applied after width change + await expect.poll(async () => { + const style = await pdfRoot.evaluate((el) => window.getComputedStyle(el).overflowX) + return style && style !== '' + }, { + timeout: 5000, + message: 'Expected overflow-x style to be applied to pdf-elements-root', + }).toBe(true) + + // Check that overflow-x allows horizontal scrolling. + // Some browsers report it on an internal PDFElements scroll host, not on the custom element itself. const computedStyle = await pdfRoot.evaluate((el) => { - return window.getComputedStyle(el).overflowX + const fromElement = window.getComputedStyle(el).overflowX + if (fromElement) { + return fromElement + } + + const nestedScrollHost = el.querySelector('.pdf-elements-root, [class*="pdf-elements"]') as HTMLElement | null + if (!nestedScrollHost) { + return '' + } + + return window.getComputedStyle(nestedScrollHost).overflowX }) expect(computedStyle).not.toBe('hidden') - expect(['auto', 'scroll']).toContain(computedStyle) + if (computedStyle !== '') { + expect(['auto', 'scroll']).toContain(computedStyle) + } // Verify touch-action is set correctly for touch gestures. const touchAction = await pdfRoot.evaluate((el) => { @@ -49,6 +66,13 @@ test('PDF viewer allows horizontal scrolling on mobile viewport', async ({ page expect(touchAction).not.toContain('pinch-zoom') // Validate real horizontal scrolling capability, not only style declarations. + await expect.poll(async () => { + return pdfRoot.evaluate((el) => el.scrollWidth > el.clientWidth) + }, { + timeout: 15000, + message: 'Expected footer preview to become horizontally scrollable after widening the preview', + }).toBe(true) + const before = await pdfRoot.evaluate((el) => { el.scrollLeft = 0 return { @@ -58,8 +82,6 @@ test('PDF viewer allows horizontal scrolling on mobile viewport', async ({ page } }) - expect(before.scrollWidth).toBeGreaterThan(before.clientWidth) - const box = await pdfRoot.boundingBox() expect(box).not.toBeNull() diff --git a/playwright/e2e/multi-signer-parallel.spec.ts b/playwright/e2e/multi-signer-parallel.spec.ts index 9ceb2ab163..5716ba31e3 100644 --- a/playwright/e2e/multi-signer-parallel.spec.ts +++ b/playwright/e2e/multi-signer-parallel.spec.ts @@ -4,9 +4,13 @@ */ import { expect, test } from '@playwright/test' -import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' + import { createMailpitClient, waitForEmailTo } from '../support/mailpit' +import { login } from '../support/nc-login' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() test('request signatures from two signers in parallel', async ({ page }) => { await login( @@ -23,14 +27,16 @@ test('request signatures from two signers in parallel', async ({ page }) => { L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, - ]), + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + ], + }), ) const mailpit = createMailpitClient() @@ -69,10 +75,10 @@ test('request signatures from two signers in parallel', async ({ page }) => { // In parallel mode both signers are notified simultaneously. // Proof: wait for signer01's email, then verify that signer02's email also arrived. - await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') - await waitForEmailTo(mailpit, 'signer02@libresign.coop', 'LibreSign: There is a file for you to sign') + await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: A document is ready for your signature') + await waitForEmailTo(mailpit, 'signer02@libresign.coop', 'LibreSign: A document is ready for your signature') // Both emails arrived — both signers were notified at the same time, confirming parallel mode. - const result = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) + const result = await mailpit.searchMessages({ query: 'subject:"LibreSign: A document is ready for your signature"' }) expect(result.messages).toHaveLength(2) }) diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 8cfbc241f5..d94ff28b22 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -3,11 +3,77 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Page } from '@playwright/test' -import { expect, test } from '@playwright/test' -import { login } from '../support/nc-login' -import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, test as base } from '@playwright/test' + import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' +import { login } from '../support/nc-login' +import { configureOpenSsl, deleteAppConfig, getAppConfig, setAppConfig, setCertificateEngine, getSystemPolicyValue, setSystemPolicy } from '../support/nc-provisioning' +import { setSystemPolicyEntry } from '../support/policy-api' +import { makeAdminContext, useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() + +const FOOTER_POLICY_KEY = 'add_footer' +const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +type OriginalConfigSnapshot = { + identifyMethods: string | null + signatureEngine: string | null + tsaUrl: string | null + footerPolicy: string | null +} + +const test = base.extend<{ + adminContext: APIRequestContext + originalConfigSnapshot: OriginalConfigSnapshot +}>({ + adminContext: async ({ request: _request }, use) => { + const ctx = await makeAdminContext() + await use(ctx) + await ctx.dispose() + }, + originalConfigSnapshot: async ({ request, adminContext }, use) => { + const response = await adminContext.get(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${FOOTER_POLICY_KEY}`, { + failOnStatusCode: false, + }) + expect(response.status(), `getSystemFooterPolicy: expected 200 but got ${response.status()}`).toBe(200) + const policyBody = await response.json() as { + ocs?: { + data?: { + policy?: { + value?: string | null + } + } + } + } + + await use({ + identifyMethods: await getSystemPolicyValue(request, 'identify_methods'), + signatureEngine: await getAppConfig(request, 'libresign', 'signature_engine'), + tsaUrl: await getAppConfig(request, 'libresign', 'tsa_url'), + footerPolicy: policyBody.ocs?.data?.policy?.value ?? null, + }) + }, +}) + +test.setTimeout(120_000) + +test.afterEach(async ({ page, adminContext, originalConfigSnapshot }) => { + await setSystemPolicy(page.request, 'identify_methods', originalConfigSnapshot.identifyMethods ?? '{"factors":[]}') + if (originalConfigSnapshot.signatureEngine !== null) { + await setAppConfig(page.request, 'libresign', 'signature_engine', originalConfigSnapshot.signatureEngine) + } else { + await deleteAppConfig(page.request, 'libresign', 'signature_engine') + } + await restoreAppConfig(page.request, 'tsa_url', originalConfigSnapshot.tsaUrl) + await setSystemPolicyEntry(adminContext, FOOTER_POLICY_KEY, originalConfigSnapshot.footerPolicy ?? FOOTER_DISABLED_VALUE, true) +}) async function addEmailSigner( page: Page, @@ -27,35 +93,86 @@ async function addEmailSigner( await page.getByRole('button', { name: 'Save' }).click() } -test('request signatures from two signers in sequential order', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) +async function openInvitationAsExternalSigner(page: Page, signLink: string) { + await page.context().clearCookies() + await page.goto('about:blank') - await configureOpenSsl(page.request, 'LibreSign Test', { - C: 'BR', - OU: ['Organization Unit'], - ST: 'Rio de Janeiro', - O: 'LibreSign', - L: 'Rio de Janeiro', - }) + const baseCandidates = signLink.startsWith('/index.php/') + ? [signLink, signLink.replace(/^\/index\.php/, '')] + : [signLink, `/index.php${signLink.startsWith('/') ? '' : '/'}${signLink}`] - await setAppConfig( - page.request, - 'libresign', - 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, - ]), - ) - await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') - await deleteAppConfig(page.request, 'libresign', 'tsa_url') + const signLinkCandidates = [...new Set(baseCandidates.flatMap((candidate) => { + const withoutPdfSuffix = candidate.replace(/\/pdf(?=$|[?#])/, '') + return candidate === withoutPdfSuffix ? [candidate] : [candidate, withoutPdfSuffix] + }))] + + for (const candidate of signLinkCandidates) { + try { + await page.goto(candidate, { waitUntil: 'commit', timeout: 15_000 }) + } catch { + continue + } + + const currentUrl = page.url() + if (currentUrl.includes('/login') || /\/p\/error(?:$|[/?#])/.test(currentUrl)) { + continue + } + + if (currentUrl.includes('/apps/libresign/p/sign/')) { + return + } + } + + throw new Error(`Invitation link redirected to login instead of public sign page: ${page.url()}`) +} + +async function openSignDocument(page: Page) { + const signButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) + await expect(signButton).toBeVisible({ timeout: 15_000 }) + await signButton.click({ force: true }) + const confirmSignButton = page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }) + await expect(confirmSignButton).toBeVisible({ timeout: 15_000 }) +} + +test('request signatures from two signers in sequential order', async ({ page, adminContext }) => { + await test.step('configure signing environment', async () => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setSystemPolicy( + page.request, + 'identify_methods', + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true }, emailToken: { enabled: false } } }, + ], + }), + ) + await setSystemPolicy(page.request, 'make_validation_url_private', '0') + await setCertificateEngine(page.request, 'openssl') + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') + await setSystemPolicyEntry(adminContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE, true) + }) const mailpit = createMailpitClient() await mailpit.deleteMessages() + const timestamp = Date.now() + const signer01Email = `sequential-signer01-${timestamp}@libresign.coop` + const signer02Email = `sequential-signer02-${timestamp}@libresign.coop` await page.goto('./apps/libresign') await page.getByRole('button', { name: 'Upload from URL' }).click() @@ -63,16 +180,17 @@ test('request signatures from two signers in sequential order', async ({ page }) await page.getByRole('button', { name: 'Send' }).click() // Add first signer — only email method is active, so the field appears directly (no tabs) - await addEmailSigner(page, 'signer01@libresign.coop', 'Signer 01') + await addEmailSigner(page, signer01Email, 'Signer 01') // Add second signer - await addEmailSigner(page, 'signer02@libresign.coop', 'Signer 02') + await addEmailSigner(page, signer02Email, 'Signer 02') // Enable sequential signing. - // The checkbox input is hidden by CSS; click the visible label text to toggle it. - await expect(page.getByLabel('Sign in order')).toBeVisible() - await page.getByText('Sign in order').click() - await expect(page.getByLabel('Sign in order')).toBeChecked() + // The hidden checkbox can be covered by the styled label in CI, so force the state change. + const signInOrderSwitch = page.getByLabel('Sign in order') + await expect(signInOrderSwitch).toBeVisible() + await signInOrderSwitch.check({ force: true }) + await expect(signInOrderSwitch).toBeChecked() // Send the signature request await page.getByRole('button', { name: 'Request signatures' }).click() @@ -80,49 +198,66 @@ test('request signatures from two signers in sequential order', async ({ page }) // In sequential mode only signer01 (order 1) gets the email immediately. // Proof: signer01's email arrives, but signer02's does NOT at this point. - const email01 = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') + const email01 = await waitForEmailTo(mailpit, signer01Email, 'LibreSign: A document is ready for your signature') - const afterFirst = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) + const afterFirst = await mailpit.searchMessages({ query: `to:${signer01Email} subject:"LibreSign: A document is ready for your signature"` }) expect(afterFirst.messages).toHaveLength(1) // Keep the browser unauthenticated before opening a public sign link. // This avoids logout redirects to absolute hosts that may differ per environment. - await page.context().clearCookies() - await page.goto('about:blank') // Signer01 signs via the link received in the email const signLink = extractSignLink(email01.Text) if (!signLink) throw new Error('Sign link not found in email') - await page.goto(signLink) - await page.getByRole('button', { name: 'Sign the document.' }).click() - await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + await openInvitationAsExternalSigner(page, signLink) + await openSignDocument(page) + const firstSignResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() + const firstSignResponse = await firstSignResponsePromise + const firstSignResponseBody = await firstSignResponse.text() + expect( + firstSignResponse.ok(), + `Signer 01 sign API failed with status ${firstSignResponse.status()}: ${firstSignResponseBody}`, + ).toBeTruthy() + await page.waitForURL('**/validation/**', { waitUntil: 'commit' }) await expect(page.getByText('This document is valid')).toBeVisible() // Signer01 signed; signer02 is still waiting (sequential mode proof at this point) await expect(page.getByText('Signer 01')).toBeVisible() await page.getByRole('button', { name: 'Expand details of Signer 01' }).click() - await page.getByRole('button', { name: 'Expand validation status', exact: true }).click(); - await page.getByRole('link', { name: 'Document integrity verified' }).click(); - await page.getByRole('button', { name: 'Expand document certification', exact: true }).click(); - await page.getByRole('link', { name: 'Document has not been' }).click(); + await page.getByRole('button', { name: 'Expand validation status', exact: true }).click() + await page.getByRole('link', { name: 'Document integrity verified' }).click() + await page.getByRole('button', { name: 'Expand document certification', exact: true }).click() await expect(page.getByText('Signer 02')).toBeVisible() await expect(page.getByText('Not signed yet')).toBeVisible() // Now that signer01 has signed, signer02 must receive their notification. - const email02 = await waitForEmailTo(mailpit, 'signer02@libresign.coop', 'LibreSign: There is a file for you to sign') + const email02 = await waitForEmailTo(mailpit, signer02Email, 'LibreSign: A document is ready for your signature') - const afterSecond = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) - expect(afterSecond.messages).toHaveLength(2) + const afterSecond = await mailpit.searchMessages({ query: `to:${signer02Email} subject:"LibreSign: A document is ready for your signature"` }) + expect(afterSecond.messages).toHaveLength(1) // Signer02 signs via their email link. // The admin is still logged out from the signer01 step, so this is unauthenticated. const signLink02 = extractSignLink(email02.Text) if (!signLink02) throw new Error('Sign link for signer02 not found in email') - await page.goto(signLink02) - await page.getByRole('button', { name: 'Sign the document.' }).click() - await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + await openInvitationAsExternalSigner(page, signLink02) + await openSignDocument(page) + const secondSignResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() + const secondSignResponse = await secondSignResponsePromise + const secondSignResponseBody = await secondSignResponse.text() + expect( + secondSignResponse.ok(), + `Signer 02 sign API failed with status ${secondSignResponse.status()}: ${secondSignResponseBody}`, + ).toBeTruthy() + await page.waitForURL('**/validation/**', { waitUntil: 'commit' }) await expect(page.getByText('This document is valid')).toBeVisible() // Both signers must appear as signed in the final validation view. @@ -130,3 +265,16 @@ test('request signatures from two signers in sequential order', async ({ page }) await expect(page.getByText('Signer 02')).toBeVisible() await expect(page.getByText('Not signed yet')).not.toBeVisible() }) + +async function restoreAppConfig( + requestContext: APIRequestContext, + key: string, + value: string | null, +): Promise { + if (value === null) { + await deleteAppConfig(requestContext, 'libresign', key) + return + } + + await setAppConfig(requestContext, 'libresign', key, value) +} diff --git a/playwright/e2e/policy-api-signature-flow-negative.spec.ts b/playwright/e2e/policy-api-signature-flow-negative.spec.ts new file mode 100644 index 0000000000..8453ba26e8 --- /dev/null +++ b/playwright/e2e/policy-api-signature-flow-negative.spec.ts @@ -0,0 +1,53 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { + createAuthenticatedRequestContext, + policyRequest, +} from '../support/policy-api' + +const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +let ctx: Awaited> | null = null + +test.describe.configure({ mode: 'serial', retries: 0 }) + +test.afterEach(async () => { + if (!ctx) { + return + } + + await ctx.dispose() + ctx = null +}) + +test('rejects unsupported signature_flow value at system scope', async () => { + ctx = await createAuthenticatedRequestContext(adminUser, adminPassword) + const response = await policyRequest( + ctx, + 'POST', + '/apps/libresign/api/v1/policies/system/signature_flow', + { value: 'invalid_flow_mode', allowChildOverride: true }, + ) + + expect(response.httpStatus).not.toBe(200) + expect([400, 422]).toContain(response.httpStatus) +}) + +test('rejects unsupported signature_flow value at group scope', async () => { + ctx = await createAuthenticatedRequestContext(adminUser, adminPassword) + const response = await policyRequest( + ctx, + 'PUT', + '/apps/libresign/api/v1/policies/group/admin/signature_flow', + { value: 'invalid_flow_mode', allowChildOverride: true }, + ) + + expect(response.httpStatus).not.toBe(200) + expect([400, 422]).toContain(response.httpStatus) +}) diff --git a/playwright/e2e/policy-preferences-boolean-settings.spec.ts b/playwright/e2e/policy-preferences-boolean-settings.spec.ts new file mode 100644 index 0000000000..890e870649 --- /dev/null +++ b/playwright/e2e/policy-preferences-boolean-settings.spec.ts @@ -0,0 +1,398 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + configureOpenSsl, + deleteGroup, + deleteUser, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + getSystemPolicySnapshot, + policyRequest, + restoreSystemPolicySnapshot, + setGroupPolicyEntry, + setSystemPolicyEntry, + type SystemPolicySnapshot, +} from '../support/policy-api' + +const adminUser = 'admin' +const adminPass = process.env.ADMIN_PASSWORD || 'admin' +const TEST_GROUP_ID = 'pw-pref-boolean-group' +const TEST_END_USER = 'pw_prefboolean_user' + +let activeAdminCtx: APIRequestContext | null = null +let endUserCtx: APIRequestContext | null = null +let originalGroupsRequestSign: SystemPolicySnapshot | null = null +let originalCollectMetadata: SystemPolicySnapshot | null = null +let originalDocmdp: SystemPolicySnapshot | null = null +let originalSignatureText: SystemPolicySnapshot | null = null + +test.describe('Policy preferences: boolean settings', () => { + test.afterEach(async () => { + if (endUserCtx) { + await clearUserPolicyPreference(endUserCtx, 'collect_metadata', [200, 401, 500]) + await clearUserPolicyPreference(endUserCtx, 'docmdp', [200, 401, 500]) + await clearUserPolicyPreference(endUserCtx, 'signature_stamp', [200, 401, 405, 500]) + await endUserCtx.dispose() + endUserCtx = null + } + + const adminCtx = activeAdminCtx ?? await createAuthenticatedRequestContext(adminUser, adminPass) + if (originalGroupsRequestSign) { + await restoreSystemPolicySnapshot(adminCtx, 'groups_request_sign', originalGroupsRequestSign) + } + if (originalCollectMetadata) { + await restoreSystemPolicySnapshot(adminCtx, 'collect_metadata', originalCollectMetadata) + } + if (originalDocmdp) { + await restoreSystemPolicySnapshot(adminCtx, 'docmdp', originalDocmdp) + } + if (originalSignatureText) { + await restoreSystemPolicySnapshot(adminCtx, 'signature_stamp', originalSignatureText) + } + + await deleteUser(adminCtx, TEST_END_USER, adminUser, adminPass) + await deleteGroup(adminCtx, TEST_GROUP_ID, adminUser, adminPass).catch(() => {}) + await adminCtx.dispose() + + activeAdminCtx = null + originalGroupsRequestSign = null + originalCollectMetadata = null + originalDocmdp = null + originalSignatureText = null + }) + + test('user can save and clear collect_metadata/docmdp while signature_text follows group policy', async ({ page }) => { + test.setTimeout(180000) + const groupId = TEST_GROUP_ID + const endUser = TEST_END_USER + const endPass = 'user1234' + + activeAdminCtx = await createAuthenticatedRequestContext(adminUser, adminPass) + const adminCtx = activeAdminCtx + originalGroupsRequestSign = await getSystemPolicySnapshot(adminCtx, 'groups_request_sign') + originalCollectMetadata = await getSystemPolicySnapshot(adminCtx, 'collect_metadata') + originalDocmdp = await getSystemPolicySnapshot(adminCtx, 'docmdp') + originalSignatureText = await getSystemPolicySnapshot(adminCtx, 'signature_stamp') + const signatureTextSystemValue = JSON.stringify({ + template: 'System template', + template_font_size: 9, + signature_font_size: 9, + signature_width: 90, + signature_height: 60, + render_mode: 'default', + }) + const signatureTextGroupValue = JSON.stringify({ + template: 'Group template', + template_font_size: 10, + signature_font_size: 10, + signature_width: 110, + signature_height: 70, + render_mode: 'text', + }) + + await login(page.request, adminUser, adminPass) + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await ensureGroupExists(page.request, groupId) + await ensureUserExists(page.request, endUser, endPass) + await ensureUserInGroup(page.request, endUser, groupId) + + await setSystemPolicyEntry(adminCtx, 'groups_request_sign', JSON.stringify({ allowGroups: [groupId], denyGroups: [] }), true) + await setSystemPolicyEntry(adminCtx, 'collect_metadata', JSON.stringify(false), true) + await setGroupPolicyEntry(adminCtx, groupId, 'collect_metadata', JSON.stringify(true), true) + await setSystemNumericPolicyEntry(adminCtx, 'docmdp', 0, true) + await setGroupNumericPolicyEntry(adminCtx, groupId, 'docmdp', 2, true) + await setSystemPolicyEntry(adminCtx, 'signature_stamp', signatureTextSystemValue, true) + await setGroupPolicyEntry(adminCtx, groupId, 'signature_stamp', signatureTextGroupValue, true) + + endUserCtx = await createAuthenticatedRequestContext(endUser, endPass) + + await login(page.request, endUser, endPass) + await page.goto('/index.php/apps/libresign/f/preferences') + await page.locator('#app-navigation-vue').waitFor({ state: 'visible' }) + await expandSettingsMenu(page) + + const docMdpSection = await sectionByTitle(page, 'PDF certification') + const signatureTextSection = await sectionByTitle(page, /Signature stamp text|Signature text|Signature stamp/i) + + await expect(page.getByRole('heading', { name: 'Collect signer metadata' })).toHaveCount(0) + expect(await docMdpSection.isVisible()).toBe(true) + expect(await signatureTextSection.isVisible()).toBe(true) + + await saveDocMdpPreference(docMdpSection, 3) + await saveSignatureTextCollectMetadataPreference(signatureTextSection, false) + + await expectPolicyEffectiveValue(endUserCtx, 'collect_metadata', false, 'user') + await expectDocMdpEffectiveValue(endUserCtx, 3, 'user') + await expectSignatureTextEffectiveState(endUserCtx, 'group', { + templateContains: 'Group template', + renderMode: 'text', + }) + + await clearPreference(signatureTextSection) + await clearPreference(docMdpSection) + + await expectPolicyEffectiveValue(endUserCtx, 'collect_metadata', true, 'group') + await expectDocMdpEffectiveValue(endUserCtx, 2, 'group') + await expectSignatureTextEffectiveState(endUserCtx, 'group', { + templateContains: 'Group template', + renderMode: 'text', + }) + }) +}) + +/** + * Resolves the settings section container from a visible section heading. + * + * @param page Browser page containing the preferences screen. + * @param title Visible heading text used to locate the section. + */ +async function sectionByTitle(page: Page, title: string | RegExp): Promise { + const heading = page.getByRole('heading', { name: title }).first() + await expect(heading).toBeVisible() + const section = heading.locator('xpath=ancestor::div[contains(@class, "settings-section")][1]') + await expect(section).toBeVisible() + return section +} + +/** + * Clears a user preference so the effective value falls back to the inherited layer. + * + * @param section Preference section locator. + */ +async function clearPreference(section: Locator): Promise { + const resetButton = section.getByRole('button').filter({ hasText: 'Reset to default' }).first() + await expect(resetButton).toBeVisible() + + await resetButton.click() +} + +/** + * Selects a specific PDF certification level in the DocMDP preference section. + * + * @param section Preference section locator. + * @param level Desired DocMDP level. + */ +async function saveDocMdpPreference(section: Locator, level: 0 | 1 | 2 | 3): Promise { + const labelByLevel: Record = { + 0: 'Disabled', + 1: 'No changes allowed', + 2: 'Form filling', + 3: 'Form filling and annotations', + } + + const option = section.getByRole('radio', { name: new RegExp(`^${escapeRegExp(labelByLevel[level])}\\b`, 'i') }).first() + await option.click({ force: true }) +} + +/** + * Escapes user-facing labels before embedding them in a dynamic regular expression. + * + * @param value Raw label text. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Toggles the signature-stamp metadata checkbox to the requested value. + * + * @param section Preference section locator. + * @param enabled Whether metadata collection should remain enabled. + */ +async function saveSignatureTextCollectMetadataPreference(section: Locator, enabled: boolean): Promise { + const toggle = section.getByRole('checkbox', { name: /Collect signer metadata/i }).first() + await expect(toggle).toBeVisible() + const checked = await toggle.isChecked() + if (checked !== enabled) { + await toggle.click({ force: true }) + } +} + +/** + * Polls until the effective value and source scope for a policy match the expectation. + * + * @param ctx Authenticated request context for the end user. + * @param policyKey Policy key to query. + * @param expectedValue Expected effective value. + * @param expectedScope Expected source scope. + */ +async function expectPolicyEffectiveValue( + ctx: APIRequestContext, + policyKey: string, + expectedValue: unknown, + expectedScope: string, +): Promise { + await expect.poll(async () => { + const entry = await getEffectivePolicy(ctx, policyKey) + return { + value: entry?.effectiveValue, + scope: entry?.sourceScope, + } + }, { timeout: 15000 }).toEqual({ + value: expectedValue, + scope: expectedScope, + }) +} + +/** + * Polls until the effective DocMDP value matches the expected inherited scope. + * + * @param ctx Authenticated request context for the end user. + * @param expectedValue Expected numeric DocMDP value. + * @param expectedScope Expected source scope. + */ +async function expectDocMdpEffectiveValue( + ctx: APIRequestContext, + expectedValue: number, + expectedScope: string, +): Promise { + await expect.poll(async () => { + const entry = await getEffectivePolicy(ctx, 'docmdp') + return { + value: Number(entry?.effectiveValue), + scope: entry?.sourceScope, + } + }, { timeout: 15000 }).toEqual({ + value: expectedValue, + scope: expectedScope, + }) +} + +/** + * Polls until the effective signature-stamp state matches the expected scope and content. + * + * @param ctx Authenticated request context for the end user. + * @param expectedScope Expected source scope. + * @param expected Expected content matchers for the signature-stamp value. + * @param expected.templateContains Expected template substring when provided. + * @param expected.renderMode Expected render mode when provided. + */ +async function expectSignatureTextEffectiveState( + ctx: APIRequestContext, + expectedScope: string, + expected: { + templateContains?: string + renderMode?: string + }, +): Promise { + const expectedMatch: Record = { + scope: expectedScope, + } + if (expected.templateContains) { + expectedMatch.template = expect.stringContaining(expected.templateContains) + } + if (expected.renderMode) { + expectedMatch.renderMode = expected.renderMode + } + + await expect.poll(async () => { + const entry = await getEffectivePolicy(ctx, 'signature_stamp') + if (!entry) { + return { scope: '', template: '', renderMode: '' } + } + const parsed = parseSignatureTextValue(entry.effectiveValue) + return { + scope: String(entry.sourceScope ?? ''), + template: parsed.template, + renderMode: parsed.renderMode, + } + }, { timeout: 15000 }).toMatchObject(expectedMatch) +} + +/** + * Normalizes a signature-stamp value returned as a stringified JSON blob or object. + * + * @param value Raw signature-stamp policy value. + */ +function parseSignatureTextValue(value: unknown): { template: string; renderMode: string } { + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed.startsWith('{')) { + return { template: trimmed, renderMode: '' } + } + + try { + const parsed = JSON.parse(trimmed) as { template?: string; render_mode?: string } + return { + template: String(parsed.template ?? ''), + renderMode: String(parsed.render_mode ?? ''), + } + } catch { + return { template: '', renderMode: '' } + } + } + + if (value && typeof value === 'object') { + const raw = value as Record + return { + template: String(raw.template ?? ''), + renderMode: String(raw.render_mode ?? ''), + } + } + + return { template: '', renderMode: '' } +} + +/** + * Writes a numeric system policy entry using the generic OCS helper. + * + * @param ctx Authenticated admin request context. + * @param policyKey Policy key to write. + * @param value Numeric value to store. + * @param allowChildOverride Whether lower layers may override the stored value. + */ +async function setSystemNumericPolicyEntry( + ctx: APIRequestContext, + policyKey: string, + value: number, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest(ctx, 'POST', `/apps/libresign/api/v1/policies/system/${policyKey}`, { + value, + allowChildOverride, + }) + expect(response.httpStatus, `setSystemNumericPolicyEntry(${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +/** + * Writes a numeric group policy entry using the generic OCS helper. + * + * @param ctx Authenticated admin request context. + * @param groupId Group identifier receiving the rule. + * @param policyKey Policy key to write. + * @param value Numeric value to store. + * @param allowChildOverride Whether lower layers may override the stored value. + */ +async function setGroupNumericPolicyEntry( + ctx: APIRequestContext, + groupId: string, + policyKey: string, + value: number, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest(ctx, 'PUT', `/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`, { + value, + allowChildOverride, + }) + expect(response.httpStatus, `setGroupNumericPolicyEntry(${groupId}/${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} diff --git a/playwright/e2e/policy-preferences-visibility.spec.ts b/playwright/e2e/policy-preferences-visibility.spec.ts new file mode 100644 index 0000000000..1d5e8c456b --- /dev/null +++ b/playwright/e2e/policy-preferences-visibility.spec.ts @@ -0,0 +1,176 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' + +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + configureOpenSsl, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, + setSystemPolicy, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + getSystemPolicySnapshot, + restoreSystemPolicySnapshot, + setGroupPolicyEntry, + setSystemPolicyEntry, + type SystemPolicySnapshot, + waitForPolicyCanSaveAsUserDefault, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + endUserRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({ browserName }, use) => { + if (!browserName) { + throw new Error('Missing browserName fixture') + } + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + endUserRequestContext: async ({ browserName }, use) => { + if (!browserName) { + throw new Error('Missing browserName fixture') + } + const ctx = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ retries: 0, timeout: 90000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const DEFAULT_TEST_PASSWORD = '123456' + +const GROUP_ID = 'policy-preferences-group' +const END_USER = 'policy-preferences-member' +const POLICY_KEY = 'signature_flow' +const FOOTER_POLICY_KEY = 'add_footer' +const REQUEST_SIGN_GROUPS = JSON.stringify({ allowGroups: ['admin', GROUP_ID], denyGroups: [] }) +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) +const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +let originalGroupsRequestSignPolicy: SystemPolicySnapshot | null = null +let originalSignatureFlowPolicy: SystemPolicySnapshot | null = null +let originalFooterPolicy: SystemPolicySnapshot | null = null + +/** + * Resets the preference-related policy state to the baseline expected by this + * scenario before policy delegation is toggled. + * + * @param adminRequestContext Authenticated admin API context used to reset system policies. + * @param endUserRequestContext Authenticated end-user API context used to clear user preferences. + */ +async function resetPolicyPreferencesState( + adminRequestContext: APIRequestContext, + endUserRequestContext: APIRequestContext, +): Promise { + await clearUserPolicyPreference(endUserRequestContext, POLICY_KEY) + await clearUserPolicyPreference(endUserRequestContext, FOOTER_POLICY_KEY) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE, true) + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, null, true) +} + +test.beforeEach(async ({ page, adminRequestContext, endUserRequestContext }) => { + await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, END_USER, GROUP_ID) + originalGroupsRequestSignPolicy = await getSystemPolicySnapshot(adminRequestContext, 'groups_request_sign') + originalSignatureFlowPolicy = await getSystemPolicySnapshot(adminRequestContext, POLICY_KEY) + originalFooterPolicy = await getSystemPolicySnapshot(adminRequestContext, FOOTER_POLICY_KEY) + await setSystemPolicy(adminRequestContext, 'groups_request_sign', REQUEST_SIGN_GROUPS) + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + await resetPolicyPreferencesState(adminRequestContext, endUserRequestContext) +}) + +test.afterEach(async ({ adminRequestContext, endUserRequestContext }) => { + await resetPolicyPreferencesState(adminRequestContext, endUserRequestContext) + if (originalFooterPolicy) { + await restoreSystemPolicySnapshot(adminRequestContext, FOOTER_POLICY_KEY, originalFooterPolicy) + } + if (originalSignatureFlowPolicy) { + await restoreSystemPolicySnapshot(adminRequestContext, POLICY_KEY, originalSignatureFlowPolicy) + } + if (originalGroupsRequestSignPolicy) { + await restoreSystemPolicySnapshot(adminRequestContext, 'groups_request_sign', originalGroupsRequestSignPolicy) + } + originalGroupsRequestSignPolicy = null + originalSignatureFlowPolicy = null + originalFooterPolicy = null +}) + +test('group member sees Preferences controls only when lower-layer customization is allowed', async ({ page, adminRequestContext, endUserRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'parallel', true) + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, POLICY_KEY, 'ordered_numeric', false) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, true) + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, false) + + let effectivePolicy = await getEffectivePolicy(endUserRequestContext, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('ordered_numeric') + expect(effectivePolicy?.canSaveAsUserDefault).toBe(false) + + await login(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, POLICY_KEY, 'ordered_numeric', true) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, POLICY_KEY) + expect(effectivePolicy?.canSaveAsUserDefault).toBe(true) + + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, true) + await waitForPolicyCanSaveAsUserDefault(endUserRequestContext, FOOTER_POLICY_KEY, true) + + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + + const enableFooterSwitch = page.locator('.checkbox-radio-switch') + .filter({ hasText: /Add visible footer(?: with signature details)?/i }) + .first() + await expect(enableFooterSwitch).toBeVisible({ timeout: 20000 }) + const enableFooterCheckbox = enableFooterSwitch.locator('input[type="checkbox"]').first() + if (!await enableFooterCheckbox.isChecked()) { + await enableFooterSwitch.locator('.checkbox-radio-switch__content').first().click() + await expect(enableFooterCheckbox).toBeChecked() + } + + const customizeTemplateSwitch = page.locator('.checkbox-radio-switch') + .filter({ hasText: /Customize footer template/i }) + .first() + await expect(customizeTemplateSwitch).toBeVisible({ timeout: 20000 }) + const customizeTemplateCheckbox = customizeTemplateSwitch.locator('input[type="checkbox"]').first() + if (!await customizeTemplateCheckbox.isChecked()) { + await customizeTemplateSwitch.locator('.checkbox-radio-switch__content').first().click() + await expect(customizeTemplateCheckbox).toBeChecked() + } + const footerTemplateLabel = page.getByText('Footer template', { exact: true }) + await expect(footerTemplateLabel).toBeVisible() +}) diff --git a/playwright/e2e/policy-request-sign-hidden-seed-overlay.spec.ts b/playwright/e2e/policy-request-sign-hidden-seed-overlay.spec.ts new file mode 100644 index 0000000000..299aa4339d --- /dev/null +++ b/playwright/e2e/policy-request-sign-hidden-seed-overlay.spec.ts @@ -0,0 +1,478 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext, type Locator, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + configureOpenSsl, + deleteGroup, + deleteUser, + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setUserLanguage, +} from '../support/nc-provisioning' +import { + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, + setGroupPolicyEntry, + setSystemPolicyEntry, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + groupAdminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({ browserName }, use) => { + if (!browserName) { + throw new Error('Missing browserName fixture') + } + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({ browserName }, use) => { + if (!browserName) { + throw new Error('Missing browserName fixture') + } + const ctx = await createAuthenticatedRequestContext(GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.NEXTCLOUD_ADMIN_PASSWORD || 'admin' +const GROUP_ADMIN = 'policy-request-sign-overlay-admin' +const GROUP_ADMIN_PASSWORD = '123456' +const BOARD_GROUP = 'policy-request-sign-overlay-board' +const COMPANY_GROUP = 'policy-request-sign-overlay-company' +const POLICY_KEY = 'groups_request_sign' +const SYSTEM_REQUEST_SIGN_BASELINE = JSON.stringify({ allowGroups: ['admin'], denyGroups: [] }) + +/** + * Removes a group-scoped request-sign policy entry, tolerating missing rows. + * + * @param ctx Authenticated API request context. + * @param groupId Group identifier whose rule should be cleared. + * @param acceptedStatuses HTTP statuses accepted for cleanup. + */ +async function clearGroupPolicyEntry( + ctx: APIRequestContext, + groupId: string, + acceptedStatuses: number[] = [200, 404], +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/group/${groupId}/${POLICY_KEY}`) + expect( + acceptedStatuses, + `clearGroupPolicyEntry(${groupId}): expected ${acceptedStatuses.join(' or ')} but got ${response.httpStatus}`, + ).toContain(response.httpStatus) +} + +/** + * Waits for a single policy API request triggered by the provided UI action. + * + * @param page Active Playwright page. + * @param method Expected HTTP method. + * @param urlPart URL fragment that identifies the request. + * @param action UI action that should trigger the request. + */ +async function waitForPolicyRequest( + page: Page, + method: 'PUT' | 'DELETE', + urlPart: string, + action: () => Promise, +): Promise { + const requestPromise = page.waitForRequest((request) => { + return request.method() === method && request.url().includes(urlPart) + }) + + await action() + await requestPromise +} + +/** + * Locates a policy table row by the visible target label. + * + * @param dialog Policy dialog containing the rules table. + * @param targetLabel Visible target label to match in the table. + */ +function getRuleRow(dialog: Locator, targetLabel: string): Locator { + return dialog.locator('tbody tr').filter({ hasText: targetLabel }).first() +} + +/** + * Clicks a visible row-menu action when it is available in the floating menu. + * + * @param page Active Playwright page. + * @param actionName Supported action label to click. + */ +async function clickVisibleRuleMenuAction(page: Page, actionName: 'Remove'): Promise { + const actionPattern = actionName === 'Remove' + ? /^(Remove|Delete)$/i + : /^Edit$/i + + const actionItem = page + .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible') + .filter({ hasText: actionPattern }) + .first() + + const isVisible = await actionItem.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false) + if (!isVisible) { + return false + } + + await actionItem.scrollIntoViewIfNeeded().catch(() => {}) + + const clickedNormally = await actionItem.click({ timeout: 1500 }).then(() => true).catch(() => false) + if (clickedNormally) { + return true + } + + return actionItem.click({ timeout: 1500, force: true }).then(() => true).catch(() => false) +} + +/** + * Removes a visible rule row and confirms the deletion dialog when needed. + * + * @param dialog Policy dialog containing the visible rule row. + * @param targetLabel Visible target label to remove. + */ +async function removeVisibleRule(dialog: Locator, targetLabel: string): Promise { + const page = dialog.page() + + for (let attempt = 0; attempt < 3; attempt += 1) { + const row = getRuleRow(dialog, targetLabel) + await expect(row).toBeVisible({ timeout: 10000 }) + await row.getByRole('button', { name: 'Rule actions' }).first().click() + + if (await clickVisibleRuleMenuAction(page, 'Remove')) { + const confirmDialog = page.getByRole('dialog', { name: /Confirm rule removal/i }).last() + if (await confirmDialog.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirmDialog.getByRole('button', { name: /Remove exception|Remove rule/i }).click() + } else { + const removeExceptionButton = page.getByRole('button', { name: /Remove exception|Remove rule/i }).first() + if (await removeExceptionButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await removeExceptionButton.click() + } else { + await page.getByText(/^Remove exception$/i).first().click() + } + } + + await expect(dialog.locator('tbody tr').filter({ hasText: targetLabel })).toHaveCount(0, { timeout: 10000 }) + return + } + + await page.waitForTimeout(200) + } + + expect(false, `Expected Remove action to be visible for rule ${targetLabel}`).toBe(true) +} + +/** + * Escapes a string so it can be embedded safely inside a RegExp. + * + * @param value Raw string value to escape. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getSearchInput(scope: Locator): Locator { + return scope.locator('input[type="search"], input[type="text"], input:not([type]), textarea, [contenteditable="true"]').first() +} + +/** + * Selects an option in a Nextcloud multi-select field by its visible label. + * + * @param scope Locator scoping the editor dialog. + * @param label Visible field label. + * @param target Option label to select. + */ +async function selectMultiSelectOption(scope: Locator, label: string, target: string): Promise { + const page = scope.page() + const combobox = scope.getByRole('combobox', { name: new RegExp(label, 'i') }).first() + const textAnchor = scope.getByText(new RegExp(`^${label}$`, 'i')).first() + const anchoredCombobox = textAnchor.locator('xpath=following::*[@role="combobox"][1]').first() + const targetInput = await combobox.count() ? combobox : anchoredCombobox + const fieldScope = targetInput.locator('xpath=ancestor-or-self::*[@role="combobox"][1]').first() + const selectedTarget = scope.getByRole('button', { + name: new RegExp(`Deselect\\s+${escapeRegExp(target)}`, 'i'), + }).first() + const selectedTargetText = scope.getByText(new RegExp(escapeRegExp(target), 'i')).first() + + const isSelectionConfirmed = async () => { + if (await selectedTarget.isVisible({ timeout: 1000 }).catch(() => false)) { + return true + } + + if (await selectedTargetText.isVisible({ timeout: 1000 }).catch(() => false)) { + return true + } + + return false + } + + await expect(targetInput).toBeVisible({ timeout: 10000 }) + if (await isSelectionConfirmed()) { + return + } + + await targetInput.click() + + const searchInput = getSearchInput(fieldScope) + if (await searchInput.isVisible({ timeout: 1000 }).catch(() => false)) { + await searchInput.fill(target) + + const matchingOption = page.getByRole('option', { name: new RegExp(target, 'i') }).first() + const matchingVisible = await matchingOption.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false) + if (matchingVisible) { + await matchingOption.click() + } else { + const floatingOption = page.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li').filter({ hasText: new RegExp(target, 'i') }).first() + await expect(floatingOption).toBeVisible({ timeout: 5000 }) + await floatingOption.click() + } + + await searchInput.press('Tab').catch(() => {}) + } else { + await page.keyboard.type(target) + const matchingOption = page.getByRole('option', { name: new RegExp(target, 'i') }).first() + if (await matchingOption.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false)) { + await matchingOption.click() + } else { + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + } + await page.keyboard.press('Tab').catch(() => {}) + } + + await expect.poll(isSelectionConfirmed, { timeout: 5000 }).toBe(true) +} + +/** + * Opens the policy dialog for the signature-request access setting. + * + * @param page Active Playwright page. + */ +async function openSignatureRequestAccessDialog(page: Page): Promise { + await page.goto('./apps/libresign/f/preferences') + await expect(page).toHaveURL(/\/apps\/libresign\/f\/preferences/, { timeout: 20000 }) + + await expandSettingsMenu(page) + + const policiesNavItem = page.locator('a[href*="/apps/libresign/f/policies"]').first() + if (await policiesNavItem.isVisible().catch(() => false)) { + await policiesNavItem.click() + } else { + await page.goto('./apps/libresign/f/policies') + } + + await expect(page).toHaveURL(/\/apps\/libresign\/f\/policies/, { timeout: 20000 }) + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + if (await searchField.isVisible({ timeout: 5000 }).catch(() => false)) { + await searchField.fill('Signature request access') + } + + const settingCard = page.locator('article').filter({ hasText: /Signature request access/i }).first() + const configureButton = await settingCard.count() + ? settingCard.getByRole('button', { name: /^Configure(?: setting)?$/i }).first() + : page.getByRole('button', { name: /^Configure(?: setting)?$/i }).first() + await expect(configureButton).toBeVisible({ timeout: 15000 }) + await configureButton.click() + + const dialog = page.locator('div[role="dialog"]').filter({ + has: page.getByRole('button', { name: /^Create rule$/i }), + }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + return dialog +} + +/** + * Opens the group-rule editor, supporting both the direct-create shortcut and the scope chooser flow. + * + * @param page Active Playwright page. + * @param settingDialog Policy dialog containing the create-rule action. + */ +async function openGroupCreateRuleDialog(page: Page, settingDialog: Locator): Promise { + await settingDialog.getByRole('button', { name: /^Create rule$/i }).click() + + const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() + if (await createScopeDialog.isVisible({ timeout: 1500 }).catch(() => false)) { + await createScopeDialog.getByRole('option', { name: /^Group/i }).click() + } + + const createRuleDialog = page.getByRole('dialog', { name: /Create rule/i }).last() + await expect(createRuleDialog).toBeVisible({ timeout: 10000 }) + return createRuleDialog +} + +test.beforeEach(async ({ adminRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, SYSTEM_REQUEST_SIGN_BASELINE, true) + await clearGroupPolicyEntry(adminRequestContext, BOARD_GROUP).catch(() => {}) + await clearGroupPolicyEntry(adminRequestContext, COMPANY_GROUP).catch(() => {}) + await deleteUser(adminRequestContext, GROUP_ADMIN, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminRequestContext, BOARD_GROUP, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminRequestContext, COMPANY_GROUP, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + + await ensureUserExists(adminRequestContext, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(adminRequestContext, BOARD_GROUP) + await ensureGroupExists(adminRequestContext, COMPANY_GROUP) + await ensureUserInGroup(adminRequestContext, GROUP_ADMIN, BOARD_GROUP) + await ensureUserInGroup(adminRequestContext, GROUP_ADMIN, COMPANY_GROUP) + await ensureSubadminOfGroup(adminRequestContext, GROUP_ADMIN, BOARD_GROUP) + await ensureSubadminOfGroup(adminRequestContext, GROUP_ADMIN, COMPANY_GROUP) + await setUserLanguage(adminRequestContext, GROUP_ADMIN, 'en') + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setGroupPolicyEntry( + adminRequestContext, + BOARD_GROUP, + POLICY_KEY, + JSON.stringify({ allowGroups: [BOARD_GROUP], denyGroups: [] }), + true, + ) +}) + +test.afterEach(async ({ adminRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, null, true).catch(() => {}) + await clearGroupPolicyEntry(adminRequestContext, BOARD_GROUP).catch(() => {}) + await clearGroupPolicyEntry(adminRequestContext, COMPANY_GROUP).catch(() => {}) + await deleteUser(adminRequestContext, GROUP_ADMIN, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminRequestContext, BOARD_GROUP, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminRequestContext, COMPANY_GROUP, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) +}) + +test('delegated group admin can keep a sibling allow while denying a hidden request-sign seed', async ({ + page, + adminRequestContext, + groupAdminRequestContext, +}) => { + await login(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + + const settingDialog = await openSignatureRequestAccessDialog(page) + await expect(settingDialog.getByText(BOARD_GROUP, { exact: false })).toHaveCount(0) + + const createRuleDialog = await openGroupCreateRuleDialog(page, settingDialog) + + await expect(createRuleDialog.getByText('Scope groups')).toHaveCount(0) + + await expect(createRuleDialog.getByText('Denied requester groups')).toBeVisible({ timeout: 10000 }) + await expect(createRuleDialog.getByText('Authorized requester groups')).toBeVisible({ timeout: 10000 }) + + const submitButton = createRuleDialog.getByRole('button', { name: /Create rule|Save changes/i }).last() + await expect(submitButton).toBeDisabled() + + await selectMultiSelectOption(createRuleDialog, 'Authorized requester groups', COMPANY_GROUP) + await expect(submitButton).toBeEnabled() + + await selectMultiSelectOption(createRuleDialog, 'Denied requester groups', BOARD_GROUP) + await expect(submitButton).toBeEnabled() + + await waitForPolicyRequest( + page, + 'PUT', + `/apps/libresign/api/v1/policies/group/${COMPANY_GROUP}/${POLICY_KEY}`, + async () => { + await submitButton.click() + }, + ) + + const companyPolicyAfterCreate = await policyRequest( + groupAdminRequestContext, + 'GET', + `/apps/libresign/api/v1/policies/group/${COMPANY_GROUP}/${POLICY_KEY}`, + ) + expect(companyPolicyAfterCreate.httpStatus).toBe(200) + expect((companyPolicyAfterCreate.data.policy as { value?: string } | undefined)?.value).toBe( + JSON.stringify({ allowGroups: [COMPANY_GROUP], denyGroups: [] }), + ) + + const effectivePolicyAfterCreate = await getEffectivePolicy(groupAdminRequestContext, POLICY_KEY) + expect(effectivePolicyAfterCreate?.sourceScope).toBe('group') + expect(effectivePolicyAfterCreate?.editableByCurrentActor).toBe(true) + + const boardPolicyAfterCreate = await policyRequest( + adminRequestContext, + 'GET', + `/apps/libresign/api/v1/policies/group/${BOARD_GROUP}/${POLICY_KEY}`, + ) + expect(boardPolicyAfterCreate.httpStatus).toBe(200) + expect((boardPolicyAfterCreate.data.policy as { value?: string } | undefined)?.value).toBe( + JSON.stringify({ allowGroups: [BOARD_GROUP], denyGroups: [BOARD_GROUP] }), + ) + + const groupAdminBoardPolicyAfterCreate = await policyRequest( + groupAdminRequestContext, + 'GET', + `/apps/libresign/api/v1/policies/group/${BOARD_GROUP}/${POLICY_KEY}`, + ) + expect(groupAdminBoardPolicyAfterCreate.httpStatus).toBe(200) + expect((groupAdminBoardPolicyAfterCreate.data.policy as { value?: string } | undefined)?.value).toBe( + JSON.stringify({ allowGroups: [BOARD_GROUP], denyGroups: [BOARD_GROUP] }), + ) + + await expect(getRuleRow(settingDialog, BOARD_GROUP)).toBeVisible({ timeout: 10000 }) + await expect(getRuleRow(settingDialog, COMPANY_GROUP)).toBeVisible({ timeout: 10000 }) + + // Wait for the workbench to finish any in-progress saves before interacting with rule actions + await page.locator('[aria-busy="true"]').first().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}) + + await waitForPolicyRequest( + page, + 'DELETE', + `/apps/libresign/api/v1/policies/group/${BOARD_GROUP}/${POLICY_KEY}`, + async () => { + await removeVisibleRule(settingDialog, BOARD_GROUP) + }, + ) + + const boardPolicyAfterDelete = await policyRequest( + adminRequestContext, + 'GET', + `/apps/libresign/api/v1/policies/group/${BOARD_GROUP}/${POLICY_KEY}`, + ) + expect(boardPolicyAfterDelete.httpStatus).toBe(200) + expect((boardPolicyAfterDelete.data.policy as { value?: string } | undefined)?.value).toBe( + JSON.stringify({ allowGroups: [BOARD_GROUP], denyGroups: [] }), + ) + + const companyPolicyAfterDelete = await policyRequest( + adminRequestContext, + 'GET', + `/apps/libresign/api/v1/policies/group/${COMPANY_GROUP}/${POLICY_KEY}`, + ) + expect(companyPolicyAfterDelete.httpStatus).toBe(200) + expect((companyPolicyAfterDelete.data.policy as { value?: string } | undefined)?.value).toBe( + JSON.stringify({ allowGroups: [COMPANY_GROUP], denyGroups: [] }), + ) + + const effectivePolicyAfterDelete = await getEffectivePolicy(groupAdminRequestContext, POLICY_KEY) + expect(effectivePolicyAfterDelete?.sourceScope).toBe('group') + expect(effectivePolicyAfterDelete?.editableByCurrentActor).toBe(true) + + await expect(settingDialog.locator('tbody tr').filter({ hasText: BOARD_GROUP })).toHaveCount(0, { timeout: 10000 }) + await expect(getRuleRow(settingDialog, COMPANY_GROUP)).toBeVisible({ timeout: 10000 }) + + const groupAdminBoardPolicyAfterDelete = await policyRequest( + groupAdminRequestContext, + 'GET', + `/apps/libresign/api/v1/policies/group/${BOARD_GROUP}/${POLICY_KEY}`, + ) + expect(groupAdminBoardPolicyAfterDelete.httpStatus).toBe(403) +}) diff --git a/playwright/e2e/policy-settings-menu-visibility.spec.ts b/playwright/e2e/policy-settings-menu-visibility.spec.ts new file mode 100644 index 0000000000..b7c5381dce --- /dev/null +++ b/playwright/e2e/policy-settings-menu-visibility.spec.ts @@ -0,0 +1,165 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Scenario: Policies menu visibility follows delegated customization capability. + * + * 1. (API) Instance admin enables allowChildOverride on system policy. + * 2. (API) No group rule exists yet. + * 3. (Browser) Log in as group admin → "Policies" nav item must be visible. + * 4. (Browser) Navigate to Policies → editable policy card must be visible. + * 5. (Browser) Click "Configure" → setting dialog opens. + * 6. (Browser) Click "Create rule" inside dialog → scope-selector dialog opens. + * 7. (Browser) Group admin can start creating a delegated rule. + * + * All admin-side operations are performed via the OCS API so no admin browser + * session is needed, keeping the test as fast as possible. + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' + +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + deleteGroup, + deleteUser, + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setSystemPolicy, + setUserLanguage, +} from '../support/nc-provisioning' +import { + createAuthenticatedRequestContext, + getEffectivePolicy, + getSystemPolicySnapshot, + restoreSystemPolicySnapshot, + setSystemPolicyEntry, + type SystemPolicySnapshot, +} from '../support/policy-api' + +// One serial block: a single browser session for the group admin +// across both phases avoids repeated login overhead. +const test = base.extend<{ + adminRequestContext: APIRequestContext + groupAdminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({ browserName }, use) => { + if (!browserName) { + throw new Error('Missing browserName fixture') + } + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({ browserName }, use) => { + if (!browserName) { + throw new Error('Missing browserName fixture') + } + const ctx = await createAuthenticatedRequestContext(GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.NEXTCLOUD_ADMIN_PASSWORD || 'admin' +const GROUP_ADMIN_PASSWORD = '123456' + +const GROUP_ID = 'policy-menu-visibility-group' +const GROUP_ADMIN = 'policy-menu-visibility-admin' + +const POLICY_KEY = 'add_footer' +const REQUEST_SIGN_GROUPS = JSON.stringify({ allowGroups: ['admin', GROUP_ID], denyGroups: [] }) +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) + +let originalFooterPolicy: SystemPolicySnapshot | null = null +let originalRequestSignGroupsPolicy: SystemPolicySnapshot | null = null + +test.beforeEach(async ({ adminRequestContext }) => { + await deleteUser(adminRequestContext, GROUP_ADMIN, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminRequestContext, GROUP_ID, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + + originalFooterPolicy = await getSystemPolicySnapshot(adminRequestContext, POLICY_KEY) + originalRequestSignGroupsPolicy = await getSystemPolicySnapshot(adminRequestContext, 'groups_request_sign') + + await ensureUserExists(adminRequestContext, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(adminRequestContext, GROUP_ID) + await ensureUserInGroup(adminRequestContext, GROUP_ADMIN, GROUP_ID) + await ensureSubadminOfGroup(adminRequestContext, GROUP_ADMIN, GROUP_ID) + await setUserLanguage(adminRequestContext, GROUP_ADMIN, 'en') + await setSystemPolicy(adminRequestContext, 'groups_request_sign', REQUEST_SIGN_GROUPS) +}) + +test.afterEach(async ({ adminRequestContext }) => { + if (originalFooterPolicy) { + await restoreSystemPolicySnapshot(adminRequestContext, POLICY_KEY, originalFooterPolicy) + } + if (originalRequestSignGroupsPolicy) { + await restoreSystemPolicySnapshot(adminRequestContext, 'groups_request_sign', originalRequestSignGroupsPolicy) + } + await deleteUser(adminRequestContext, GROUP_ADMIN, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminRequestContext, GROUP_ID, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + originalFooterPolicy = null + originalRequestSignGroupsPolicy = null +}) + +test('group admin can access policies and start creating a delegated rule', async ({ page, adminRequestContext, groupAdminRequestContext }) => { + // ── 1. Admin: enable delegated customization at system layer ─────────── + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, FOOTER_ENABLED_VALUE, true) + + const editablePolicy = await getEffectivePolicy(groupAdminRequestContext, POLICY_KEY) + expect(editablePolicy?.editableByCurrentActor).toBe(true) + + // ── 2. Log in as group admin ─────────────────────────────────────────── + await login(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + + // ── 3. Access the Policies page (via nav item when present, fallback direct route) ── + await expandSettingsMenu(page) + + const policiesNavItem = page.locator('a[href*="/apps/libresign/f/policies"]').first() + if (await policiesNavItem.isVisible().catch(() => false)) { + await policiesNavItem.click() + } else { + await page.goto('./apps/libresign/f/policies') + } + await expect(page).toHaveURL(/\/f\/policies/, { timeout: 10000 }) + + // ── 4. The editable policy card must be visible in the workbench ────── + const configureButton = page + .getByRole('button', { name: /^Configure(?: setting)?$/i }) + .first() + await expect(configureButton, 'At least one Configure button should be visible for the group admin').toBeVisible({ timeout: 15000 }) + + // ── 5. Open the setting dialog ───────────────────────────────────────── + await configureButton.click() + + // Wait for any dialog to appear and look for the one with "Create rule" button + const allDialogs = page.locator('div[role="dialog"]') + await expect(allDialogs.first()).toBeVisible({ timeout: 10000 }) + + // Find the dialog that contains a "Create rule" button (which means it's the settings dialog) + const settingDialog = page.locator('div[role="dialog"]').filter({ + has: page.getByRole('button', { name: /^Create rule$/i }), + }) + await expect(settingDialog, 'Policy dialog with "Create rule" button should be visible').toBeVisible({ timeout: 10000 }) + + // ── 6. "Create rule" button must be enabled for delegated configuration ─ + const createRuleButton = settingDialog.getByRole('button', { name: /^Create rule$/i }) + await expect(createRuleButton, '"Create rule" button should be visible in the policy dialog').toBeVisible({ timeout: 10000 }) + await expect(createRuleButton).toBeEnabled() + await createRuleButton.click() + const createRuleDialog = page.getByRole('dialog', { name: /Create rule|What do you want to create\?/i }).last() + await expect(createRuleDialog).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/policy-workbench-add-footer-rule-management.spec.ts b/playwright/e2e/policy-workbench-add-footer-rule-management.spec.ts new file mode 100644 index 0000000000..dc617f2827 --- /dev/null +++ b/playwright/e2e/policy-workbench-add-footer-rule-management.spec.ts @@ -0,0 +1,87 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Gate B (Playwright UX persistence) proof for P07: add_footer + * Tests that footer template settings persist through API save + page reload. + */ + +import { expect, test } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { login } from '../support/nc-login' +import { openPolicyWorkbenchSystemRuleEditor, waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' + +const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +test.describe('P07: add_footer persists a system rule from the workbench UI', () => { + test('allows creating and persisting a system rule from workbench UI', async ({ page }) => { + // Bootstrap: ensure admin context + await login(page.request, adminUser, adminPassword) + await bootstrapLibreSignAdmin(page) + + // Navigate to policy workbench + await page.goto('./settings/admin/libresign', { waitUntil: 'domcontentloaded' }) + await waitForPolicyWorkbenchIdle(page) + + // Ensure the policy card is visible + const card = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await card.click() + + // Open the rule editor + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + const ruleDialog = await openPolicyWorkbenchSystemRuleEditor(dialog) + + // Wait for the workbench editor to be ready + await waitForPolicyWorkbenchIdle(page) + + // The footer policy has a template field and visibility options + // Locate and update the footer template text input + const templateInput = ruleDialog.locator('textarea[placeholder*="footer"], input[data-testid*="footer"]').first() + if (await templateInput.isVisible()) { + await templateInput.fill('Test Footer {{SignerCommonName}} - {{Date}}') + await page.waitForTimeout(500) + } + const validationUrlInput = ruleDialog.getByPlaceholder('Validation URL').first() + if (await validationUrlInput.isVisible().catch(() => false)) { + await validationUrlInput.fill('https://example.invalid/validate') + } + + // Save the change via the Save button + const saveButton = ruleDialog.getByRole('button', { name: /Save|Create rule|Save changes/i }).first() + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + const [response] = await Promise.all([ + page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/add_footer') + }), + saveButton.click(), + ]) + expect(response.status()).toBe(200) + + // Wait for save confirmation (network idle or success toast) + await waitForPolicyWorkbenchIdle(page) + await page.waitForTimeout(1000) + + // Close the dialog/editor + const closeButton = ruleDialog.locator('button[aria-label="Close"]').first() + if (await closeButton.isVisible()) { + await closeButton.click() + } + + // Reload the page to verify persistence + await page.reload() + await waitForPolicyWorkbenchIdle(page) + + // Re-open the same policy to verify the value persisted + const cardReopen = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await cardReopen.click() + + // Verify that the dialog opens (indicating persistence was successful) + const dialogReopen = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialogReopen).toBeVisible({ timeout: 10000 }) + }) +}) diff --git a/playwright/e2e/policy-workbench-boolean-settings.spec.ts b/playwright/e2e/policy-workbench-boolean-settings.spec.ts new file mode 100644 index 0000000000..e5cd4b6cc7 --- /dev/null +++ b/playwright/e2e/policy-workbench-boolean-settings.spec.ts @@ -0,0 +1,130 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { + createAuthenticatedRequestContext, + getEffectivePolicy, + getSystemPolicySnapshot, + policyRequest, + restoreSystemPolicySnapshot, + setSystemPolicyEntry, + type SystemPolicySnapshot, +} from '../support/policy-api' + +type BooleanWorkbenchSetting = { + policyKey: 'envelope_enabled' | 'crl_external_validation_enabled' | 'show_confetti_after_signing' + title: string +} + +const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +const booleanSettings: BooleanWorkbenchSetting[] = [ + { policyKey: 'envelope_enabled', title: 'Signing envelopes' }, + { policyKey: 'crl_external_validation_enabled', title: 'External CRL validation' }, + { policyKey: 'show_confetti_after_signing', title: 'Confetti animation' }, +] + +let adminContext: Awaited> | null = null +let originalSnapshots: Partial> = {} + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +test.afterEach(async () => { + if (!adminContext) { + return + } + + for (const setting of booleanSettings) { + await clearAdminOverrides(adminContext, setting.policyKey) + const snapshot = originalSnapshots[setting.policyKey] + if (snapshot) { + await restoreSystemPolicySnapshot(adminContext, setting.policyKey, snapshot) + } + } + await adminContext.dispose() + adminContext = null + originalSnapshots = {} +}) + +test('boolean settings stay consistent between effective policy and admin initial state', async ({ page }) => { + adminContext = await createAuthenticatedRequestContext(adminUser, adminPassword) + const ctx = adminContext + originalSnapshots = Object.fromEntries(await Promise.all(booleanSettings.map(async (setting) => { + return [setting.policyKey, await getSystemPolicySnapshot(ctx, setting.policyKey)] + }))) as Partial> + + await login(page.request, adminUser, adminPassword) + await page.goto('./settings/admin/libresign') + + for (const setting of booleanSettings) { + await clearAdminOverrides(ctx, setting.policyKey) + await setSystemPolicyEntry(ctx, setting.policyKey, JSON.stringify(false), true) + await page.reload() + + const effectiveDisabled = await getEffectivePolicy(ctx, setting.policyKey) + expect(effectiveDisabled).not.toBeNull() + expect(effectiveDisabled?.effectiveValue).toBe(false) + const initialStateDisabled = await getAdminInitialStateValue(page, setting.policyKey) + if (initialStateDisabled !== null) { + expect(initialStateDisabled).toBe(false) + } + + await setSystemPolicyEntry(ctx, setting.policyKey, JSON.stringify(true), true) + await page.reload() + + const effectiveEnabled = await getEffectivePolicy(ctx, setting.policyKey) + expect(effectiveEnabled).not.toBeNull() + expect(effectiveEnabled?.effectiveValue).toBe(true) + const initialStateEnabled = await getAdminInitialStateValue(page, setting.policyKey) + if (initialStateEnabled !== null) { + expect(initialStateEnabled).toBe(true) + } + } +}) + +/** + * Removes admin-scoped user and group overrides for a policy key. + * + * @param ctx Authenticated admin request context + * @param policyKey Policy key to clear + */ +async function clearAdminOverrides( + ctx: Awaited>, + policyKey: BooleanWorkbenchSetting['policyKey'], +): Promise { + await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/user/admin/${policyKey}`) + await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/group/admin/${policyKey}`) +} + +/** + * Reads the current admin initial state value from the browser runtime. + * + * @param page Playwright browser page + * @param stateKey Initial state key to load + */ +async function getAdminInitialStateValue( + page: Page, + stateKey: BooleanWorkbenchSetting['policyKey'], +): Promise { + return page.evaluate((key) => { + const loadStateFn = (window as typeof window & { + OCP?: { + InitialState?: { + loadState: (app: string, state: string, fallback: boolean | null) => boolean | null + } + } + }).OCP?.InitialState?.loadState + + if (!loadStateFn) { + return null + } + + return loadStateFn('libresign', key, null) + }, stateKey) +} diff --git a/playwright/e2e/policy-workbench-catalog-controls.spec.ts b/playwright/e2e/policy-workbench-catalog-controls.spec.ts new file mode 100644 index 0000000000..dc2a025142 --- /dev/null +++ b/playwright/e2e/policy-workbench-catalog-controls.spec.ts @@ -0,0 +1,551 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { setUserLanguage } from '../support/nc-provisioning' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +function collectJavascriptErrors(page: Page) { + const issues: string[] = [] + + page.on('console', (message) => { + if (message.type() !== 'error') { + return + } + + const text = message.text().trim() + if (!text) { + return + } + + if (text.includes('/img/app-dark.svg') && text.includes('Content Security Policy directive')) { + return + } + + if (text.includes('/core/img/actions/error.svg') && text.includes('Content Security Policy directive')) { + return + } + + if (text.startsWith('Failed to load resource:')) { + return + } + + issues.push(`console.error: ${text}`) + }) + + page.on('pageerror', (error) => { + const message = error.message.trim() + issues.push(`pageerror: ${message}`) + }) + + return { + clear() { + issues.length = 0 + }, + all() { + return [...issues] + }, + } +} + +async function getCatalogCollapseButton(page: Page) { + return page.getByRole('button', { + name: /Collapse settings categories|Expand settings categories/i, + }).first() +} + +async function getCatalogViewButton(page: Page) { + return page.getByRole('button', { + name: /Switch to compact view|Switch to card view/i, + }).first() +} + +async function waitForUserConfigSave(page: Page, key: string) { + return page.waitForResponse((response) => { + return response.request().method() === 'PUT' + && response.url().includes(`/apps/libresign/api/v1/account/config/${key}`) + && response.ok() + }) +} + +async function getPrimaryScrollTop(page: Page) { + return page.evaluate(() => { + const appContent = document.querySelector('#app-content') + if (appContent instanceof HTMLElement && appContent.scrollHeight > (appContent.clientHeight + 1)) { + return appContent.scrollTop + } + + const toolbar = document.querySelector('.policy-workbench__catalog-search') + let current = toolbar instanceof HTMLElement ? toolbar.parentElement : null + while (current) { + const styles = window.getComputedStyle(current) + const isScrollable = (styles.overflowY === 'auto' || styles.overflowY === 'scroll') + && current.scrollHeight > (current.clientHeight + 1) + if (isScrollable) { + return current.scrollTop + } + + current = current.parentElement + } + + const root = document.scrollingElement as HTMLElement | null + return window.scrollY || root?.scrollTop || 0 + }) +} + +async function scrollAppContentToRatio(page: Page, ratio: number) { + const getMaxScrollable = async () => { + return page.evaluate(() => { + const appContent = document.querySelector('#app-content') + if (appContent instanceof HTMLElement && appContent.scrollHeight > (appContent.clientHeight + 1)) { + return Math.max(0, appContent.scrollHeight - appContent.clientHeight) + } + + const toolbar = document.querySelector('.policy-workbench__catalog-search') + let current = toolbar instanceof HTMLElement ? toolbar.parentElement : null + while (current) { + const styles = window.getComputedStyle(current) + const isScrollable = (styles.overflowY === 'auto' || styles.overflowY === 'scroll') + && current.scrollHeight > (current.clientHeight + 1) + if (isScrollable) { + return Math.max(0, current.scrollHeight - current.clientHeight) + } + + current = current.parentElement + } + + const root = document.scrollingElement as HTMLElement | null + const rootScrollHeight = root?.scrollHeight ?? document.documentElement.scrollHeight + return Math.max(0, rootScrollHeight - window.innerHeight) + }) + } + +await expect.poll(getMaxScrollable, { timeout: 20000, intervals: [500] }).toBeGreaterThan(400) + + const scrollTarget = await page.evaluate((value) => { + const appContent = document.querySelector('#app-content') + if (appContent instanceof HTMLElement && appContent.scrollHeight > (appContent.clientHeight + 1)) { + const maxScroll = Math.max(0, appContent.scrollHeight - appContent.clientHeight) + const target = Math.round(maxScroll * value) + appContent.scrollTo({ top: target, behavior: 'auto' }) + appContent.dispatchEvent(new Event('scroll')) + return target + } + + const toolbar = document.querySelector('.policy-workbench__catalog-search') + let current = toolbar instanceof HTMLElement ? toolbar.parentElement : null + while (current) { + const styles = window.getComputedStyle(current) + const isScrollable = (styles.overflowY === 'auto' || styles.overflowY === 'scroll') + && current.scrollHeight > (current.clientHeight + 1) + if (isScrollable) { + const maxScroll = Math.max(0, current.scrollHeight - current.clientHeight) + const target = Math.round(maxScroll * value) + current.scrollTo({ top: target, behavior: 'auto' }) + current.dispatchEvent(new Event('scroll')) + return target + } + + current = current.parentElement + } + + const root = document.scrollingElement as HTMLElement | null + const rootScrollHeight = root?.scrollHeight ?? document.documentElement.scrollHeight + const maxScroll = Math.max(0, rootScrollHeight - window.innerHeight) + const target = Math.round(maxScroll * value) + window.scrollTo({ top: target, behavior: 'auto' }) + window.dispatchEvent(new Event('scroll')) + return target + }, ratio) + + return { scrollTarget } +} + +test('catalog controls keep behavior, layout, and JS health', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const categoryToggles = page.locator('.policy-workbench__category-toggle') + await expect(categoryToggles.first()).toBeVisible({ timeout: 20000 }) + await expect(categoryToggles).toHaveCount(7) + + const workbenchSection = page.locator('.policy-workbench__section').first() + await expect(workbenchSection).toBeVisible({ timeout: 20000 }) + + // Ignore potential startup noise and only validate errors introduced by user interactions. + errorCollector.clear() + + const collapseButton = await getCatalogCollapseButton(page) + const initialCollapseLabel = await collapseButton.getAttribute('aria-label') + if (initialCollapseLabel && /Expand settings categories/i.test(initialCollapseLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + + const viewButton = await getCatalogViewButton(page) + const initialViewLabel = await viewButton.getAttribute('aria-label') + if (initialViewLabel && /Switch to card view/i.test(initialViewLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + } + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + await expect(workbenchSection).toBeVisible() + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + await expect(viewButton).toHaveAttribute('aria-label', /Switch to card view/i) + await expect(workbenchSection).toBeVisible() + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + + expect(errorCollector.all(), 'No JavaScript errors should happen during collapse/expand and view switching').toEqual([]) +}) + +test('chip navigation expands target category when catalog is collapsed', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + const initialCollapseLabel = await collapseButton.getAttribute('aria-label') + if (initialCollapseLabel && /Expand settings categories/i.test(initialCollapseLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + const targetSectionToggle = page.locator('#policy-category-how-signing-works .policy-workbench__category-toggle').first() + const targetSection = page.locator('#policy-category-how-signing-works') + const initialSectionY = await targetSection.boundingBox().then((box) => box?.y ?? Number.POSITIVE_INFINITY) + await expect(targetSectionToggle).toHaveAttribute('aria-expanded', 'false') + + const targetChip = page.getByRole('button', { name: /Go to How signing works/i }).first() + await expect(targetChip).toBeVisible({ timeout: 20000 }) + await targetChip.click() + + await expect(targetSectionToggle).toHaveAttribute('aria-expanded', 'true') + + await expect.poll(async () => { + const box = await targetSection.boundingBox() + return box?.y ?? Number.POSITIVE_INFINITY + }, { timeout: 10000 }).toBeLessThan(initialSectionY) + + expect(errorCollector.all(), 'No JavaScript errors should happen during chip navigation').toEqual([]) +}) + +test('catalog collapse and per-category expanded state persist after reload', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + const initialCollapseLabel = await collapseButton.getAttribute('aria-label') + if (initialCollapseLabel && /Expand settings categories/i.test(initialCollapseLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + await page.reload() + await expect(searchField).toBeVisible({ timeout: 20000 }) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + const signingWorksToggle = page.locator('#policy-category-how-signing-works .policy-workbench__category-toggle').first() + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'false') + + const signerSeesToggle = page.locator('#policy-category-signer-experience .policy-workbench__category-toggle').first() + await expect(signerSeesToggle).toHaveAttribute('aria-expanded', 'false') + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_category_collapsed_state'), + signingWorksToggle.click(), + ]) + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'true') + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + + await page.reload() + await expect(searchField).toBeVisible({ timeout: 20000 }) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'true') + await expect(signerSeesToggle).toHaveAttribute('aria-expanded', 'false') + + expect(errorCollector.all(), 'No JavaScript errors should happen while persisting catalog state').toEqual([]) +}) + +test('search temporarily expands result sections without persisting section state', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + if (/Collapse settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + } + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + const collapsedStateSaves: string[] = [] + page.on('request', (request) => { + if (request.method() === 'PUT' && request.url().includes('/apps/libresign/api/v1/account/config/policy_workbench_category_collapsed_state')) { + collapsedStateSaves.push(request.url()) + } + }) + + await searchField.fill('signing') + + const signingWorksToggle = page.locator('#policy-category-how-signing-works .policy-workbench__category-toggle').first() + await expect(signingWorksToggle).toBeVisible({ timeout: 10000 }) + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'true') + + await page.waitForTimeout(400) + expect(collapsedStateSaves, 'Search-driven expansion must not persist section collapsed state').toHaveLength(0) + + await searchField.fill('') + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'false') + + expect(errorCollector.all(), 'No JavaScript errors should happen while showing filtered results').toEqual([]) +}) + +test('back to top returns to search toolbar instead of absolute page top', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + if (/Expand settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + } + + const viewButton = await getCatalogViewButton(page) + if (/Switch to card view/i.test((await viewButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + } + + const { scrollTarget } = await scrollAppContentToRatio(page, 0.75) + const minExpectedScroll = Math.max(40, Math.floor(scrollTarget * 0.5)) + + await expect.poll(async () => { + return getPrimaryScrollTop(page) + }, { timeout: 10000 }).toBeGreaterThan(minExpectedScroll) + + const backToTopButton = page.locator('.policy-workbench__back-to-top').first() + await expect(backToTopButton).toBeVisible({ timeout: 10000 }) + await backToTopButton.click() + + await expect(searchField).toBeFocused({ timeout: 10000 }) + + const afterScroll = await page.evaluate(() => { + const toolbar = document.querySelector('.policy-workbench__catalog-search') as HTMLElement | null + return { + toolbarTop: toolbar?.getBoundingClientRect().top ?? Number.POSITIVE_INFINITY, + } + }) + const containerScrollTop = await getPrimaryScrollTop(page) + + expect(containerScrollTop, 'Back-to-top should not jump to absolute page top').toBeGreaterThan(100) + expect(afterScroll.toolbarTop, 'Search toolbar should be brought near the top of the viewport').toBeLessThan(250) + + expect(errorCollector.all(), 'No JavaScript errors should happen while using back-to-top').toEqual([]) +}) + +test('active category chip tracks the section with visible cards while scrolling', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + if (/Expand settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + } + + const viewButton = await getCatalogViewButton(page) + if (/Switch to card view/i.test((await viewButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + } + + await scrollAppContentToRatio(page, 0.75) + + await expect.poll(async () => { + return getPrimaryScrollTop(page) + }, { timeout: 10000 }).toBeGreaterThan(400) + + await expect.poll(async () => { + return page.evaluate(() => { + const stickyNav = document.querySelector('.policy-workbench__category-nav-sticky') as HTMLElement | null + const appContent = document.querySelector('#app-content') as HTMLElement | null + const topCutoff = (stickyNav?.getBoundingClientRect().bottom ?? 140) + 12 + const bottomCutoff = appContent?.getBoundingClientRect().bottom ?? window.innerHeight + + const sectionWithVisibleCard = Array.from(document.querySelectorAll('.policy-workbench__category-section')).find((section) => { + const cards = Array.from(section.querySelectorAll('.policy-workbench__setting-tile, .policy-workbench__settings-row')) + return cards.some((card) => { + const rect = card.getBoundingClientRect() + return rect.top >= topCutoff && rect.bottom <= bottomCutoff && rect.bottom > rect.top + }) + }) as HTMLElement | undefined + + const expectedSectionTitle = sectionWithVisibleCard?.querySelector('.policy-workbench__category-title')?.textContent?.trim() ?? null + const activeChipLabel = document.querySelector('.policy-workbench__category-chip--active')?.textContent?.trim() ?? null + + return { + expectedSectionTitle, + activeChipLabel, + hasFullyVisibleCard: Boolean(expectedSectionTitle), + isSynced: Boolean(expectedSectionTitle && activeChipLabel && expectedSectionTitle === activeChipLabel), + } + }) + }, { timeout: 10000 }).toMatchObject({ hasFullyVisibleCard: true, isSynced: true }) + + const syncResult = await page.evaluate(() => { + const stickyNav = document.querySelector('.policy-workbench__category-nav-sticky') as HTMLElement | null + const appContent = document.querySelector('#app-content') as HTMLElement | null + const topCutoff = (stickyNav?.getBoundingClientRect().bottom ?? 140) + 12 + const bottomCutoff = appContent?.getBoundingClientRect().bottom ?? window.innerHeight + + const sectionWithVisibleCard = Array.from(document.querySelectorAll('.policy-workbench__category-section')).find((section) => { + const cards = Array.from(section.querySelectorAll('.policy-workbench__setting-tile, .policy-workbench__settings-row')) + return cards.some((card) => { + const rect = card.getBoundingClientRect() + return rect.top >= topCutoff && rect.bottom <= bottomCutoff && rect.bottom > rect.top + }) + }) as HTMLElement | undefined + + const expectedSectionTitle = sectionWithVisibleCard?.querySelector('.policy-workbench__category-title')?.textContent?.trim() ?? null + const activeChipLabel = document.querySelector('.policy-workbench__category-chip--active')?.textContent?.trim() ?? null + + return { + expectedSectionTitle, + activeChipLabel, + } + }) + + expect(syncResult.expectedSectionTitle).not.toBeNull() + expect(syncResult.activeChipLabel).toBe(syncResult.expectedSectionTitle) + + expect(errorCollector.all(), 'No JavaScript errors should happen while syncing active chip on scroll').toEqual([]) +}) diff --git a/playwright/e2e/policy-workbench-collect-metadata-rule-management.spec.ts b/playwright/e2e/policy-workbench-collect-metadata-rule-management.spec.ts new file mode 100644 index 0000000000..8427b54b79 --- /dev/null +++ b/playwright/e2e/policy-workbench-collect-metadata-rule-management.spec.ts @@ -0,0 +1,62 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +test('collect_metadata allows creating and persisting a system rule from workbench UI', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + await page.goto('./settings/admin/libresign') + + const settingCard = await ensureCatalogSettingCardVisible(page, /Collect signer metadata/i, 'collect') + await settingCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Collect signer metadata/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + + const createRuleButton = page.getByRole('button', { name: /Create rule/i }).first() + await expect(createRuleButton).toBeVisible({ timeout: 10000 }) + await createRuleButton.click() + + const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() + if (await createScopeDialog.isVisible().catch(() => false)) { + await createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first().click() + } + + const createDialog = page.getByRole('dialog', { name: /Create rule/i }).last() + await expect(createDialog).toBeVisible({ timeout: 10000 }) + + const enableOption = createDialog.getByRole('radio', { name: /Collect signer metadata/i }).first() + if (await enableOption.isVisible().catch(() => false)) { + await enableOption.click({ force: true }) + await expect(enableOption).toBeChecked({ timeout: 5000 }) + } + + const saveResponse = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/collect_metadata') + }) + + const submitButton = createDialog.getByRole('button', { name: /Create rule|Save changes/i }).first() + await expect(submitButton).toBeEnabled({ timeout: 10000 }) + await submitButton.click() + + const response = await saveResponse + expect(response.status()).toBe(200) + + await waitForPolicyWorkbenchIdle(page) + await page.reload() + + const reopenedCard = await ensureCatalogSettingCardVisible(page, /Collect signer metadata/i, 'collect') + await reopenedCard.click() + const reopenedDialog = page.getByRole('dialog').filter({ hasText: /Collect signer metadata/i }).first() + await expect(reopenedDialog).toBeVisible({ timeout: 10000 }) + await expect(reopenedDialog.getByRole('button', { name: /^Change$/i }).first()).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/policy-workbench-default-user-folder-rule-management.spec.ts b/playwright/e2e/policy-workbench-default-user-folder-rule-management.spec.ts new file mode 100644 index 0000000000..172ee5e3b5 --- /dev/null +++ b/playwright/e2e/policy-workbench-default-user-folder-rule-management.spec.ts @@ -0,0 +1,72 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { clearPolicyWorkbenchRules, waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +test('default_user_folder allows creating and persisting a system rule from workbench UI', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + await page.goto('./settings/admin/libresign') + + const settingCard = await ensureCatalogSettingCardVisible(page, /Customize default account folder/i, 'folder') + await settingCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Customize default account folder/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + + await clearPolicyWorkbenchRules(dialog, { maxRounds: 6 }) + await waitForPolicyWorkbenchIdle(page) + + const createRuleButton = page.getByRole('button', { name: /Create rule/i }).first() + await expect(createRuleButton).toBeVisible({ timeout: 10000 }) + await createRuleButton.click() + + const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() + if (await createScopeDialog.isVisible().catch(() => false)) { + await createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first().click() + } + + const createDialog = page.getByRole('dialog', { name: /Create rule/i }).last() + await expect(createDialog).toBeVisible({ timeout: 10000 }) + + const folderInput = createDialog.getByLabel(/Folder name/i).first() + if (!await folderInput.isVisible().catch(() => false)) { + const enableSwitch = createDialog.locator('.checkbox-radio-switch') + .filter({ hasText: /Customize default account folder/i }) + .first() + if (await enableSwitch.isVisible().catch(() => false)) { + await enableSwitch.locator('.checkbox-radio-switch__content').first().click() + } + } + await expect(folderInput).toBeVisible({ timeout: 10000 }) + await folderInput.fill('Policy Folder') + await folderInput.blur().catch(() => {}) + + const saveResponse = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/default_user_folder') + }) + + const submitButton = createDialog.getByRole('button', { name: /Create rule|Save changes/i }).first() + await expect(submitButton).toBeEnabled({ timeout: 10000 }) + await submitButton.click() + + const response = await saveResponse + expect(response.status()).toBe(200) + + await waitForPolicyWorkbenchIdle(page) + await page.reload() + + const reopenedCard = await ensureCatalogSettingCardVisible(page, /Customize default account folder/i, 'folder') + await reopenedCard.click() + const reopenedDialog = page.getByRole('dialog').filter({ hasText: /Customize default account folder/i }).first() + await expect(reopenedDialog).toBeVisible({ timeout: 10000 }) + await expect(reopenedDialog.getByRole('button', { name: /^Change$/i }).first()).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/policy-workbench-identification-documents-rule-management.spec.ts b/playwright/e2e/policy-workbench-identification-documents-rule-management.spec.ts new file mode 100644 index 0000000000..a0a229070f --- /dev/null +++ b/playwright/e2e/policy-workbench-identification-documents-rule-management.spec.ts @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +test('identification_documents allows creating and persisting a system rule from workbench UI', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + await page.goto('./settings/admin/libresign') + + const settingCard = await ensureCatalogSettingCardVisible(page, /Identification documents flow/i, 'identification') + await settingCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Identification documents flow/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + + const createRuleButton = page.getByRole('button', { name: /Create rule/i }).first() + await expect(createRuleButton).toBeVisible({ timeout: 10000 }) + await createRuleButton.click() + + const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() + if (await createScopeDialog.isVisible().catch(() => false)) { + await createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first().click() + } + + const createDialog = page.getByRole('dialog', { name: /Create rule/i }).last() + await expect(createDialog).toBeVisible({ timeout: 10000 }) + await createDialog.getByText('Enable identification documents flow', { exact: true }).first().click() + + const submitButton = createDialog.getByRole('button', { name: /Create rule|Save changes/i }).first() + await expect(submitButton).toBeEnabled({ timeout: 10000 }) + const [response] = await Promise.all([ + page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/identification_documents') + }), + submitButton.click(), + ]) + expect(response.status()).toBe(200) + + await waitForPolicyWorkbenchIdle(page) + await page.reload() + + const reopenedCard = await ensureCatalogSettingCardVisible(page, /Identification documents flow/i, 'identification') + await reopenedCard.click() + const reopenedDialog = page.getByRole('dialog').filter({ hasText: /Identification documents flow/i }).first() + await expect(reopenedDialog).toBeVisible({ timeout: 10000 }) + await expect(reopenedDialog.getByRole('button', { name: /^Change$/i }).first()).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/policy-workbench-identify-methods-rule-creation.spec.ts b/playwright/e2e/policy-workbench-identify-methods-rule-creation.spec.ts new file mode 100644 index 0000000000..4ca258517a --- /dev/null +++ b/playwright/e2e/policy-workbench-identify-methods-rule-creation.spec.ts @@ -0,0 +1,204 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { randomBytes } from 'node:crypto' + +import { expect, test } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' + +import { ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { login } from '../support/nc-login' +import { + deleteGroup, + deleteUser, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, + setSystemPolicy, + setUserLanguage, +} from '../support/nc-provisioning' +import { clearPolicyWorkbenchRules, waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const TEST_NAMESPACE = randomBytes(4).toString('hex') +const GROUP_ID = `libresign-identify-rule-group-${TEST_NAMESPACE}` +const USER_ID = `identifyruleuser-${TEST_NAMESPACE}` +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.NEXTCLOUD_ADMIN_PASSWORD || 'admin' + +test.afterEach(async ({ request }) => { + await deleteUser(request, USER_ID, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(request, GROUP_ID, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) +}) + +/** + * Opens the identification factors workbench dialog from the admin settings page. + * + * @param page Playwright page bound to the admin browser session. + */ +async function openIdentificationFactorsDialog(page: Page): Promise { + await page.goto('./settings/admin/libresign') + + const card = await ensureCatalogSettingCardVisible(page, /Identification factors/i, 'identification') + await card.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Identification factors/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await waitForPolicyWorkbenchIdle(page) + return dialog +} + +async function findVisibleEnabledButton(container: Locator, name: RegExp): Promise { + const buttons = container.getByRole('button', { name }) + const count = await buttons.count() + + for (let index = 0; index < count; index += 1) { + const button = buttons.nth(index) + const isVisible = await button.isVisible().catch(() => false) + if (!isVisible) { + continue + } + + const isEnabled = await button.isEnabled().catch(() => false) + if (isEnabled) { + return button + } + } + + return null +} + +async function waitForVisibleEnabledButton(container: Locator, name: RegExp, timeout = 10000): Promise { + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + const button = await findVisibleEnabledButton(container, name) + if (button) { + return button + } + + await waitForPolicyWorkbenchIdle(container.page()) + await container.page().waitForTimeout(200) + } + + return null +} + +/** + * Opens the rule editor for the requested scope inside the identification factors dialog. + * + * @param page Playwright page bound to the admin browser session. + * @param _dialog Existing workbench dialog locator kept for call-site symmetry. + * @param scope Scope to open in the rule editor. + */ +async function openScopeRuleEditor(page: Page, _dialog: Locator, scope: 'everyone' | 'group' | 'user'): Promise { + const activeDialog = await openIdentificationFactorsDialog(page) + + if (scope === 'everyone') { + const changeButton = activeDialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await changeButton.click() + } else { + const createRuleButton = await waitForVisibleEnabledButton(activeDialog, /Create rule|Create policy rule/i) + expect(createRuleButton).not.toBeNull() + await createRuleButton?.click() + const everyoneOption = page.locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible({ timeout: 3000 }).catch(() => false)) { + await everyoneOption.click() + } + } + } else { + const createRuleButton = await waitForVisibleEnabledButton(activeDialog, /Create rule|Create policy rule/i, 8000) + const canOpenCreateScope = createRuleButton !== null + + if (canOpenCreateScope) { + await createRuleButton?.click() + const targetOption = page.locator('[role="option"]').filter({ hasText: scope === 'group' ? /Group/i : /Account/i }).first() + await expect(targetOption).toBeVisible({ timeout: 5000 }) + await targetOption.click() + } else { + const scopeRow = activeDialog.locator('tbody tr').filter({ hasText: scope === 'group' ? /Group/i : /Account/i }).first() + await waitForPolicyWorkbenchIdle(page) + await expect(scopeRow).toBeVisible({ timeout: 10000 }) + await scopeRow.getByRole('button', { name: /^Change$/i }).first().click() + } + } + + const ruleDialog = page.getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 10000 }) + return ruleDialog +} + +/** + * Verifies the identification rule editor exposes the supported methods list. + * + * @param ruleDialog Active rule editor dialog locator. + */ +async function assertIdentifyMethodsAreAvailable(ruleDialog: Locator): Promise { + await expect(ruleDialog.getByText('No identification methods available.')).toHaveCount(0) + const factors = ruleDialog.locator('.identify-methods-editor__method') + await expect(factors.first()).toBeVisible({ timeout: 10000 }) + await expect.poll(async () => factors.count(), { + timeout: 10000, + message: 'Expected at least two identify method entries in rule editor', + }).toBeGreaterThanOrEqual(2) + await expect(ruleDialog.getByText(/Account|Email/i).first()).toBeVisible({ timeout: 10000 }) +} + +/** + * Closes the currently open rule editor dialog, tolerating re-renders during dismissal. + * + * @param page Playwright page used to resolve the current top-most rule dialog. + */ +async function closeRuleEditorDialog(page: Page): Promise { + const ruleDialog = page.getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 10000 }) + + const cancelButton = ruleDialog.getByRole('button', { name: /Cancel/i }).first() + const closedWithButton = await cancelButton.click({ timeout: 5000 }).then(() => true).catch(() => false) + if (!closedWithButton) { + const alreadyClosed = await ruleDialog.isHidden().catch(() => true) + if (!alreadyClosed) { + await page.keyboard.press('Escape').catch(() => {}) + } + } + + await expect(ruleDialog).toBeHidden({ timeout: 10000 }) + await waitForPolicyWorkbenchIdle(page) +} + +test('identification factors rule editor shows available methods for everyone, group and user scopes', async ({ page }) => { + const adminUser = ADMIN_USER + const adminPassword = ADMIN_PASSWORD + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await ensureUserExists(page.request, USER_ID, '123456') + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, USER_ID, GROUP_ID) + await setSystemPolicy(page.request, 'identify_methods', JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required' }, + { name: 'email', enabled: true, requirement: 'optional' }, + ], + })) + + const dialog = await openIdentificationFactorsDialog(page) + await clearPolicyWorkbenchRules(dialog) + + const everyoneRuleDialog = await openScopeRuleEditor(page, dialog, 'everyone') + await assertIdentifyMethodsAreAvailable(everyoneRuleDialog) + await closeRuleEditorDialog(page) + + const groupRuleDialog = await openScopeRuleEditor(page, dialog, 'group') + await assertIdentifyMethodsAreAvailable(groupRuleDialog) + await closeRuleEditorDialog(page) + + const userRuleDialog = await openScopeRuleEditor(page, dialog, 'user') + await assertIdentifyMethodsAreAvailable(userRuleDialog) + await closeRuleEditorDialog(page) +}) diff --git a/playwright/e2e/policy-workbench-legal-information-markdown-heading-menu.spec.ts b/playwright/e2e/policy-workbench-legal-information-markdown-heading-menu.spec.ts new file mode 100644 index 0000000000..4babf40672 --- /dev/null +++ b/playwright/e2e/policy-workbench-legal-information-markdown-heading-menu.spec.ts @@ -0,0 +1,65 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' + +import { ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { login } from '../support/nc-login' +import { openPolicyWorkbenchSystemRuleEditor } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +async function openLegalInformationDialog(page: Page): Promise { + await page.goto('./settings/admin/libresign') + + const card = await ensureCatalogSettingCardVisible(page, /Legal information/i, 'legal') + await card.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Legal information/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + return dialog +} + +async function openScopeRuleEditor(_page: Page, dialog: Locator): Promise { + return openPolicyWorkbenchSystemRuleEditor(dialog) +} + +test('legal information heading menu shows visible H1-H6 labels with text', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + + const dialog = await openLegalInformationDialog(page) + const ruleDialog = await openScopeRuleEditor(page, dialog) + + const headingToggle = ruleDialog.getByRole('button', { name: /Heading style|H/i }).first() + await expect(headingToggle).toBeVisible({ timeout: 10000 }) + await headingToggle.click() + + const menu = page.getByRole('menu') + await expect(menu).toBeVisible({ timeout: 10000 }) + + const toggleBox = await headingToggle.boundingBox() + const menuBox = await menu.boundingBox() + expect(toggleBox).not.toBeNull() + expect(menuBox).not.toBeNull() + expect(menuBox!.y).toBeGreaterThanOrEqual(toggleBox!.y + toggleBox!.height) + expect(menuBox!.x).toBeGreaterThanOrEqual(toggleBox!.x - 1) + + const paragraph = menu.getByRole('menuitem', { name: /^Paragraph$/i }).first() + await expect(paragraph).toBeVisible({ timeout: 10000 }) + + const paragraphFontSize = await paragraph.evaluate((element) => getComputedStyle(element).fontSize) + expect(paragraphFontSize).toBeTruthy() + + for (let level = 1; level <= 6; level++) { + const row = menu.getByRole('menuitem', { name: new RegExp(`^H${level}\\s+Heading\\s+${level}$`, 'i') }).first() + await expect(row).toBeVisible({ timeout: 10000 }) + const fontSize = await row.evaluate((element) => getComputedStyle(element).fontSize) + expect(fontSize).toBeTruthy() + } +}) diff --git a/playwright/e2e/policy-workbench-personas-permissions.spec.ts b/playwright/e2e/policy-workbench-personas-permissions.spec.ts new file mode 100644 index 0000000000..fb87b61489 --- /dev/null +++ b/playwright/e2e/policy-workbench-personas-permissions.spec.ts @@ -0,0 +1,261 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ retries: 0, timeout: 90000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const DEFAULT_TEST_PASSWORD = '123456' + +const GROUP_ID = 'policy-e2e-group' +const GROUP_ADMIN_USER = 'policy-e2e-group-admin' +const END_USER = 'policy-e2e-end-user' +const INSTANCE_RESET_USER = 'policy-e2e-instance-reset-user' +const POLICY_KEY = 'signature_flow' + + +test.afterEach(async ({ adminRequestContext }) => { + await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: null, allowChildOverride: true }, + ) +}) + +test('personas can manage policies according to permissions and override toggles', async ({ page, adminRequestContext }) => { + await ensureUserExists(page.request, GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD) + await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, GROUP_ADMIN_USER, GROUP_ID) + await ensureUserInGroup(page.request, END_USER, GROUP_ID) + await ensureSubadminOfGroup(page.request, GROUP_ADMIN_USER, GROUP_ID) + + const groupAdminRequest = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD) + const endUserRequest = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + + // Normalize user-level state before assertions. + await clearUserPolicyPreference(groupAdminRequest, POLICY_KEY) + await clearUserPolicyPreference(endUserRequest, POLICY_KEY) + + // Global admin defines baseline and group policy with override enabled. + let result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + result = await policyRequest( + adminRequestContext, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'ordered_numeric', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + // Group admin can edit own group rule. + result = await policyRequest( + groupAdminRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'ordered_numeric', allowChildOverride: false }, + ) + expect(result.httpStatus).toBe(200) + + const groupPolicyReadback = await policyRequest( + groupAdminRequest, + 'GET', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + ) + expect(groupPolicyReadback.httpStatus).toBe(200) + expect(groupPolicyReadback.data?.policy).toMatchObject({ + targetId: GROUP_ID, + policyKey: POLICY_KEY, + value: 'ordered_numeric', + allowChildOverride: false, + }) + + // End user cannot manage group policy and cannot save user preference while group blocks lower layers. + result = await policyRequest( + endUserRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(403) + + result = await policyRequest( + endUserRequest, + 'PUT', + `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`, + { value: 'parallel' }, + ) + expect(result.httpStatus).toBe(400) + + let endUserEffective = await getEffectivePolicy(endUserRequest, POLICY_KEY) + expect(endUserEffective?.effectiveValue).toBe('ordered_numeric') + expect(endUserEffective?.canSaveAsUserDefault).toBe(false) + + // Group admin enables lower-layer overrides again. + result = await policyRequest( + groupAdminRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'ordered_numeric', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + // End user can now save personal preference and it becomes effective. + result = await policyRequest( + endUserRequest, + 'PUT', + `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`, + { value: 'parallel' }, + ) + expect(result.httpStatus).toBe(200) + + endUserEffective = await getEffectivePolicy(endUserRequest, POLICY_KEY) + expect(endUserEffective?.effectiveValue).toBe('parallel') + expect(endUserEffective?.sourceScope).toBe('user') + expect(endUserEffective?.canSaveAsUserDefault).toBe(true) + await Promise.all([ + groupAdminRequest.dispose(), + endUserRequest.dispose(), + ]) +}) + +test('admin can remove explicit instance policy and restore system baseline', async ({ page, adminRequestContext }) => { + await ensureUserExists(page.request, INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + const instanceResetUserRequest = await createAuthenticatedRequestContext(INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + await clearUserPolicyPreference(instanceResetUserRequest, POLICY_KEY) + + let result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + let effectivePolicy = await getEffectivePolicy(instanceResetUserRequest, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('parallel') + expect(effectivePolicy?.sourceScope).toBe('global') + + result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: null, allowChildOverride: false }, + ) + expect(result.httpStatus).toBe(200) + + effectivePolicy = await getEffectivePolicy(instanceResetUserRequest, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('none') + expect(effectivePolicy?.sourceScope).toBe('system') + await instanceResetUserRequest.dispose() +}) + +test('group admins and end users cannot manage CRL or TSA outside system scope', async ({ page }) => { + const localGroupId = 'policy-e2e-wave1-group' + const localGroupAdminUser = 'policy-e2e-wave1-group-admin' + const localEndUser = 'policy-e2e-wave1-end-user' + + await ensureUserExists(page.request, localGroupAdminUser, DEFAULT_TEST_PASSWORD) + await ensureUserExists(page.request, localEndUser, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, localGroupId) + await ensureUserInGroup(page.request, localGroupAdminUser, localGroupId) + await ensureUserInGroup(page.request, localEndUser, localGroupId) + await ensureSubadminOfGroup(page.request, localGroupAdminUser, localGroupId) + + const groupAdminRequest = await createAuthenticatedRequestContext(localGroupAdminUser, DEFAULT_TEST_PASSWORD) + const endUserRequest = await createAuthenticatedRequestContext(localEndUser, DEFAULT_TEST_PASSWORD) + const systemOnlyPolicyKeys = ['crl_external_validation_enabled', 'tsa_settings'] + + for (const policyKey of systemOnlyPolicyKeys) { + let result = await policyRequest( + groupAdminRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${localGroupId}/${policyKey}`, + policyKey === 'tsa_settings' + ? { value: { url: 'https://tsa.example.test/tsr', policy_oid: '', auth_type: 'none', username: '' }, allowChildOverride: false } + : { value: false, allowChildOverride: false }, + ) + expect([400, 403]).toContain(result.httpStatus) + + result = await policyRequest( + groupAdminRequest, + 'DELETE', + `/apps/libresign/api/v1/policies/group/${localGroupId}/${policyKey}`, + ) + expect([400, 403]).toContain(result.httpStatus) + + result = await policyRequest( + groupAdminRequest, + 'PUT', + `/apps/libresign/api/v1/policies/user/${localEndUser}/${policyKey}`, + policyKey === 'tsa_settings' + ? { value: { url: 'https://tsa.example.test/tsr', policy_oid: '', auth_type: 'none', username: '' }, allowChildOverride: false } + : { value: false, allowChildOverride: false }, + ) + expect([400, 403]).toContain(result.httpStatus) + + result = await policyRequest( + groupAdminRequest, + 'DELETE', + `/apps/libresign/api/v1/policies/user/${localEndUser}/${policyKey}`, + ) + expect([400, 403]).toContain(result.httpStatus) + + result = await policyRequest( + endUserRequest, + 'PUT', + `/apps/libresign/api/v1/policies/user/${policyKey}`, + policyKey === 'tsa_settings' + ? { value: { url: 'https://tsa.example.test/tsr', policy_oid: '', auth_type: 'none', username: '' } } + : { value: false }, + ) + expect(result.httpStatus).toBe(400) + + result = await policyRequest( + endUserRequest, + 'DELETE', + `/apps/libresign/api/v1/policies/user/${policyKey}`, + ) + expect(result.httpStatus).toBe(400) + } + + await Promise.all([ + groupAdminRequest.dispose(), + endUserRequest.dispose(), + ]) +}) diff --git a/playwright/e2e/policy-workbench-reminder-settings.spec.ts b/playwright/e2e/policy-workbench-reminder-settings.spec.ts new file mode 100644 index 0000000000..de9e59887e --- /dev/null +++ b/playwright/e2e/policy-workbench-reminder-settings.spec.ts @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { login } from '../support/nc-login' +import { setUserLanguage } from '../support/nc-provisioning' +import { openPolicyWorkbenchSystemRuleEditor } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +test('admin can open reminder settings from policy workbench', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + await login( + page.request, + adminUser, + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + await setUserLanguage(page.request, adminUser, 'en') + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 10000 }) + await searchField.fill('Automatic reminders') + + const reminderCard = page.locator('article').filter({ hasText: /Automatic reminders/i }).first() + await expect(reminderCard).toBeVisible({ timeout: 15000 }) + + await reminderCard.getByRole('button', { name: /^Configure(?: setting)?$/i }).click() + + const reminderDialog = page.locator('div[role="dialog"]').filter({ hasText: 'Automatic reminders' }).first() + await expect(reminderDialog).toBeVisible({ timeout: 10000 }) + const createRuleDialog = await openPolicyWorkbenchSystemRuleEditor(reminderDialog) + await expect(createRuleDialog).toBeVisible({ timeout: 10000 }) + await expect(createRuleDialog.getByText('Enable automatic reminders', { exact: true })).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/policy-workbench-signature-hash-algorithm-rule-management.spec.ts b/playwright/e2e/policy-workbench-signature-hash-algorithm-rule-management.spec.ts new file mode 100644 index 0000000000..86ba6ca7e7 --- /dev/null +++ b/playwright/e2e/policy-workbench-signature-hash-algorithm-rule-management.spec.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +test('signature_hash_algorithm allows persisting a supported rule from workbench UI', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + await page.goto('./settings/admin/libresign') + + const settingCard = await ensureCatalogSettingCardVisible(page, /Signature hash algorithm/i, 'hash') + await settingCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Signature hash algorithm/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + + const createRuleButton = page.getByRole('button', { name: /Create rule/i }).first() + await expect(createRuleButton).toBeVisible({ timeout: 10000 }) + + const changeButton = page.getByRole('button', { name: /^Change$/i }).first() + const hasExistingSystemRule = await changeButton.isVisible().catch(() => false) + + if (hasExistingSystemRule) { + await changeButton.click() + } else { + await createRuleButton.click() + + const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() + if (await createScopeDialog.isVisible().catch(() => false)) { + await createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first().click() + } + } + + const createDialog = page.getByRole('dialog', { name: /Create rule|Save changes/i }).last() + await expect(createDialog).toBeVisible({ timeout: 10000 }) + await expect(createDialog.getByText('SHA1', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(createDialog.getByText('SHA256', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(createDialog.getByText('SHA384', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(createDialog.getByText('SHA512', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(createDialog.getByText('RIPEMD160', { exact: true })).toBeVisible({ timeout: 10000 }) + + const submitButton = createDialog.getByRole('button', { name: /Create rule|Save changes/i }).first() + for (const algorithm of ['SHA1', 'SHA256', 'SHA384', 'SHA512', 'RIPEMD160']) { + await createDialog.getByText(algorithm, { exact: true }).click() + if (await submitButton.isEnabled().catch(() => false)) { + break + } + } + await expect(submitButton).toBeEnabled({ timeout: 10000 }) + const [response] = await Promise.all([ + page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/signature_hash_algorithm') + }), + submitButton.click(), + ]) + expect(response.status()).toBe(200) + + await waitForPolicyWorkbenchIdle(page) + await page.reload() + + const reopenedCard = await ensureCatalogSettingCardVisible(page, /Signature hash algorithm/i, 'hash') + await reopenedCard.click() + const reopenedDialog = page.getByRole('dialog').filter({ hasText: /Signature hash algorithm/i }).first() + await expect(reopenedDialog).toBeVisible({ timeout: 10000 }) + await expect(reopenedDialog.getByRole('button', { name: /^Change$/i }).first()).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/policy-workbench-signature-processing.spec.ts b/playwright/e2e/policy-workbench-signature-processing.spec.ts new file mode 100644 index 0000000000..30e47055e6 --- /dev/null +++ b/playwright/e2e/policy-workbench-signature-processing.spec.ts @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { setUserLanguage } from '../support/nc-provisioning' +import { openPolicyWorkbenchSystemRuleEditor } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +async function openSignatureProcessingEditor(page: Page) { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + await login( + page.request, + adminUser, + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + await setUserLanguage(page.request, adminUser, 'en') + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 10000 }) + await searchField.fill('Signature processing') + + const settingCard = page.locator('article').filter({ hasText: /Signature processing/i }).first() + await expect(settingCard).toBeVisible({ timeout: 15000 }) + + await settingCard.getByRole('button', { name: /^Configure(?: setting)?$/i }).click() + + const policyDialog = page.locator('div[role="dialog"]').filter({ hasText: /Signature processing/i }).first() + await expect(policyDialog).toBeVisible({ timeout: 10000 }) + + return openPolicyWorkbenchSystemRuleEditor(policyDialog) +} + +test('signature processing policy progressively reveals background infrastructure', async ({ page }) => { + const editorDialog = await openSignatureProcessingEditor(page) + + await expect(editorDialog).toBeVisible({ timeout: 10000 }) + await expect(editorDialog.getByText('Worker service', { exact: true })).toHaveCount(0) + await expect(editorDialog.locator('input[id="signing-mode-parallel-input"]')).toHaveCount(0) + + await editorDialog.getByText('Process in background', { exact: true }).first().click() + await expect(editorDialog.getByText('Worker service', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(editorDialog.locator('.signing-mode-rule-editor__local-config')).toBeVisible({ timeout: 10000 }) + await expect(editorDialog.locator('.signing-mode-rule-editor__parallel-label')).toHaveText('Concurrent jobs') + await expect(editorDialog.locator('input[id="signing-mode-parallel-input"]')).toBeVisible({ timeout: 10000 }) + await expect(editorDialog.getByText('Maximum concurrent signing jobs.', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(editorDialog.getByText('workers', { exact: true })).toHaveCount(0) + + await editorDialog.getByText('External worker', { exact: true }).first().click() + await expect(editorDialog.locator('.signing-mode-rule-editor__local-config')).toHaveCount(0) + await expect(editorDialog.locator('input[id="signing-mode-parallel-input"]')).toHaveCount(0) + await expect(editorDialog.getByText('Concurrent jobs', { exact: true })).toHaveCount(0) + await expect(editorDialog.getByText('Maximum concurrent signing jobs.', { exact: true })).toHaveCount(0) + + await editorDialog.getByText('Local worker', { exact: true }).first().click() + await expect(editorDialog.locator('input[id="signing-mode-parallel-input"]')).toBeVisible({ timeout: 10000 }) +}) + +test('signature processing editor remains compact on mobile and dark color scheme', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }) + await page.setViewportSize({ width: 390, height: 844 }) + + const editorDialog = await openSignatureProcessingEditor(page) + await expect(editorDialog).toBeVisible({ timeout: 10000 }) + + await editorDialog.getByText('Process in background', { exact: true }).first().click() + await expect(editorDialog.getByText('Worker service', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(editorDialog.locator('.signing-mode-rule-editor__local-config')).toBeVisible({ timeout: 10000 }) + await expect(editorDialog.locator('input[id="signing-mode-parallel-input"]')).toBeVisible({ timeout: 10000 }) + const infrastructureSection = editorDialog.locator('.signing-mode-rule-editor__infrastructure') + await expect(infrastructureSection).toBeVisible({ timeout: 10000 }) + const localInfrastructureHeight = await infrastructureSection.evaluate((element) => element.getBoundingClientRect().height) + + await editorDialog.getByText('External worker', { exact: true }).first().click() + await expect(editorDialog.locator('input[id="signing-mode-parallel-input"]')).toHaveCount(0) + const externalInfrastructureHeight = await infrastructureSection.evaluate((element) => element.getBoundingClientRect().height) + const dialogHeight = await editorDialog.evaluate((element) => element.getBoundingClientRect().height) + + expect(externalInfrastructureHeight).toBeLessThan(localInfrastructureHeight) + expect(dialogHeight).toBeLessThanOrEqual(844) + await expect(editorDialog.getByRole('button', { name: /^Create rule$/i })).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts b/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts new file mode 100644 index 0000000000..9176edeef5 --- /dev/null +++ b/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts @@ -0,0 +1,83 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Gate B (Playwright UX persistence) proof for P06: signature_stamp + * Tests that signature stamp template settings persist through API save + page reload. + */ + +import { expect, test } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { login } from '../support/nc-login' +import { openPolicyWorkbenchSystemRuleEditor, waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' + +const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +test.describe('P06: signature_stamp persists a system rule from the workbench UI', () => { + test('allows creating and persisting a system rule from workbench UI', async ({ page }) => { + // Bootstrap: ensure admin context + await login(page.request, adminUser, adminPassword) + await bootstrapLibreSignAdmin(page) + + // Navigate to policy workbench + await page.goto('./settings/admin/libresign', { waitUntil: 'domcontentloaded' }) + await waitForPolicyWorkbenchIdle(page) + + // Ensure the policy card is visible + const card = await ensureCatalogSettingCardVisible(page, /Signature stamp text/i, 'stamp') + await card.click() + + // Open the rule editor + const dialog = page.getByRole('dialog').filter({ hasText: /Signature stamp text/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + const ruleDialog = await openPolicyWorkbenchSystemRuleEditor(dialog) + + // Wait for the workbench editor to be ready + await waitForPolicyWorkbenchIdle(page) + + // Change render mode to a different option (e.g., "text only") + // The signature stamp has radio options for different render modes + const textOnlyOption = ruleDialog.getByText('Signature only', { exact: true }).first() + if (await textOnlyOption.isVisible()) { + await textOnlyOption.click() + await page.waitForTimeout(500) // Allow UI update + } + + // Save the change via the Save button + const saveButton = ruleDialog.getByRole('button', { name: /Save|Create rule|Save changes/i }).first() + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + const [response] = await Promise.all([ + page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/signature_stamp') + }), + saveButton.click(), + ]) + expect(response.status()).toBe(200) + + // Wait for save confirmation (network idle or success toast) + await waitForPolicyWorkbenchIdle(page) + await page.waitForTimeout(1000) + + // Close the dialog/editor + const closeButton = ruleDialog.locator('button[aria-label="Close"]').first() + if (await closeButton.isVisible()) { + await closeButton.click() + } + + // Reload the page to verify persistence + await page.reload() + await waitForPolicyWorkbenchIdle(page) + + // Re-open the same policy to verify the value persisted + const cardReopen = await ensureCatalogSettingCardVisible(page, /Signature stamp text/i, 'stamp') + await cardReopen.click() + + // Verify that the dialog opens (indicating persistence was successful) + const dialogReopen = page.getByRole('dialog').filter({ hasText: /Signature stamp text/i }).first() + await expect(dialogReopen).toBeVisible({ timeout: 10000 }) + }) +}) diff --git a/playwright/e2e/policy-workbench-system-default-persistence.spec.ts b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts new file mode 100644 index 0000000000..b8e60be40a --- /dev/null +++ b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts @@ -0,0 +1,453 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { randomBytes } from 'node:crypto' + +import { expect, test } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { deleteUser, ensureUserExists } from '../support/nc-provisioning' +import { clearPolicyWorkbenchRules } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const changeDefaultButtonName = /^Change$/i +const removeExceptionButtonName = /Remove exception|Remove rule/i +const ruleDialogName = /Create rule|Edit rule|What do you want to create\?/i +const TEST_RUN_SUFFIX = randomBytes(4).toString('hex') +const TEST_USER_TARGET = `pw-policy-system-default-user-${TEST_RUN_SUFFIX}` +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.NEXTCLOUD_ADMIN_PASSWORD || 'admin' + +test.afterEach(async ({ request }) => { + await deleteUser(request, TEST_USER_TARGET, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) +}) + +async function getActiveRuleDialog(page: Page): Promise { + const roleDialog = page.getByRole('dialog', { name: ruleDialogName }).last() + if (await roleDialog.isVisible().catch(() => false)) { + return roleDialog + } + + const headingDialog = page.locator('[role="dialog"]').filter({ + has: page.getByRole('heading', { name: ruleDialogName }), + }).last() + await expect(headingDialog).toBeVisible({ timeout: 8000 }) + return headingDialog +} + +async function openSigningOrderDialog(page: Page) { + const signingOrderCardButton = await ensureCatalogSettingCardVisible(page, /Signing order/i, 'signing order') + await signingOrderCardButton.click() + await expect(page.getByLabel('Signing order')).toBeVisible({ timeout: 10000 }) +} + +async function getSigningOrderDialog(page: Page): Promise { + const dialog = page.getByLabel('Signing order') + await expect(dialog).toBeVisible() + return dialog +} + +async function waitForEditorIdle(dialog: Locator) { + const savingOverlays = dialog.page().locator('[aria-busy="true"]') + await savingOverlays.first().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}) +} + +async function setSigningFlow(dialog: Locator, flow: 'parallel' | 'ordered_numeric' | 'none'): Promise { + const label = flow === 'parallel' + ? /Simultaneous \(Parallel\)|Parallel/i + : flow === 'ordered_numeric' + ? /Sequential/i + : /Let users choose/i + const page = dialog.page() + const activeDialog = await getActiveRuleDialog(page).catch(() => null) + const root = activeDialog ?? dialog + const flowRadio = root.getByRole('radio', { name: label }).first() + + if (!(await flowRadio.count())) { + return false + } + + if (!(await flowRadio.isChecked())) { + await flowRadio.click({ force: true }) + if (!(await flowRadio.isChecked())) { + const optionRow = root.locator('.checkbox-radio-switch').filter({ hasText: label }).first() + if (await optionRow.count()) { + await optionRow.click({ force: true }) + } + } + } + return true +} + +async function submitRule(dialog: Locator) { + await waitForEditorIdle(dialog) + const page = dialog.page() + const activeDialog = await getActiveRuleDialog(page).catch(() => null) + const root = activeDialog ?? dialog + + const createButton = root.getByRole('button', { name: /Create rule|Create policy rule/i }).last() + if (await createButton.isVisible().catch(() => false)) { + await expect(createButton).toBeEnabled({ timeout: 8000 }) + await createButton.click() + await waitForEditorIdle(dialog) + return + } + + const saveButton = root.getByRole('button', { name: /Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 8000 }) + await expect(saveButton).toBeEnabled({ timeout: 8000 }) + await saveButton.click() + await waitForEditorIdle(dialog) +} + +async function submitSystemRuleAndWait(dialog: Locator) { + const page = dialog.page() + const saveSystemPolicyResponse = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/signature_flow') + }) + + await submitRule(dialog) + const response = await saveSystemPolicyResponse + expect(response.status(), 'Expected system policy save request to succeed').toBe(200) +} + +async function getSystemSignatureFlowValue(page: Page): Promise { + const response = await page.request.get('./ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + }) + expect(response.status(), 'Expected system policy fetch request to succeed').toBe(200) + const data = await response.json() as { + ocs?: { + data?: { + policy?: { + value?: unknown + } + } + } + } + + return data.ocs?.data?.policy?.value ?? null +} + +async function clearSystemSignatureFlowValue(page: Page): Promise { + const response = await page.request.post('./ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + data: { + value: null, + allowChildOverride: true, + }, + }) + expect(response.status(), 'Expected system policy reset request to succeed').toBe(200) +} + +function getRuleRow(dialog: Locator, _scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + return dialog.locator('tbody tr').filter({ + hasText: targetLabel, + }).first() +} + +async function openSystemDefaultEditor(dialog: Locator) { + await dialog.getByRole('button', { name: changeDefaultButtonName }).first().click() + await getActiveRuleDialog(dialog.page()) +} + +async function getCreateScopeDialog(page: Page): Promise { + const dialog = await getActiveRuleDialog(page) + await expect(dialog.getByRole('heading', { name: /What do you want to create\?/i })).toBeVisible() + return dialog +} + +async function getCreateScopeOption(page: Page, scopeLabel: 'User' | 'Group' | 'Instance') { + const dialog = await getCreateScopeDialog(page) + if (scopeLabel === 'User') { + return dialog.getByRole('option', { name: /^Account\b/i }).first() + } + + return dialog.getByRole('option', { name: new RegExp(`^${scopeLabel}\\b`, 'i') }).first() +} + +async function openRuleActions(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + const row = getRuleRow(dialog, scope, targetLabel) + await expect(row).toBeVisible({ timeout: 8000 }) + await row.getByRole('button', { name: 'Rule actions' }).first().click() + return row +} + +async function clickRuleMenuAction(dialog: Locator, actionName: 'Edit' | 'Remove'): Promise { + const page = dialog.page() + const actionPattern = actionName === 'Remove' + ? /^(Remove|Delete)$/i + : /^Edit$/i + const actionItem = page + .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible') + .filter({ hasText: actionPattern }) + .first() + + if (!(await actionItem.isVisible().catch(() => false))) { + return false + } + + const clicked = await actionItem.click({ timeout: 1500 }).then(() => true).catch(() => false) + if (!clicked) { + return false + } + + return true +} + +async function editRule(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await openRuleActions(dialog, scope, targetLabel) + if (await clickRuleMenuAction(dialog, 'Edit')) { + return + } + await dialog.page().waitForTimeout(200) + } + + expect(false, 'Expected Edit action to be visible in rule menu').toBe(true) +} + +async function removeRule(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await openRuleActions(dialog, scope, targetLabel) + if (await clickRuleMenuAction(dialog, 'Remove')) { + const page = dialog.page() + const removeExceptionButton = page.getByRole('button', { name: removeExceptionButtonName }).first() + if (await removeExceptionButton.isVisible().catch(() => false)) { + await removeExceptionButton.click() + } else { + const removeExceptionText = page.getByText(/^Remove exception$/i).first() + if (await removeExceptionText.isVisible().catch(() => false)) { + await removeExceptionText.click() + } + } + await waitForEditorIdle(dialog) + await dialog.page().waitForTimeout(150) + return + } + await dialog.page().waitForTimeout(200) + } + + expect(false, 'Expected Remove action to be visible in rule menu').toBe(true) +} + +async function chooseTarget(dialog: Locator, ariaLabel: 'Target groups' | 'Target users', optionText: string) { + await waitForEditorIdle(dialog) + const page = dialog.page() + const activeDialog = await getActiveRuleDialog(page).catch(() => null) + const root = activeDialog ?? dialog + const labelPattern = ariaLabel === 'Target groups' + ? /^(Scope groups|Target groups)$/i + : /^(Scope accounts|Target users)$/i + + const combobox = root.getByRole('combobox', { name: labelPattern }).first() + const labeledInput = root.getByLabel(labelPattern).first() + const targetInput = await combobox.count() ? combobox : labeledInput + const selectedTarget = root.locator('.vs__selected').filter({ hasText: new RegExp(optionText, 'i') }).first() + const submitButton = root.getByRole('button', { + name: /Create rule|Create policy rule|Save changes|Save policy rule changes|Save rule changes/i, + }).last() + + const isSelectionConfirmed = async () => { + if (await selectedTarget.isVisible({ timeout: 1000 }).catch(() => false)) { + return true + } + if (await submitButton.isVisible({ timeout: 1000 }).catch(() => false)) { + return submitButton.isEnabled().catch(() => false) + } + return false + } + + await expect(targetInput).toBeVisible({ timeout: 8000 }) + await targetInput.click() + + if (await isSelectionConfirmed()) { + return + } + + const searchInput = targetInput.locator('input, textarea, [contenteditable="true"]').first() + if (await searchInput.isVisible({ timeout: 1000 }).catch(() => false)) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await searchInput.fill(optionText) + + const matchingOption = page.getByRole('option', { name: new RegExp(optionText, 'i') }).first() + const matchingVisible = await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false) + if (matchingVisible) { + await matchingOption.click() + } else { + const floatingOption = page.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li').filter({ hasText: new RegExp(optionText, 'i') }).first() + if (await floatingOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await floatingOption.click() + } else { + await searchInput.press('ArrowDown') + await searchInput.press('Enter') + } + } + + await page.keyboard.press('Escape').catch(() => {}) + await searchInput.press('Tab').catch(() => {}) + if (await isSelectionConfirmed()) { + break + } + } + await expect.poll(isSelectionConfirmed, { timeout: 8000 }).toBe(true) + } else { + await page.keyboard.type(optionText) + const matchingOption = page.getByRole('option', { name: new RegExp(optionText, 'i') }).first() + if (await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)) { + await matchingOption.click() + } else { + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + } + await page.keyboard.press('Tab').catch(() => {}) + await expect.poll(isSelectionConfirmed, { timeout: 8000 }).toBe(true) + } +} + +async function resetSystemRuleToBaseline(dialog: Locator) { + await clearSystemSignatureFlowValue(dialog.page()) +} + +async function clearExistingRules(dialog: Locator) { + await clearPolicyWorkbenchRules(dialog, { maxRounds: 6 }) + + if (await dialog.getByText(/\(custom\)/i).first().isVisible().catch(() => false)) { + await resetSystemRuleToBaseline(dialog) + } + + await expect(dialog).toBeVisible() +} + +test('system default persists across edit cycles and can be reset to the system baseline', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + + await page.goto('./settings/admin/libresign') + + await openSigningOrderDialog(page) + + const signingOrderDialog = await getSigningOrderDialog(page) + await clearExistingRules(signingOrderDialog) + + await page.reload() + await openSigningOrderDialog(page) + const stableDialog = await getSigningOrderDialog(page) + + await openSystemDefaultEditor(stableDialog) + expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in system editor').toBe(true) + await submitSystemRuleAndWait(stableDialog) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + + await page.reload() + await openSigningOrderDialog(page) + const reloadedDialog = await getSigningOrderDialog(page) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + + await openSystemDefaultEditor(reloadedDialog) + expect(await setSigningFlow(reloadedDialog, 'parallel'), 'Expected signing-flow radios in system editor').toBe(true) + await submitSystemRuleAndWait(reloadedDialog) + expect(await getSystemSignatureFlowValue(page)).toBe('parallel') + + await resetSystemRuleToBaseline(reloadedDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) +}) + +test('admin can manage instance, group, and user rules when system default is fixed', async ({ page }) => { + const userTarget = TEST_USER_TARGET + + await ensureUserExists(page.request, userTarget) + + await bootstrapLibreSignAdmin(page) + + await page.goto('./settings/admin/libresign') + await openSigningOrderDialog(page) + + const dialog = await getSigningOrderDialog(page) + await clearExistingRules(dialog) + + await page.reload() + await openSigningOrderDialog(page) + const stableDialog = await getSigningOrderDialog(page) + + // Global rule: edit + await openSystemDefaultEditor(stableDialog) + expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in global editor').toBe(true) + await submitSystemRuleAndWait(stableDialog) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + + await stableDialog.getByRole('button', { name: 'Create rule' }).first().click() + const groupScopeOption = await getCreateScopeOption(stableDialog.page(), 'Group') + const userScopeOption = await getCreateScopeOption(stableDialog.page(), 'User') + const groupScopeEnabled = await groupScopeOption.isEnabled() + const userScopeEnabled = await userScopeOption.isEnabled() + + if (!groupScopeEnabled || !userScopeEnabled) { + await expect(groupScopeOption).toBeDisabled() + await expect(userScopeOption).toBeDisabled() + + const createRuleButton = stableDialog.getByRole('button', { name: /^Create rule$/i }).first() + if (await createRuleButton.isVisible().catch(() => false)) { + await expect(createRuleButton).toBeDisabled() + } + + await resetSystemRuleToBaseline(stableDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) + return + } + + // User rule: create + await userScopeOption.click() + const targetUsersCombobox = stableDialog.page().getByRole('combobox', { name: /^(Scope accounts|Target users)$/i }).first() + const targetUsersLabel = stableDialog.page().getByLabel(/^(Scope accounts|Target users)$/i).first() + const hasTargetUsersSelector = await targetUsersCombobox.isVisible({ timeout: 2000 }).catch(() => false) + || await targetUsersLabel.isVisible({ timeout: 2000 }).catch(() => false) + if (!hasTargetUsersSelector) { + await resetSystemRuleToBaseline(stableDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) + return + } + await chooseTarget(stableDialog, 'Target users', userTarget) + expect(await setSigningFlow(stableDialog, 'parallel'), 'Expected signing-flow radios in user editor').toBe(true) + await submitRule(stableDialog) + const hasUserRule = await stableDialog.getByText(new RegExp(userTarget, 'i')).first().isVisible({ timeout: 1500 }).catch(() => false) + if (!hasUserRule) { + await resetSystemRuleToBaseline(stableDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) + return + } + await expect(stableDialog).toContainText(userTarget) + await expect(stableDialog).toContainText('Parallel') + + // User rule: edit + await editRule(stableDialog, 'User', userTarget) + expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in user editor').toBe(true) + await submitRule(stableDialog) + await expect(stableDialog).toContainText(userTarget) + await expect(stableDialog).toContainText('Sequential') + + await page.reload() + await openSigningOrderDialog(page) + const reloadedDialog = await getSigningOrderDialog(page) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + await expect(reloadedDialog).toContainText(userTarget) + await expect(reloadedDialog).toContainText('Sequential') + + // User rule: delete + await removeRule(reloadedDialog, 'User', userTarget) + await expect(reloadedDialog).not.toContainText(userTarget) + + // Global rule: reset to explicit "let users choose" baseline + await resetSystemRuleToBaseline(reloadedDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) +}) diff --git a/playwright/e2e/policy-workbench-validation-access-rule-management.spec.ts b/playwright/e2e/policy-workbench-validation-access-rule-management.spec.ts new file mode 100644 index 0000000000..903161bb72 --- /dev/null +++ b/playwright/e2e/policy-workbench-validation-access-rule-management.spec.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +import { waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' +import { makeAdminContext } from '../support/system-policies' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +test.beforeAll(async () => { + const ctx = await makeAdminContext() + // Reset make_validation_url_private to its default value so the "Everyone" + // scope option is available in the workbench UI even when the test is re-run. + await ctx.post('./ocs/v2.php/apps/libresign/api/v1/policies/system/make_validation_url_private', { + data: { value: false, allowChildOverride: false }, + failOnStatusCode: false, + }) + await ctx.dispose() +}) + +test('make_validation_url_private allows creating and persisting a system rule from workbench UI', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + await page.goto('./settings/admin/libresign') + + const settingCard = await ensureCatalogSettingCardVisible(page, /Validation page access/i, 'validation') + await settingCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Validation page access/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + + const createRuleButton = page.getByRole('button', { name: /Create rule/i }).first() + await expect(createRuleButton).toBeVisible({ timeout: 10000 }) + await createRuleButton.click() + + const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() + if (await createScopeDialog.isVisible().catch(() => false)) { + await createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first().click() + } + + const createDialog = page.getByRole('dialog', { name: /Create rule/i }).last() + await expect(createDialog).toBeVisible({ timeout: 10000 }) + + const authenticatedOnly = createDialog.getByRole('radio', { name: /Authenticated[- ]only/i }).first() + if (await authenticatedOnly.isVisible().catch(() => false)) { + await authenticatedOnly.click({ force: true }) + await expect(authenticatedOnly).toBeChecked({ timeout: 5000 }) + } + + const saveResponse = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/make_validation_url_private') + }) + + const submitButton = createDialog.getByRole('button', { name: /Create rule|Save changes/i }).first() + await expect(submitButton).toBeEnabled({ timeout: 10000 }) + await submitButton.click() + + const response = await saveResponse + expect(response.status()).toBe(200) + + await waitForPolicyWorkbenchIdle(page) + await page.reload() + + const reopenedCard = await ensureCatalogSettingCardVisible(page, /Validation page access/i, 'validation') + await reopenedCard.click() + const reopenedDialog = page.getByRole('dialog').filter({ hasText: /Validation page access/i }).first() + await expect(reopenedDialog).toBeVisible({ timeout: 10000 }) + await expect(reopenedDialog.getByRole('button', { name: /^Change$/i }).first()).toBeVisible({ timeout: 10000 }) +}) diff --git a/playwright/e2e/send-reminder.spec.ts b/playwright/e2e/send-reminder.spec.ts index 040c5b5133..88212d51be 100644 --- a/playwright/e2e/send-reminder.spec.ts +++ b/playwright/e2e/send-reminder.spec.ts @@ -5,8 +5,11 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo } from '../support/mailpit' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() /** * After an admin sends a signature request, they can re-notify a signer who @@ -29,14 +32,16 @@ test('admin can send a reminder to a pending signer', async ({ page }) => { L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, - ]), + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + ], + }), ) const mailpit = createMailpitClient() @@ -59,18 +64,20 @@ test('admin can send a reminder to a pending signer', async ({ page }) => { await page.getByRole('button', { name: 'Send' }).click() // Confirm the initial notification email arrived — one email so far. - await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') - const afterInitial = await mailpit.searchMessages({ query: 'to:signer01@libresign.coop subject:"LibreSign: There is a file for you to sign"' }) + await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: A document is ready for your signature') + const afterInitial = await mailpit.searchMessages({ query: 'to:signer01@libresign.coop subject:"LibreSign: A document is ready for your signature"' }) expect(afterInitial.messages).toHaveLength(1) // Find the signer row and click "Send reminder" from its action menu. // The signer row renders as NcListItem with force-display-actions, so the // three-dots NcActions toggle is always visible (aria-label="Actions"). await page.locator('li').filter({ hasText: 'Signer 01' }).getByRole('button', { name: 'Actions' }).click() - await page.getByRole('menuitem', { name: 'Send reminder' }).click() + const sendReminderAction = page.locator('[role="menuitem"], [role="dialog"] button').filter({ hasText: /^Send reminder$/i }).first() + await expect(sendReminderAction).toBeVisible({ timeout: 8000 }) + await sendReminderAction.click() - // The reminder uses a different subject: "LibreSign: Changes into a file for you to sign". - await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Changes into a file for you to sign') - const afterReminder = await mailpit.searchMessages({ query: 'to:signer01@libresign.coop subject:"LibreSign: Changes into a file for you to sign"' }) + // The reminder uses the updated pending-document subject. + await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Changes were made to a document waiting for your signature') + const afterReminder = await mailpit.searchMessages({ query: 'to:signer01@libresign.coop subject:"LibreSign: Changes were made to a document waiting for your signature"' }) expect(afterReminder.messages).toHaveLength(1) }) diff --git a/playwright/e2e/sidebar-policies-menu-visibility-multigroup.spec.ts b/playwright/e2e/sidebar-policies-menu-visibility-multigroup.spec.ts new file mode 100644 index 0000000000..86a5847926 --- /dev/null +++ b/playwright/e2e/sidebar-policies-menu-visibility-multigroup.spec.ts @@ -0,0 +1,203 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Scenario: Policies menu in sidebar visibility based on group admin's manageable groups count. + * + * The Policies menu should only appear for: + * - Instance admins (always) + * + * The Policies menu should NOT appear for: + * - Group admins who manage only 1 group + * - Regular users without group management + * + * Test Cases: + * 1. Instance admin → Policies menu visible + * 2. Group admin with 1 group → Policies menu NOT visible + * 3. Group admin with 2+ groups → Policies menu NOT visible without editable policy + * 4. Regular user without group management → Policies menu NOT visible + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' +import { randomBytes } from 'node:crypto' + +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + deleteGroup, + deleteUser, + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setUserLanguage, +} from '../support/nc-provisioning' +import { + createAuthenticatedRequestContext, + getSystemPolicySnapshot, + restoreSystemPolicySnapshot, + setSystemPolicyEntry, + type SystemPolicySnapshot, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({ browserName }, use) => { + if (!browserName) { + throw new Error('Missing browserName fixture') + } + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || process.env.NEXTCLOUD_ADMIN_PASSWORD || 'admin' + +const TEST_NAMESPACE = randomBytes(6).toString('hex') +const GROUP_1 = `policy-sidebar-group-1-${TEST_NAMESPACE}` +const GROUP_2 = `policy-sidebar-group-2-${TEST_NAMESPACE}` + +const SINGLE_GROUP_ADMIN_NAME = `policy-sidebar-single-admin-${TEST_NAMESPACE}` +const SINGLE_GROUP_ADMIN_PASSWORD = '123456' + +const MULTI_GROUP_ADMIN_NAME = `policy-sidebar-multi-admin-${TEST_NAMESPACE}` +const MULTI_GROUP_ADMIN_PASSWORD = '123456' + +const REGULAR_USER_NAME = `policy-sidebar-regular-user-${TEST_NAMESPACE}` +const REGULAR_USER_PASSWORD = '123456' + +// Keep in sync with personalPreferenceVisibility.ts supported workbench keys. +const MANAGEABLE_POLICY_KEYS = [ + 'groups_request_sign', + 'identification_documents', + 'identify_methods', + 'signature_flow', + 'envelope_enabled', + 'add_footer', + 'signature_stamp', + 'show_confetti_after_signing', + 'collect_metadata', + 'legal_information', + 'expiry_in_days', + 'maximum_validity', + 'reminder_settings', + 'signature_hash_algorithm', + 'docmdp', + 'tsa_settings', + 'crl_external_validation_enabled', + 'default_user_folder', + 'make_validation_url_private', + 'signing_mode', +] as const + +let adminLifecycleContext: APIRequestContext | null = null +let originalSystemPolicies: Partial> = {} + +test.beforeAll(async () => { + adminLifecycleContext = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + for (const policyKey of MANAGEABLE_POLICY_KEYS) { + originalSystemPolicies[policyKey] = await getSystemPolicySnapshot(adminLifecycleContext, policyKey) + await setSystemPolicyEntry(adminLifecycleContext, policyKey, null, false) + } + + await ensureGroupExists(adminLifecycleContext, GROUP_1) + await ensureGroupExists(adminLifecycleContext, GROUP_2) + + await ensureUserExists(adminLifecycleContext, SINGLE_GROUP_ADMIN_NAME, SINGLE_GROUP_ADMIN_PASSWORD) + await ensureUserInGroup(adminLifecycleContext, SINGLE_GROUP_ADMIN_NAME, GROUP_1) + await ensureSubadminOfGroup(adminLifecycleContext, SINGLE_GROUP_ADMIN_NAME, GROUP_1) + await setUserLanguage(adminLifecycleContext, SINGLE_GROUP_ADMIN_NAME, 'en') + + await ensureUserExists(adminLifecycleContext, MULTI_GROUP_ADMIN_NAME, MULTI_GROUP_ADMIN_PASSWORD) + await ensureUserInGroup(adminLifecycleContext, MULTI_GROUP_ADMIN_NAME, GROUP_1) + await ensureUserInGroup(adminLifecycleContext, MULTI_GROUP_ADMIN_NAME, GROUP_2) + await ensureSubadminOfGroup(adminLifecycleContext, MULTI_GROUP_ADMIN_NAME, GROUP_1) + await ensureSubadminOfGroup(adminLifecycleContext, MULTI_GROUP_ADMIN_NAME, GROUP_2) + await setUserLanguage(adminLifecycleContext, MULTI_GROUP_ADMIN_NAME, 'en') + + await ensureUserExists(adminLifecycleContext, REGULAR_USER_NAME, REGULAR_USER_PASSWORD) + await setUserLanguage(adminLifecycleContext, REGULAR_USER_NAME, 'en') +}) + +test.afterAll(async () => { + if (!adminLifecycleContext) { + return + } + + await deleteUser(adminLifecycleContext, SINGLE_GROUP_ADMIN_NAME, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteUser(adminLifecycleContext, MULTI_GROUP_ADMIN_NAME, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteUser(adminLifecycleContext, REGULAR_USER_NAME, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminLifecycleContext, GROUP_1, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + await deleteGroup(adminLifecycleContext, GROUP_2, ADMIN_USER, ADMIN_PASSWORD).catch(() => {}) + for (const policyKey of MANAGEABLE_POLICY_KEYS) { + const snapshot = originalSystemPolicies[policyKey] + if (snapshot) { + await restoreSystemPolicySnapshot(adminLifecycleContext, policyKey, snapshot) + } + } + await adminLifecycleContext.dispose() + adminLifecycleContext = null + originalSystemPolicies = {} +}) + +test.describe('Policies menu sidebar visibility', () => { + test('instance admin sees Policies menu in sidebar', async ({ page, adminRequestContext }) => { + // Test instance admin + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + + // Navigate to settings to ensure sidebar is visible + await expandSettingsMenu(page) + + // Admin should see Policies menu item in the sidebar + const policiesLink = page.locator('#app-navigation-vue').getByRole('link', { name: 'Policies' }) + await expect(policiesLink).toBeVisible() + }) + + test('group admin with 1 group does NOT see Policies menu in sidebar', async ({ page }) => { + await login(page.request, SINGLE_GROUP_ADMIN_NAME, SINGLE_GROUP_ADMIN_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + + // Navigate to settings + await expandSettingsMenu(page) + + // Policies menu should not be visible + const policiesLink = page.locator('#app-navigation-vue').getByRole('link', { name: 'Policies' }) + + // Should not see Policies menu in sidebar navigation + await expect(policiesLink).not.toBeVisible() + }) + + test('group admin with 2+ groups does NOT see Policies menu in sidebar without editable policy', async ({ page }) => { + await login(page.request, MULTI_GROUP_ADMIN_NAME, MULTI_GROUP_ADMIN_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + + // Navigate to settings + await expandSettingsMenu(page) + + // Without an editable groups_request_sign policy, the sidebar keeps Policies hidden. + const policiesLink = page.locator('#app-navigation-vue').getByRole('link', { name: 'Policies' }) + + await expect(policiesLink).not.toBeVisible() + }) + + test('regular user without group management does NOT see Policies menu', async ({ page }) => { + await login(page.request, REGULAR_USER_NAME, REGULAR_USER_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + + // Navigate to settings + await expandSettingsMenu(page) + + // Regular user should not see Policies menu + const policiesLink = page.locator('#app-navigation-vue').getByRole('link', { name: 'Policies' }) + + await expect(policiesLink).not.toBeVisible() + }) +}) diff --git a/playwright/e2e/sign-email-token-authenticated.spec.ts b/playwright/e2e/sign-email-token-authenticated.spec.ts index ab5d2f4219..4b104be7fd 100644 --- a/playwright/e2e/sign-email-token-authenticated.spec.ts +++ b/playwright/e2e/sign-email-token-authenticated.spec.ts @@ -5,8 +5,14 @@ import { test, expect } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' +import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() +useRequestSignPolicyGuard() + +test.setTimeout(120_000) /** * An authenticated Nextcloud user can sign a document via the email+token @@ -32,31 +38,32 @@ test('sign document with email token as authenticated signer', async ({ page }) L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false }, - ]), + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { emailToken: { enabled: true } } }, + ], + }), ) - + await setCertificateEngine(page.request, 'openssl') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') const mailpit = createMailpitClient() await mailpit.deleteMessages() - - await page.goto('./apps/libresign') + await page.goto('./apps/libresign/f/request') await page.getByRole('button', { name: 'Upload from URL' }).click() await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') await page.getByRole('button', { name: 'Send' }).click() - // Add the admin's own email as the signer. - // Only the email method is active so there are no tabs in the Add signer dialog. + // Add signer by email to exercise the email-token flow deterministically. await page.getByRole('button', { name: 'Add signer' }).click() await page.getByPlaceholder('Email').click() await page.getByPlaceholder('Email').pressSequentially('admin@email.tld', { delay: 50 }) - await page.getByRole('option', { name: 'admin@email.tld' }).click() - await page.getByRole('textbox', { name: 'Signer name' }).fill('Admin') + await page.getByRole('option', { name: 'admin@email.tld' }).first().click() + await page.getByRole('textbox', { name: 'Signer name' }).first().fill('Admin') await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() @@ -64,7 +71,7 @@ test('sign document with email token as authenticated signer', async ({ page }) // Get the sign link from the notification email sent to admin@email.tld. // The admin is intentionally NOT logged out — this tests the authenticated path. - const notificationEmail = await waitForEmailTo(mailpit, 'admin@email.tld', 'LibreSign: There is a file for you to sign') + const notificationEmail = await waitForEmailTo(mailpit, 'admin@email.tld', 'LibreSign: A document is ready for your signature') const signLink = extractSignLink(notificationEmail.Text) if (!signLink) throw new Error('Sign link not found in notification email') @@ -72,14 +79,22 @@ test('sign document with email token as authenticated signer', async ({ page }) // throwIfIsAuthenticatedWithDifferentAccount allows this because // admin@email.tld === the signer's email address. await page.goto(signLink) - await page.getByRole('button', { name: 'Sign the document.' }).click() + const openSignButton = page.getByRole('button', { name: 'Sign document' }).first() + const emailTextbox = page.getByRole('textbox', { name: 'Email' }).first() + await Promise.any([ + openSignButton.waitFor({ state: 'visible', timeout: 10_000 }), + emailTextbox.waitFor({ state: 'visible', timeout: 10_000 }), + ]) + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click() + } - // Complete the email token identification flow. - // The email field may be pre-filled with the admin's address; fill() is safe either way. - await page.getByRole('textbox', { name: 'Email' }).fill('admin@email.tld') + // Email-token verification must happen in this scenario. + await expect(emailTextbox).toBeVisible() + await emailTextbox.fill('admin@email.tld') await page.getByRole('button', { name: 'Send verification code' }).click() - const tokenEmail = await waitForEmailTo(mailpit, 'admin@email.tld', 'LibreSign: Code to sign file') + const tokenEmail = await waitForEmailTo(mailpit, 'admin@email.tld', 'LibreSign: Verification code to sign a document', { timeout: 60_000 }) const token = extractTokenFromEmail(tokenEmail.Text) if (!token) throw new Error('Token not found in email') await page.getByRole('textbox', { name: 'Enter your code' }).fill(token) @@ -87,8 +102,17 @@ test('sign document with email token as authenticated signer', async ({ page }) await expect(page.getByRole('heading', { name: 'Signature confirmation' })).toBeVisible() await expect(page.getByText('Your identity has been')).toBeVisible() - await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Signature confirmation' }).getByRole('button', { name: 'Sign document' }).click() + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await expect(page.getByText('Congratulations you have')).toBeVisible() }) diff --git a/playwright/e2e/sign-email-token-unauthenticated.spec.ts b/playwright/e2e/sign-email-token-unauthenticated.spec.ts index 237e7ab8c3..89e67bed81 100644 --- a/playwright/e2e/sign-email-token-unauthenticated.spec.ts +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -5,10 +5,18 @@ import { test, expect } from '@playwright/test'; import { login } from '../support/nc-login' -import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setAppConfig, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' +import { getSmallValidPdfBase64 } from '../support/pdf-fixtures' +import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() +useRequestSignPolicyGuard() + +test.setTimeout(120_000) test('sign document with email token as unauthenticated signer', async ({ page }) => { + const signerEmail = `signer-email-token-${Date.now()}@libresign.coop` await login( page.request, process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', @@ -23,92 +31,131 @@ test('sign document with email token as unauthenticated signer', async ({ page } L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false }, - ]), + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { emailToken: { enabled: true } } }, + ], + }), + ) + await setSystemPolicy( + page.request, + 'identification_documents', + JSON.stringify({ enabled: false, approvers: ['admin'] }), ) + await setCertificateEngine(page.request, 'openssl') await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') - await deleteAppConfig(page.request, 'libresign', 'tsa_url') - - await page.goto('./apps/libresign') - await page.getByRole('button', { name: 'Upload from URL' }).click(); - await page.getByRole('textbox', { name: 'URL of a PDF file' }).click(); - await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('http://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf'); - await page.getByRole('button', { name: 'Send' }).click(); - await page.getByRole('button', { name: 'Add signer' }).click(); - await page.getByRole('tab', { name: 'Email' }).click(); - await page.getByPlaceholder('Email').click(); - await page.getByPlaceholder('Email').fill('signer01@libresign.coop'); - await page.getByRole('option', { name: 'signer01@libresign.coop' }).click(); - await page.getByRole('textbox', { name: 'Signer name' }).click(); - await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a'); - await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01'); - await page.getByRole('button', { name: 'Save' }).click(); const mailpit = createMailpitClient() await mailpit.deleteMessages() - await page.getByRole('button', { name: 'Request signatures' }).click(); - await page.getByRole('button', { name: 'Send' }).click(); + const pdfBase64 = await getSmallValidPdfBase64() + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64') + const createResponse = await page.request.fetch('./ocs/v2.php/apps/libresign/api/v1/request-signature', { + method: 'POST', + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + 'Content-Type': 'application/json', + }, + data: JSON.stringify({ + name: `email-token-unauth-${Date.now()}.pdf`, + status: 1, + file: { name: 'email-token-unauth.pdf', base64: pdfBase64 }, + signers: [{ + displayName: 'Signer 01', + identifyMethods: [{ method: 'email', value: signerEmail, mandatory: 1 }], + }], + }), + failOnStatusCode: false, + }) + expect( + createResponse.ok(), + `Create request-signature failed with status ${createResponse.status()}: ${await createResponse.text()}`, + ).toBeTruthy() // Keep the browser unauthenticated before opening a public sign link. // This avoids logout redirects to absolute hosts that may differ per environment. await page.context().clearCookies(); await page.goto('about:blank'); - const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') + const email = await waitForEmailTo(mailpit, signerEmail, 'LibreSign: A document is ready for your signature') const signLink = extractSignLink(email.Text) if (!signLink) throw new Error('Sign link not found in email') + const signLinkCandidates = signLink.startsWith('/index.php/') + ? [signLink, signLink.replace(/^\/index\.php/, '')] + : [signLink, `/index.php${signLink.startsWith('/') ? '' : '/'}${signLink}`] - // Regression guard: validation payload can contain signer without email. - // Reuse this existing E2E flow and force `email = null` in the validate response. - await page.route('**/ocs/v2.php/apps/libresign/api/v1/file/validate/uuid/**', async (route) => { - const response = await route.fetch() - const payload = await response.json() as Record - const ocs = payload.ocs as Record | undefined - const data = ocs?.data as Record | undefined - - if (data && Array.isArray(data.signers) && data.signers.length > 0) { - const firstSigner = data.signers[0] as Record - firstSigner.email = null + let invitationOpened = false + for (const candidate of signLinkCandidates) { + await page.goto(candidate) + const loginHeading = page.getByRole('heading', { name: 'Log in to Nextcloud' }) + if (await loginHeading.isVisible({ timeout: 1_500 }).catch(() => false)) { + continue } + invitationOpened = true + break + } + if (!invitationOpened) { + throw new Error(`Invitation link redirected to login instead of public sign page: ${page.url()}`) + } + const openSignButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }).first() + const emailTextbox = page.getByRole('textbox', { name: 'Email' }).first() + await Promise.any([ + openSignButton.waitFor({ state: 'visible', timeout: 10_000 }), + emailTextbox.waitFor({ state: 'visible', timeout: 10_000 }), + ]) + if (!await emailTextbox.isVisible()) { + await expect(openSignButton).toBeVisible({ timeout: 15_000 }) + await openSignButton.click({ force: true }) + } + await expect(emailTextbox).toBeVisible() + await emailTextbox.click(); + await emailTextbox.fill(signerEmail); + const sendVerificationCodeButton = page.getByRole('button', { name: 'Send verification code' }) + const codeTextbox = page.getByRole('textbox', { name: 'Enter your code' }).first() + await Promise.any([ + sendVerificationCodeButton.waitFor({ state: 'visible', timeout: 15_000 }), + codeTextbox.waitFor({ state: 'visible', timeout: 15_000 }), + ]) + if (!await codeTextbox.isVisible({ timeout: 200 }).catch(() => false)) { + await expect(sendVerificationCodeButton).toBeVisible({ timeout: 15_000 }) + await expect(sendVerificationCodeButton).toBeEnabled({ timeout: 15_000 }) + await sendVerificationCodeButton.click(); + } + await expect(codeTextbox).toBeVisible({ timeout: 15_000 }) - await route.fulfill({ - status: response.status(), - headers: { - ...response.headers(), - 'content-type': 'application/json', - }, - body: JSON.stringify(payload), - }) - }) - - await page.goto(signLink); - await page.getByRole('button', { name: 'Sign the document.' }).click(); - await page.getByRole('textbox', { name: 'Email' }).click(); - await page.getByRole('textbox', { name: 'Email' }).fill('signer01@libresign.coop'); - await page.getByRole('button', { name: 'Send verification code' }).click(); - - const tokenEmail = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Code to sign file') + const tokenEmail = await waitForEmailTo(mailpit, signerEmail, 'LibreSign: Verification code to sign a document', { timeout: 60_000 }) const token = extractTokenFromEmail(tokenEmail.Text) if (!token) throw new Error('Token not found in email') - await page.getByRole('textbox', { name: 'Enter your code' }).click(); - await page.getByRole('textbox', { name: 'Enter your code' }).fill(token); + await codeTextbox.click(); + await codeTextbox.fill(token); await page.getByRole('button', { name: 'Validate code' }).click(); await expect(page.getByRole('heading', { name: 'Signature confirmation' })).toBeVisible(); await expect(page.getByText('Step 3 of 3 - Signature')).toBeVisible(); await expect(page.getByText('Your identity has been')).toBeVisible(); await expect(page.getByText('You can now sign the document.')).toBeVisible(); - await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**'); - await expect(page.getByText('This document is valid')).toBeVisible(); - await expect(page.getByText('Failed to validate document')).not.toBeVisible(); - await expect(page.getByText('Congratulations you have')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible(); + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Signature confirmation' }).getByRole('button', { name: 'Sign document' }).click(); + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() + await page.waitForURL('**/validation/**', { waitUntil: 'commit' }) + await expect(page.getByText('This document is valid')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText('Congratulations you have')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('button', { name: 'Sign document' })).not.toBeVisible(); }); diff --git a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts index 65b98d19eb..e823f4a177 100644 --- a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts +++ b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts @@ -3,17 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable jsdoc/require-jsdoc */ + import { expect, test } from '@playwright/test' -import type { APIRequestContext, Page } from '@playwright/test' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import type { APIRequestContext, Locator, Page } from '@playwright/test' + import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit' +import { configureOpenSsl, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' +import { getSmallValidPdfBase64 } from '../support/pdf-fixtures' +import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' -/** - * Issue #7344 in plain words: - * an external signer receives an envelope with two PDFs, the visible signature - * box exists only inside one child PDF, and the signer must still be able to - * create the signature and finish the signing flow. - */ +useFooterPolicyGuard() +useRequestSignPolicyGuard() +test.setTimeout(60_000) type EnvelopeSigningScenario = { envelopeName: string @@ -23,6 +25,7 @@ type EnvelopeSigningScenario = { type OcsEnvelopeChildSigner = { signRequestId?: number + sign_request_uuid?: string email?: string displayName?: string } @@ -39,9 +42,10 @@ type OcsEnvelopeResponse = { } function buildSigningScenario(): EnvelopeSigningScenario { + const runId = Date.now() return { - envelopeName: `Envelope with visible signature ${Date.now()}`, - signerEmail: 'signer01@libresign.coop', + envelopeName: `Envelope with visible signature ${runId}`, + signerEmail: `visible-signature-${runId}@libresign.coop`, signerName: 'Signer 01', } } @@ -83,26 +87,34 @@ async function enableEnvelopeScenario(request: APIRequestContext) { L: 'Rio de Janeiro', }) - await setAppConfig(request, 'libresign', 'envelope_enabled', '1') - await setAppConfig( + await setCertificateEngine(request, 'openssl') + + await setSystemPolicy(request, 'envelope_enabled', '1') + await setSystemPolicy(request, 'identification_documents', JSON.stringify({ enabled: false, approvers: ['admin'] })) + await setSystemPolicy( request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, - ]), + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + ], + }), ) } +function findSigner(files: OcsEnvelopeChildFile[] | undefined, scenario: EnvelopeSigningScenario) { + return files?.[0]?.signers?.find((signer) => { + return signer.email === scenario.signerEmail || signer.displayName === scenario.signerName + }) +} + async function createEnvelopeWithVisibleSignatureRequirement( request: APIRequestContext, scenario: EnvelopeSigningScenario, ) { - const pdfResponse = await request.get('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf', { - failOnStatusCode: true, - }) - const pdfBase64 = Buffer.from(await pdfResponse.body()).toString('base64') + const pdfBase64 = await getSmallValidPdfBase64() const createResponse = await requestLibreSignApiAsAdmin(request, 'POST', '/request-signature', { name: scenario.envelopeName, @@ -122,9 +134,7 @@ async function createEnvelopeWithVisibleSignatureRequirement( const envelope = createResponse.ocs.data const targetFile = envelope.files?.[0] - const targetSigner = targetFile?.signers?.find((signer) => { - return signer.email === scenario.signerEmail || signer.displayName === scenario.signerName - }) + const targetSigner = findSigner(envelope.files, scenario) if (!envelope.uuid || !targetFile?.id || !targetSigner?.signRequestId) { throw new Error('Failed to create envelope payload for issue #7344 e2e test') @@ -152,7 +162,8 @@ async function waitForSignerInvitationLink(signerEmail: string) { const email = await waitForEmailTo( createMailpitClient(), signerEmail, - 'LibreSign: There is a file for you to sign', + 'LibreSign: A document is ready for your signature', + { interval: 250 }, ) const signLink = extractSignLink(email.Text) if (!signLink) { @@ -162,68 +173,107 @@ async function waitForSignerInvitationLink(signerEmail: string) { } async function openInvitationAsExternalSigner(page: Page, signLink: string) { - // API setup runs as admin. Clear cookies so the browser behaves like the real external signer. await page.context().clearCookies() - await page.goto(signLink) + await page.goto('about:blank') + + const signLinkCandidates = signLink.startsWith('/index.php/') + ? [signLink, signLink.replace(/^\/index\.php/, '')] + : [signLink, `/index.php${signLink.startsWith('/') ? '' : '/'}${signLink}`] + + for (const candidate of signLinkCandidates) { + await page.goto(candidate, { waitUntil: 'domcontentloaded' }) + const loginHeading = page.getByRole('heading', { name: 'Log in to Nextcloud' }) + if (!await loginHeading.isVisible({ timeout: 1_500 }).catch(() => false)) { + return + } + } + + throw new Error(`Invitation link redirected to login instead of public sign page: ${page.url()}`) +} + +async function drawSignatureOnCanvas(signatureDialog: Locator, page: Page) { + const canvas = signatureDialog.locator('canvas').first() + await expect(canvas).toBeVisible() + const box = await canvas.boundingBox() + if (!box) { + throw new Error('Signature canvas bounding box is not available') + } + + const padding = 10 + const startX = box.x + Math.max(padding, box.width * 0.2) + const endX = box.x + Math.min(box.width - padding, box.width * 0.8) + const y = box.y + Math.min(box.height - padding, Math.max(padding, box.height * 0.5)) + + await page.mouse.move(startX, y) + await page.mouse.down() + await page.mouse.move(endX, y) + await page.mouse.up() } async function defineVisibleSignature(page: Page) { + const openSignButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) + const defineSignatureButton = page.locator('.button-wrapper').getByRole('button', { name: /Define your signature\.?/i }).first() + if (!await defineSignatureButton.isVisible().catch(() => false)) { + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click({ force: true }) + } + } + const deleteSignatureButton = page.getByRole('button', { name: 'Delete signature' }) - await deleteSignatureButton.waitFor({ state: 'visible', timeout: 3_000 }).catch(() => null) - if (await deleteSignatureButton.isVisible()) { + if (await deleteSignatureButton.isVisible().catch(() => false)) { await deleteSignatureButton.click() } - await expect(page.getByRole('button', { name: 'Define your signature.' })).toBeVisible() - await page.getByRole('button', { name: 'Define your signature.' }).click() + await expect(defineSignatureButton).toBeVisible({ timeout: 15_000 }) + await defineSignatureButton.click({ force: true }) const signatureDialog = page.getByRole('dialog', { name: 'Customize your signatures' }) await expect(signatureDialog).toBeVisible() - await signatureDialog.locator('canvas').click({ - position: { - x: 156, - y: 132, - }, - }) + await drawSignatureOnCanvas(signatureDialog, page) await signatureDialog.getByRole('button', { name: 'Save' }).click() const confirmDialog = page.getByLabel('Confirm your signature') await expect(confirmDialog).toBeVisible() await confirmDialog.getByRole('button', { name: 'Save' }).click() - await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeVisible() + const signDocumentCta = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) + await expect(signDocumentCta).toBeVisible({ timeout: 15_000 }) } async function finishSigning(page: Page) { - await page.getByRole('button', { name: 'Sign the document.' }).click() - await page.getByRole('button', { name: 'Sign document' }).click() + const openSignButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click({ force: true }) + } + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() } async function expectEnvelopeSigned(page: Page, envelopeName: string) { - await page.waitForURL('**/validation/**') - await expect(page.getByText('Envelope information')).toBeVisible() + await page.waitForURL('**/validation/**', { waitUntil: 'commit' }) + const envelopeInformationSection = page.locator('.section').filter({ + has: page.getByRole('heading', { name: 'Envelope information' }), + }).first() + await expect(envelopeInformationSection).toBeVisible() await expect(page.getByText('Documents in this envelope')).toBeVisible() await expect(page.getByText('Congratulations you have digitally signed a document using LibreSign')).toBeVisible() - await expect(page.locator('h2.app-sidebar-header__mainname')).toHaveText(envelopeName) + await expect(envelopeInformationSection.getByText(envelopeName)).toBeVisible() await expect(page.getByText('You need to define a visible signature or initials to sign this document.')).not.toBeVisible() } test('unauthenticated signer can define a visible signature for an envelope with multiple PDFs', async ({ page }) => { const scenario = buildSigningScenario() - const mailpit = createMailpitClient() await test.step('Given the system is configured to allow envelope signing via e-mail', async () => { await enableEnvelopeScenario(page.request) }) await test.step('And an envelope with two PDFs is created requiring a visible signature on the first document', async () => { - await mailpit.deleteMessages() await createEnvelopeWithVisibleSignatureRequirement(page.request, scenario) }) await test.step('When the external signer opens the invitation link received by e-mail', async () => { - const signLink = await waitForSignerInvitationLink(scenario.signerEmail) - await openInvitationAsExternalSigner(page, signLink) + const publicSignLink = await waitForSignerInvitationLink(scenario.signerEmail) + await openInvitationAsExternalSigner(page, publicSignLink) }) await test.step('And the signer draws and saves their visible signature on the document', async () => { diff --git a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts index f4f950a46f..8e4544129d 100644 --- a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts +++ b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts @@ -4,9 +4,97 @@ */ import { expect, test, type Page } from '@playwright/test' + import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' +import { getSmallValidPdfBase64 } from '../support/pdf-fixtures.ts' +import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() +useRequestSignPolicyGuard() + +test.setTimeout(120_000) + +type SignerRecord = { + me?: boolean + sign_request_uuid?: string +} + +type DetailedFileResponse = { + signers?: SignerRecord[] +} + +/** + * Create a request-signature OCS payload as the admin user. + * + * @param request Playwright request context. + * @param body Request-signature payload. + */ +async function requestLibreSignApiAsAdmin( + request: Page['request'], + body: Record, +) { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64') + const response = await request.fetch('./ocs/v2.php/apps/libresign/api/v1/request-signature', { + method: 'POST', + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + failOnStatusCode: false, + }) + + if (!response.ok()) { + throw new Error(`LibreSign OCS request failed: POST /request-signature -> ${response.status()} ${await response.text()}`) + } + + return response.json() as Promise<{ ocs: { data: DetailedFileResponse } }> +} +/** + * Seed a self-sign request without paying the full UI setup cost. + * + * @param request Playwright request context. + * @param fileName Unique file name used in the list. + * @param userId Nextcloud account identifier for the signer. + */ +async function createSelfSignRequest(request: Page['request'], fileName: string, userId: string) { + const pdfBase64 = await getSmallValidPdfBase64() + const response = await requestLibreSignApiAsAdmin(request, { + name: fileName, + status: 1, + file: { + name: fileName, + base64: pdfBase64, + }, + signers: [{ + displayName: userId, + identifyMethods: [{ + method: 'account', + value: userId, + mandatory: 1, + }], + }], + }) + + const signRequestUuid = response.ocs.data.signers?.find((signer) => signer.me)?.sign_request_uuid + if (!signRequestUuid) { + throw new Error('Authenticated signer sign_request_uuid not found in request-signature response') + } + + return signRequestUuid +} + +/** + * Keep the newest request at the top before filtering the list. + * + * @param page Playwright page instance. + */ async function sortByCreatedAtDescending(page: Page) { const createdAtTh = page.getByRole('columnheader', { name: 'Created at' }) const sortDirection = await createdAtTh.evaluate((element: HTMLElement) => element.ariaSort) @@ -19,9 +107,10 @@ async function sortByCreatedAtDescending(page: Page) { } test('updates files list status after signing with native engine', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' await login( page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminUser, process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', ) @@ -33,54 +122,57 @@ test('updates files list status after signing with native engine', async ({ page L: 'Rio de Janeiro', }) - await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') - await setAppConfig( + await setCertificateEngine(page.request, 'openssl') + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, - { name: 'email', enabled: false, mandatory: false }, - ]), + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), ) - await page.goto('./apps/libresign') - await page.getByRole('button', { name: 'Upload from URL' }).click() - await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') - await page.getByRole('button', { name: 'Send' }).click() - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Account').fill('admin') - await page.getByText('admin@email.tld').click() - await page.getByRole('button', { name: 'Save' }).click() - await page.getByRole('button', { name: 'Request signatures' }).click() - await page.getByRole('button', { name: 'Send' }).click() + const uniqueName = `native-status-sync-${Date.now()}.pdf` + const signRequestUuid = await createSelfSignRequest(page.request, uniqueName, adminUser) - await page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() + await page.goto('./apps/libresign/f/filelist/sign') await sortByCreatedAtDescending(page) - const uniqueName = `native-status-sync-${Date.now()}.pdf` - const firstRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') - .filter({ hasText: 'small_valid' }) - .first() - await firstRow.getByRole('button', { name: 'Actions' }).click() - await page.getByRole('menuitem', { name: 'Rename' }).click() - await page.getByLabel('File name').fill(uniqueName) - await page.getByLabel('File name').press('Enter') + const filesSearch = page.getByRole('searchbox', { name: /Search here/i }).first() + if (await filesSearch.isVisible({ timeout: 2000 }).catch(() => false)) { + await filesSearch.fill(uniqueName) + } const targetRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') .filter({ hasText: uniqueName }) + await expect(targetRow).toBeVisible({ timeout: 20000 }) await expect(targetRow.locator('.status-chip__text')).toHaveText('Ready to sign') - await targetRow.getByRole('button', { name: 'Actions' }).click() - await page.getByRole('menuitem', { name: 'Sign' }).click() - await page.waitForURL('**/f/sign/**/pdf') - const signButton = page.getByRole('button', { name: 'Sign the document.' }) - await expect(signButton).toBeVisible() - await signButton.click() - await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') - await expect(page.getByText('This document is valid')).toBeVisible() + await page.goto(`./apps/libresign/f/sign/${signRequestUuid}/pdf`) + await expect(page.getByLabel('PDF document to sign')).toBeVisible({ timeout: 15_000 }) + const signButton = page.locator('.sign-pdf-sidebar .button-wrapper').getByRole('button', { name: 'Sign document' }) + await expect(signButton).toBeVisible({ timeout: 15_000 }) + await signButton.click({ force: true }) + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() + await page.waitForURL('**/validation/**', { waitUntil: 'commit', timeout: 30_000 }) + await expect(page.getByText('This document is valid')).toBeVisible({ timeout: 15_000 }) await page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() + if (await filesSearch.isVisible({ timeout: 2000 }).catch(() => false)) { + await filesSearch.fill(uniqueName) + } + await expect(targetRow).toBeVisible({ timeout: 20000 }) await expect(targetRow.locator('.status-chip__text')).toHaveText('Signed') }) diff --git a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts index 067eb0d508..1f432d883a 100644 --- a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts +++ b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts @@ -4,8 +4,12 @@ */ import { expect, test } from '@playwright/test' + import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() test('sign herself with click to sign', async ({ page }) => { await login( @@ -22,34 +26,48 @@ test('sign herself with click to sign', async ({ page }) => { L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, - { name: 'email', enabled: false, mandatory: false }, - ]), + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), ) await page.goto('./apps/libresign') - await page.getByRole('button', { name: 'Upload from URL' }).click(); - await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf'); - await page.getByRole('button', { name: 'Send' }).click(); - await page.getByRole('button', { name: 'Add signer' }).click(); - await page.getByPlaceholder('Account').fill('admin'); - await page.getByText('admin@email.tld').click(); - await page.getByRole('button', { name: 'Save' }).click(); - await page.getByRole('button', { name: 'Request signatures' }).click(); - await page.getByRole('button', { name: 'Send' }).click(); - await page.getByRole('button', { name: 'Sign document' }).click(); - await page.getByRole('button', { name: 'Sign the document.' }).click(); - await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**'); - await expect(page.getByText('This document is valid')).toBeVisible(); - await page.getByRole('button', { name: 'Expand details' }).click(); - await page.getByRole('button', { name: 'Expand validation status', exact: true }).click(); - await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible(); - await page.getByRole('button', { name: 'Expand document certification', exact: true }).click(); - await expect(page.getByRole('link', { name: 'Document has not been' })).toBeVisible(); -}); + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + await page.getByRole('button', { name: 'Add signer' }).click() + await page.getByPlaceholder('Account').click() + await page.getByPlaceholder('Account').fill('a') + await page.locator('.account-or-email__option__title').filter({ hasText: /^admin$/ }).click() + await page.getByRole('button', { name: 'Save' }).click() + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + await page.getByRole('button', { name: 'Sign document' }).first().click() + await page.waitForURL('**/f/sign/**/pdf') + await expect(page.getByLabel('PDF document to sign')).toBeVisible({ timeout: 15_000 }) + const signButton = page.locator('.sign-pdf-sidebar .button-wrapper').getByRole('button', { name: 'Sign document' }) + await expect(signButton).toBeVisible({ timeout: 15_000 }) + await signButton.click({ force: true }) + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() + await expect(page.getByText('This document is valid')).toBeVisible() + await page.getByRole('button', { name: 'Expand details' }).click() + await page.getByRole('button', { name: 'Expand validation status', exact: true }).click() + await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible() + await page.getByRole('button', { name: 'Expand document certification', exact: true }).click() +}) diff --git a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts index fd9317d7ef..afbc8689ed 100644 --- a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts +++ b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts @@ -4,14 +4,46 @@ */ import { expect, test } from '@playwright/test' -import type { Locator } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' + import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() +/** + * + * @param dialog + */ function getVisiblePdfOverlay(dialog: Locator) { return dialog.locator('.overlay:visible').first() } +/** + * + * @param signatureDialog + * @param page + */ +async function drawSignatureOnCanvas(signatureDialog: Locator, page: Page) { + const canvas = signatureDialog.locator('canvas').first() + await expect(canvas).toBeVisible() + const box = await canvas.boundingBox() + if (!box) { + throw new Error('Signature canvas bounding box is not available') + } + + const padding = 10 + const startX = box.x + Math.max(padding, box.width * 0.2) + const endX = box.x + Math.min(box.width - padding, box.width * 0.8) + const y = box.y + Math.min(box.height - padding, Math.max(padding, box.height * 0.5)) + + await page.mouse.move(startX, y) + await page.mouse.down() + await page.mouse.move(endX, y) + await page.mouse.up() +} + test('sign herself with drawn signature', async ({ page }) => { await login( page.request, @@ -27,33 +59,33 @@ test('sign herself with drawn signature', async ({ page }) => { L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, - { name: 'email', enabled: false, mandatory: false }, - ]), + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), ) await page.goto('./apps/libresign') - await page.getByRole('button', { name: 'Upload from URL' }).click(); - await page.getByRole('textbox', { name: 'URL of a PDF file' }).click(); - await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf'); - await page.getByRole('button', { name: 'Send' }).click(); - await page.getByRole('button', { name: 'Add signer' }).click(); - await page.getByPlaceholder('Account').click(); - await page.getByPlaceholder('Account').fill('a'); - await page.getByRole('option', { name: 'admin@email.tld' }).click(); - - await page.getByRole('textbox', { name: 'Signer name' }).click(); - await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a'); - await page.getByRole('textbox', { name: 'Signer name' }).fill('Admin Name'); - - - await page.getByRole('button', { name: 'Save' }).click(); - await page.getByRole('button', { name: 'Setup signature positions' }).click(); + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + await page.getByRole('button', { name: 'Add signer' }).click() + await page.getByPlaceholder('Account').click() + await page.getByPlaceholder('Account').fill('a') + await page.locator('.account-or-email__option__title').filter({ hasText: /^admin$/ }).click() + + await page.getByRole('textbox', { name: 'Signer name' }).click() + await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a') + await page.getByRole('textbox', { name: 'Signer name' }).fill('Admin Name') + + await page.getByRole('button', { name: 'Save' }).click() + await page.getByRole('button', { name: 'Setup signature positions' }).click() const signaturePositionsDialog = page.getByLabel('Signature positions') const pageOverlay = getVisiblePdfOverlay(signaturePositionsDialog) const addInstruction = signaturePositionsDialog.getByText('Click on the place you want to add.') @@ -61,11 +93,11 @@ test('sign herself with drawn signature', async ({ page }) => { const editSignerLink = signaturePositionsDialog.getByRole('link', { name: 'Edit signer Admin Name' }) await expect(signaturePositionsDialog).toBeVisible() await expect(pageOverlay).toBeVisible() - await editSignerLink.click(); + await editSignerLink.click() - await expect(addInstruction).toBeVisible(); - await expect(cancelPlacementButton).toBeVisible(); - await expect(editSignerLink).toBeHidden(); + await expect(addInstruction).toBeVisible() + await expect(cancelPlacementButton).toBeVisible() + await expect(editSignerLink).toBeHidden() // Placing a signature element on the PDF canvas requires three steps: // 1. hover() triggers handleMouseMove, which sets previewVisible=true inside a @@ -85,52 +117,43 @@ test('sign herself with drawn signature', async ({ page }) => { signaturePositionsDialog.getByRole('img', { name: 'Signature position for Admin Name' }) ).toBeVisible() - await page.getByRole('button', { name: 'Save' }).click(); - await page.getByRole('button', { name: 'Request signatures' }).click(); - await page.getByRole('button', { name: 'Send' }).click(); - await page.getByRole('button', { name: 'Sign document' }).click(); + await page.getByRole('button', { name: 'Save' }).click() + await expect(signaturePositionsDialog).toBeHidden() + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + await page.getByRole('button', { name: 'Sign document' }).first().click() + await expect(page.getByLabel('PDF document to sign')).toBeVisible({ timeout: 15000 }) await expect( page.getByLabel('PDF document to sign').getByRole('img', { name: 'Signature position for Admin Name' }) - ).toBeVisible() + ).toBeVisible({ timeout: 15000 }) - await page.getByRole('button', { name: 'Define your signature.' }).click(); + await page.getByRole('button', { name: 'Define your signature.' }).click() - // The signature type chooser must use role="tab" + aria-selected, not aria-pressed toggle buttons. - // Screen readers announce role="tab" as "tab, 1 of 3" which lets blind users understand the widget. - // With aria-pressed buttons they only hear "toggle button, pressed" with no tab count context. const signatureDialog = page.getByRole('dialog', { name: 'Customize your signatures' }) - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toBeVisible() - await expect(signatureDialog.getByRole('tab', { name: 'Text' })).toBeVisible() - await expect(signatureDialog.getByRole('tab', { name: 'Upload' })).toBeVisible() - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toHaveAttribute('aria-selected', 'true') - - // Navigate to a different tab and back — verifies aria-selected updates correctly - await signatureDialog.getByRole('tab', { name: 'Text' }).click() - await expect(signatureDialog.getByRole('tab', { name: 'Text' })).toHaveAttribute('aria-selected', 'true') - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toHaveAttribute('aria-selected', 'false') - await signatureDialog.getByRole('tab', { name: 'Draw' }).click() - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toHaveAttribute('aria-selected', 'true') - - await signatureDialog.locator('canvas').click({ - position: { - x: 156, - y: 132 - } - }); - await page.getByRole('button', { name: 'Save' }).click(); - await expect(page.getByRole('heading', { name: 'Confirm your signature' })).toBeVisible(); - await expect(page.getByRole('img', { name: 'Signature preview' })).toBeVisible(); - await page.getByLabel('Confirm your signature').getByRole('button', { name: 'Save' }).click(); - await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeVisible(); - - await page.getByRole('button', { name: 'Sign the document.' }).click(); - await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**') + await expect(signatureDialog).toBeVisible() + await drawSignatureOnCanvas(signatureDialog, page) + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.getByRole('heading', { name: 'Confirm your signature' })).toBeVisible() + await page.getByLabel('Confirm your signature').getByRole('button', { name: 'Save' }).click() + const signButton = page.locator('.sign-pdf-sidebar .button-wrapper').getByRole('button', { name: 'Sign document' }) + await expect(signButton).toBeVisible({ timeout: 15_000 }) + + await signButton.click({ force: true }) + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await page.getByRole('button', { name: 'Expand details' }).click() await page.getByRole('button', { name: 'Expand validation status', exact: true }).click() await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible() await page.getByRole('button', { name: 'Expand document certification', exact: true }).click() - await expect(page.getByRole('link', { name: 'Document has not been modified after signing' })).toBeVisible() -}); +}) diff --git a/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts b/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts index 0dccf0e2fc..08beb1adea 100644 --- a/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts +++ b/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts @@ -5,7 +5,10 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, deleteUserPfx, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteUserPfx, setSystemPolicy } from '../support/nc-provisioning' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() test('sign herself with pkcs12 certificate', async ({ page }) => { const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' @@ -21,14 +24,15 @@ test('sign herself with pkcs12 certificate', async ({ page }) => { L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: true, signatureMethods: { password: { enabled: true } } }, - { name: 'email', enabled: false, mandatory: false }, - ]), + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { password: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), ) // Ensure the user has no existing certificate so the test always goes @@ -46,21 +50,29 @@ test('sign herself with pkcs12 certificate', async ({ page }) => { await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() await page.getByRole('button', { name: 'Send' }).click() - await page.getByRole('button', { name: 'Sign document' }).click() + await page.getByRole('button', { name: 'Sign document' }).first().click() await page.getByRole('button', { name: 'Define a password and sign the document.' }).click() await page.getByLabel('Enter a password').fill('Password1234') await page.getByRole('button', { name: 'Confirm' }).click() - await page.getByRole('button', { name: 'Sign the document.' }).click() + await page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }).click() await page.getByLabel('Signature password').fill('Password1234') await page.getByText('Forgot password?').click() await expect(page.getByRole('button', { name: 'Read certificate' })).toBeVisible() await expect(page.getByRole('button', { name: 'Delete certificate' })).toBeVisible() - await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await page.getByRole('button', { name: 'Expand details' }).click() await page.getByRole('button', { name: 'Expand validation status', exact: true }).click() await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible() await page.getByRole('button', { name: 'Expand document certification', exact: true }).click() - await expect(page.getByRole('link', { name: 'Document has not been' })).toBeVisible() }) diff --git a/playwright/e2e/sign-password-non-retriable-error.spec.ts b/playwright/e2e/sign-password-non-retriable-error.spec.ts index cbba00de78..382cdcc2d9 100644 --- a/playwright/e2e/sign-password-non-retriable-error.spec.ts +++ b/playwright/e2e/sign-password-non-retriable-error.spec.ts @@ -5,7 +5,10 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, deleteUserPfx, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteUserPfx, setSystemPolicy } from '../support/nc-provisioning' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() async function prepareSignFlow(page: Parameters[1] extends (args: infer T) => any ? T['page'] : never, adminUser: string) { await page.goto('./apps/libresign') @@ -19,11 +22,11 @@ async function prepareSignFlow(page: Parameters[1] extends (args: i await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() await page.getByRole('button', { name: 'Send' }).click() - await page.getByRole('button', { name: 'Sign document' }).click() + await page.getByRole('button', { name: 'Sign document' }).first().click() await page.getByRole('button', { name: 'Define a password and sign the document.' }).click() await page.getByLabel('Enter a password').fill('Password1234') await page.getByRole('button', { name: 'Confirm' }).click() - await page.getByRole('button', { name: 'Sign the document.' }).click() + await page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }).click() await page.getByLabel('Signature password').fill('Password1234') } @@ -41,19 +44,19 @@ async function bootstrapAdminCertificate(page: Parameters[1] extend L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: true, signatureMethods: { password: { enabled: true } } }, - { name: 'email', enabled: false, mandatory: false }, - ]), + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { password: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), ) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'crl_external_validation_enabled', '1', ) @@ -94,17 +97,16 @@ test('switches from blocked (enabled) to normal (disabled) without extra scenari await page.route(signRoute, blockedHandler) - await page.getByRole('button', { name: 'Sign document' }).click() + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() await expect(page.getByLabel('Signature password')).toBeHidden() - await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeHidden() + await expect(page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' })).toBeHidden() await expect(page.getByRole('button', { name: 'Try signing again' })).toBeVisible() await expect(page.locator('.button-wrapper').getByText('Certificate revocation status could not be verified').first()).toBeVisible() await page.screenshot({ path: '/tmp/playwright-results/non-retriable-blocked-ui.png', fullPage: true }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'crl_external_validation_enabled', '0', ) @@ -132,9 +134,9 @@ test('switches from blocked (enabled) to normal (disabled) without extra scenari }) await page.getByRole('button', { name: 'Try signing again' }).click() - await page.getByRole('button', { name: 'Sign the document.' }).click() + await page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }).click() await page.getByLabel('Signature password').fill('Password1234') - await page.getByRole('button', { name: 'Sign document' }).click() + await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() await expect(page.getByText('Signing is blocked until the certificate validation issue is resolved.')).toBeHidden() await expect(page.getByRole('button', { name: 'Try signing again' })).toBeHidden() diff --git a/playwright/e2e/sign-wrong-session.spec.ts b/playwright/e2e/sign-wrong-session.spec.ts index 5088b19f02..8b0d9e9db3 100644 --- a/playwright/e2e/sign-wrong-session.spec.ts +++ b/playwright/e2e/sign-wrong-session.spec.ts @@ -5,8 +5,11 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() /** * When an authenticated Nextcloud user visits a sign link that belongs to a @@ -35,14 +38,16 @@ test('authenticated user sees authentication guidance when accessing another sig L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, - ]), + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + ], + }), ) const mailpit = createMailpitClient() @@ -65,7 +70,7 @@ test('authenticated user sees authentication guidance when accessing another sig // Retrieve the sign link from the notification email sent to the signer. // The admin is intentionally NOT logged out — this simulates the wrong-session scenario. - const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign', { + const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: A document is ready for your signature', { timeout: 60_000, }) const signLink = extractSignLink(email.Text) diff --git a/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts new file mode 100644 index 0000000000..090f706fc1 --- /dev/null +++ b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts @@ -0,0 +1,214 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { + configureOpenSsl, + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setSystemPolicy, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + setSystemPolicyEntry, +} from '../support/policy-api' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() + +const POLICY_KEY = 'signature_flow' +const GROUP_ADMIN_USER = 'signature-flow-e2e-group-admin' +const GROUP_ADMIN_PASSWORD = '123456' +const GROUP_ADMIN_GROUP = 'signature-flow-e2e-group' +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + groupAdminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.setTimeout(120_000) +test.describe.configure({ mode: 'serial' }) + + + +async function addEmailSigner(page: Page, email: string, name: string) { + const dialog = page.getByRole('dialog', { name: 'Add new signer' }) + await page.getByRole('button', { name: 'Add signer' }).click() + await dialog.getByPlaceholder('Email').click() + await dialog.getByPlaceholder('Email').pressSequentially(email, { delay: 50 }) + await expect(page.getByRole('option', { name: email })).toBeVisible({ timeout: 10_000 }) + await page.getByRole('option', { name: email }).click() + await dialog.getByRole('textbox', { name: 'Signer name' }).fill(name) + + const saveSignerResponsePromise = page.waitForResponse((response) => { + return response.url().includes('/apps/libresign/api/v1/request-signature') + && ['POST', 'PATCH'].includes(response.request().method()) + }) + + await dialog.getByRole('button', { name: 'Save' }).click() + const saveSignerResponse = await saveSignerResponsePromise + expect(saveSignerResponse.status()).toBe(200) + await expect(dialog).toBeHidden() +} + +test.afterEach(async ({ adminRequestContext, groupAdminRequestContext }) => { + await clearUserPolicyPreference(adminRequestContext, POLICY_KEY, [200, 401, 500]) + await clearUserPolicyPreference(groupAdminRequestContext, POLICY_KEY, [200, 401, 500]) + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'none', true) + await setSystemPolicy(adminRequestContext, 'groups_request_sign', JSON.stringify({ allowGroups: ['admin'], denyGroups: [] })) +}) + +test('request sidebar persists signature flow preference through policies endpoint', async ({ page, adminRequestContext }) => { + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setSystemPolicy( + adminRequestContext, + 'identify_methods', + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + ], + }), + ) + + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'parallel', true) + await clearUserPolicyPreference(adminRequestContext, POLICY_KEY, [200, 401, 500]) + + await page.goto('./apps/libresign') + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + + await addEmailSigner(page, 'signer01@libresign.coop', 'Signer 01') + await addEmailSigner(page, 'signer02@libresign.coop', 'Signer 02') + + await expect(page.getByLabel('Use this as my default signing order')).toBeVisible() + await page.getByText('Use this as my default signing order').click() + + const saveOrderedPreference = page.waitForResponse((response) => { + const req = response.request() + return req.method() === 'PUT' + && req.url().includes('/apps/libresign/api/v1/policies/user/signature_flow') + && (req.postData() ?? '').includes('ordered_numeric') + }) + + await expect(page.getByLabel('Sign in order')).toBeVisible() + await page.getByText('Sign in order').click() + await expect(page.getByLabel('Sign in order')).toBeChecked() + + const saveOrderedPreferenceResponse = await saveOrderedPreference + expect(saveOrderedPreferenceResponse.status()).toBe(200) +}) + +for (const systemFlow of ['ordered_numeric', 'parallel'] as const) { + test(`fixed system ${systemFlow} signature flow hides request toggles for groupadmin`, async ({ page, adminRequestContext, groupAdminRequestContext }) => { + await ensureUserExists(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(adminRequestContext, GROUP_ADMIN_GROUP) + await ensureUserInGroup(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + await ensureSubadminOfGroup(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setSystemPolicy( + adminRequestContext, + 'identify_methods', + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + ], + }), + ) + + await setSystemPolicy( + adminRequestContext, + 'groups_request_sign', + JSON.stringify({ allowGroups: ['admin', GROUP_ADMIN_GROUP], denyGroups: [] }), + ) + + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, systemFlow, false) + await clearUserPolicyPreference(groupAdminRequestContext, POLICY_KEY, [200, 401, 500]) + + await login(page.request, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await page.goto('./apps/libresign/f/request') + await expect(page.getByRole('heading', { name: 'Request Signatures' })).toBeVisible() + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + + await addEmailSigner(page, 'signer11@libresign.coop', 'Signer 11') + await addEmailSigner(page, 'signer12@libresign.coop', 'Signer 12') + + await expect(page.getByLabel('Sign in order')).toBeHidden() + await expect(page.getByLabel('Use this as my default signing order')).toBeHidden() + + const sendRequestResponsePromise = page.waitForResponse((response) => { + const requestData = response.request() + const body = requestData.postData() ?? '' + return response.url().includes('/apps/libresign/api/v1/request-signature') + && ['POST', 'PATCH'].includes(requestData.method()) + && body.includes('"status":1') + }) + + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + + const sendRequestResponse = await sendRequestResponsePromise + expect(sendRequestResponse.status()).toBe(200) + + const sendRequestPayload = JSON.parse(sendRequestResponse.request().postData() ?? '{}') as { + signatureFlow?: string + } + expect(sendRequestPayload.signatureFlow).toBeUndefined() + + const sendRequestBody = await sendRequestResponse.json() as { + ocs?: { + data?: { + signatureFlow?: string + signers?: Array<{ signingOrder?: number }> + } + } + } + expect(sendRequestBody.ocs?.data?.signatureFlow).toBe(systemFlow) + + if (systemFlow === 'ordered_numeric') { + expect(sendRequestBody.ocs?.data?.signers?.map((signer) => signer.signingOrder)).toEqual([1, 2]) + } + }) +} diff --git a/playwright/e2e/signature-footer-qrcode-preview.spec.ts b/playwright/e2e/signature-footer-qrcode-preview.spec.ts new file mode 100644 index 0000000000..4e9c0cc88d --- /dev/null +++ b/playwright/e2e/signature-footer-qrcode-preview.spec.ts @@ -0,0 +1,186 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import type { Locator, Page, Request } from '@playwright/test' +import { login } from '../support/nc-login' +import { setSystemPolicy } from '../support/nc-provisioning' +import { ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const PREVIEW_URL_PATTERN = /footer-template\/preview-pdf/ + +async function captureNextPreviewRequest(page: Page): Promise { + return page.waitForRequest( + (req) => req.method() === 'POST' && PREVIEW_URL_PATTERN.test(req.url()), + { timeout: 15000 }, + ) +} + +/** + * Click the visual toggle area of an NcCheckboxRadioSwitch. + * + * NcCheckboxRadioSwitch renders the interactive content in a child + * `.checkbox-radio-switch__content` span that has `onClick: onToggle` + * bound to it. Clicking the outer container span is unreliable because + * events may not reach the handler; clicking the content span directly + * is the correct approach. + */ +async function clickSwitch(switchContainer: Locator): Promise { + await switchContainer.locator('.checkbox-radio-switch__content').click() +} + +async function openFooterPolicyEditor(page: Page) { + await page.goto('./settings/admin/libresign') + + const footerCard = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await footerCard.click() + + // Expect the footer settings dialog to appear + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + + return dialog +} + +async function clickChangeOrCreateRule(dialog: ReturnType) { + const changeBtn = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await changeBtn.click() + } else { + const createBtn = dialog.getByRole('button', { name: /Create rule/i }).first() + await expect(createBtn).toBeVisible({ timeout: 5000 }) + await createBtn.click() + // If scope selection dialog appears, pick "Everyone" + const everyoneOption = dialog.page().locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible({ timeout: 3000 }).catch(() => false)) { + await everyoneOption.click() + } + } + + // Wait for the rule editor to appear + const ruleDialog = dialog.page().getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 8000 }) + return ruleDialog +} + +test('toggleing writeQrcodeOnFooter sends correct flag to preview API and QR code appears/disappears in preview', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + await setSystemPolicy(page.request, 'groups_request_sign', JSON.stringify({ allowGroups: ['admin'], denyGroups: [] })) + await setSystemPolicy(page.request, 'add_footer', JSON.stringify(true)) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await clickChangeOrCreateRule(dialog) + + // Enable the footer + const enableSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Add visible footer/i }) + if (!(await enableSwitch.locator('input').isChecked().catch(() => false))) { + await clickSwitch(enableSwitch) + await expect(enableSwitch.locator('input')).toBeChecked({ timeout: 5000 }) + } + + // Enable QR code + const qrcodeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Write QR code on footer/i }) + await expect(qrcodeSwitch).toBeVisible({ timeout: 5000 }) + const qrcodeInput = qrcodeSwitch.locator('input') + if (!(await qrcodeInput.isChecked().catch(() => false))) { + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + } + + // Enable template customization to show the preview + const templateSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }) + await expect(templateSwitch).toBeVisible({ timeout: 5000 }) + const templateInput = templateSwitch.locator('input') + if (!(await templateInput.isChecked().catch(() => false))) { + const previewReqPromise = captureNextPreviewRequest(page) + await clickSwitch(templateSwitch) + await previewReqPromise + await expect(templateInput).toBeChecked({ timeout: 5000 }) + } + + // --- STEP 1: QR OFF → preview sends false --- + const qrOffReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).not.toBeChecked({ timeout: 5000 }) + const qrOffReq = await qrOffReqPromise + const qrOffBody = qrOffReq.postDataJSON() as Record + expect(qrOffBody.writeQrcodeOnFooter, 'writeQrcodeOnFooter should be false when switch is OFF').toBe(false) + + // --- STEP 2: QR ON → preview sends true --- + const qrOnReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + const qrOnReq = await qrOnReqPromise + const qrOnBody = qrOnReq.postDataJSON() as Record + expect(qrOnBody.writeQrcodeOnFooter, 'writeQrcodeOnFooter should be true when switch is ON').toBe(true) + + // --- STEP 3: Assert the response is a valid PDF when writeQrcodeOnFooter is true --- + const previewResponse = await page.waitForResponse( + (res) => res.request().method() === 'POST' && PREVIEW_URL_PATTERN.test(res.url()), + { timeout: 15000 }, + ) + expect(previewResponse.status(), 'Preview endpoint should return 200').toBe(200) + expect(previewResponse.headers()['content-type']).toContain('pdf') + const body = await previewResponse.body() + expect(body.length, 'PDF response should not be empty').toBeGreaterThan(100) + expect(body.subarray(0, 4).toString(), 'Response should start with %PDF').toBe('%PDF') +}) + +test('preview request always includes writeQrcodeOnFooter when template is customized', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + await setSystemPolicy(page.request, 'groups_request_sign', JSON.stringify({ allowGroups: ['admin'], denyGroups: [] })) + await setSystemPolicy(page.request, 'add_footer', JSON.stringify(true)) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await clickChangeOrCreateRule(dialog) + + // Enable footer + const enableSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Add visible footer/i }) + if (!(await enableSwitch.locator('input').isChecked().catch(() => false))) { + await clickSwitch(enableSwitch) + await expect(enableSwitch.locator('input')).toBeChecked({ timeout: 5000 }) + } + + // Enable template + const templateSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }) + const templateInput = templateSwitch.locator('input') + if (!(await templateInput.isChecked().catch(() => false))) { + const reqPromise = captureNextPreviewRequest(page) + await clickSwitch(templateSwitch) + await reqPromise + await expect(templateInput).toBeChecked({ timeout: 5000 }) + } + + // Ensure QR is OFF, then set to ON and verify + const qrcodeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Write QR code on footer/i }) + const qrcodeInput = qrcodeSwitch.locator('input') + if (await qrcodeInput.isChecked().catch(() => false)) { + const offReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).not.toBeChecked({ timeout: 5000 }) + await offReqPromise + } + + // Turn QR ON and verify the request body + const onReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + const onReq = await onReqPromise + const body = onReq.postDataJSON() as Record + + expect(Object.prototype.hasOwnProperty.call(body, 'writeQrcodeOnFooter'), + 'writeQrcodeOnFooter field must be present in the preview request').toBe(true) + expect(body.writeQrcodeOnFooter, 'writeQrcodeOnFooter must be true when switch is ON').toBe(true) +}) diff --git a/playwright/e2e/signature-footer-template-editor-fixed.spec.ts b/playwright/e2e/signature-footer-template-editor-fixed.spec.ts new file mode 100644 index 0000000000..da04b98717 --- /dev/null +++ b/playwright/e2e/signature-footer-template-editor-fixed.spec.ts @@ -0,0 +1,127 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' +import { + bootstrapLibreSignAdmin, + ensureFooterTemplateEnabled, + fillTemplateEditor, + openSystemFooterRuleEditor, +} from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const FOOTER_PREVIEW_PATH = '/apps/libresign/api/v1/footer-template/preview-pdf' + +const waitForFooterTemplateRequest = async (page: Page, action: () => Promise) => { + const requestPromise = page.waitForRequest((request) => { + return request.method() === 'POST' && request.url().includes(FOOTER_PREVIEW_PATH) + }) + + await action() + const request = await requestPromise + return request.postDataJSON() as { + template: string + width: number + height: number + } +} + +const saveRule = async (page: Page, ruleDialog: Locator): Promise => { + const saveButton = ruleDialog.getByRole('button', { name: /Create rule|Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 10000 }) + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + const saveResponsePromise = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/add_footer') + }) + await saveButton.click() + const saveResponse = await saveResponsePromise + await expect(saveResponse.status()).toBe(200) +} + +test('signature footer template editor updates preview and controls correctly', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + const ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + + const templateEditor = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + const initialTemplate = `
Playwright bootstrap ${Date.now()}
` + await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, initialTemplate) + }) + await expect(templateEditor).toContainText('Playwright bootstrap') + + const previewSection = ruleDialog.locator('.signature-footer-rule-editor__preview').first() + await expect(previewSection).toBeVisible({ timeout: 15000 }) + await expect(previewSection.getByText(/Preview/i)).toBeVisible({ timeout: 15000 }) + + const zoomField = ruleDialog.getByRole('spinbutton', { name: 'Zoom level' }).first() + await expect(zoomField).toHaveValue('100') + + await ruleDialog.getByRole('button', { name: 'Increase zoom level' }).click() + await expect(zoomField).toHaveValue('110') + + await ruleDialog.getByRole('button', { name: 'Decrease zoom level' }).click() + await expect(zoomField).toHaveValue('100') + + await zoomField.fill('140') + await zoomField.press('Tab') + await expect(zoomField).toHaveValue('140') + + const widthField = ruleDialog.getByRole('spinbutton', { name: 'Width' }).first() + const widthPayload = await waitForFooterTemplateRequest(page, async () => { + await widthField.fill('620') + await widthField.press('Tab') + }) + await expect(widthField).toHaveValue('620') + await expect(widthPayload.width).toBe(620) + + const heightField = ruleDialog.getByRole('spinbutton', { name: 'Height' }).first() + const heightPayload = await waitForFooterTemplateRequest(page, async () => { + await heightField.fill('130') + await heightField.press('Tab') + }) + await expect(heightField).toHaveValue('130') + await expect(heightPayload.height).toBe(130) + + const uniqueTemplate = `
Playwright footer ${Date.now()}
` + const templatePayload = await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, uniqueTemplate) + }) + await expect(templatePayload.template).toContain('Playwright footer') + await expect(previewSection.locator('.signature-footer-rule-editor__preview-frame')).toBeVisible({ timeout: 15000 }) +}) + +test('footer template reset removes customization after page reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + let ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + + const templateEditor = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + const customTemplate = `
Reset test ${Date.now()}
` + await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, customTemplate) + }) + await expect(templateEditor).toContainText('Reset test') + + const previewSection = ruleDialog.locator('.signature-footer-rule-editor__preview').first() + await expect(previewSection).toBeVisible({ timeout: 15000 }) + + const resetButton = ruleDialog.getByRole('button', { name: /Reset template to inherited default/i }).first() + await expect(resetButton).toBeVisible({ timeout: 10000 }) + await waitForFooterTemplateRequest(page, async () => { + await resetButton.click() + }) + await saveRule(page, ruleDialog) + + await page.reload() + ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + const templateAfterReload = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(templateAfterReload).toBeVisible({ timeout: 10000 }) + await expect(templateAfterReload).not.toContainText('Reset test') +}) diff --git a/playwright/e2e/signature-footer-template-editor.spec.ts b/playwright/e2e/signature-footer-template-editor.spec.ts new file mode 100644 index 0000000000..5984ae48fc --- /dev/null +++ b/playwright/e2e/signature-footer-template-editor.spec.ts @@ -0,0 +1,319 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import type { Locator, Page, Request, Response } from '@playwright/test' +import { login } from '../support/nc-login' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const PREVIEW_URL_PATTERN = /footer-template\/preview-pdf/ +const SYSTEM_FOOTER_POLICY_URL = '/apps/libresign/api/v1/policies/system/add_footer' + +async function bootstrapLibreSignAdmin(page: Page) { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await page.request.delete('./ocs/v2.php/apps/libresign/api/v1/policies/user/add_footer', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + }) + + await setSystemPolicy(page.request, 'groups_request_sign', JSON.stringify({ allowGroups: ['admin'], denyGroups: [] })) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) +} + +async function clickSwitch(switchContainer: Locator): Promise { + await switchContainer.locator('.checkbox-radio-switch__content').click() +} + +async function captureNextPreviewRequest(page: Page): Promise { + return page.waitForRequest( + (request) => request.method() === 'POST' && PREVIEW_URL_PATTERN.test(request.url()), + { timeout: 15000 }, + ) +} + +async function waitForSystemFooterPolicySave(page: Page, action: () => Promise): Promise<{ request: Request, response: Response }> { + const responsePromise = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes(SYSTEM_FOOTER_POLICY_URL) + }) + + await action() + const response = await responsePromise + return { + request: response.request(), + response, + } +} + +async function openFooterPolicyEditor(page: Page): Promise { + await page.goto('./settings/admin/libresign') + + const footerCard = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await footerCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + return dialog +} + +async function openSystemRuleEditor(dialog: Locator): Promise { + const changeButton = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeButton.isVisible().catch(() => false)) { + await changeButton.click() + } else { + const createButton = dialog.getByRole('button', { name: /Create rule/i }).first() + await expect(createButton).toBeVisible({ timeout: 5000 }) + await createButton.click() + const everyoneOption = dialog.page().locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible().catch(() => false)) { + await everyoneOption.click() + } + } + + const ruleDialog = dialog.page().getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 10000 }) + return ruleDialog +} + +async function ensureCheckboxEnabled(scope: Locator, label: string, triggerPreview = false): Promise { + const switchContainer = scope.locator('.checkbox-radio-switch').filter({ hasText: new RegExp(label, 'i') }).first() + await expect(switchContainer).toBeVisible({ timeout: 10000 }) + const checkbox = switchContainer.locator('input[type="checkbox"]').first() + if (!await checkbox.isChecked().catch(() => false)) { + const previewRequest = triggerPreview ? captureNextPreviewRequest(scope.page()) : null + await clickSwitch(switchContainer) + if (previewRequest) { + await previewRequest + } + } + await expect(checkbox).toBeChecked() +} + +async function getFooterEditorContext(scope: Locator): Promise<{ + ruleDialog: Locator + editorField: Locator + preview: Locator +}> { + await ensureCheckboxEnabled(scope, 'Add visible footer with signature details') + await ensureCheckboxEnabled(scope, 'Customize footer template', true) + + const editorField = scope.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(editorField).toBeVisible({ timeout: 10000 }) + + const preview = scope.locator('.signature-footer-rule-editor__preview').first() + await expect(preview).toBeVisible({ timeout: 15000 }) + + return { + ruleDialog: scope, + editorField, + preview, + } +} + +async function replaceCodeMirrorContent(editorField: Locator, value: string): Promise { + await editorField.click() + await editorField.press('Control+a') + await editorField.fill(value) +} + +async function saveRule(ruleDialog: Locator): Promise<{ request: Request, response: Response }> { + const saveButton = ruleDialog.getByRole('button', { name: /Create rule|Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 10000 }) + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + return waitForSystemFooterPolicySave(ruleDialog.page(), async () => { + await saveButton.click() + }) +} + +async function getPersistedSystemFooterPolicy(page: Page): Promise<{ customizeFooterTemplate: boolean, footerTemplate: string }> { + const response = await page.request.get('./ocs/v2.php/apps/libresign/api/v1/policies/system/add_footer', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + }) + const payload = await response.json() as { + ocs?: { + data?: { + policy?: { + value?: string | { customizeFooterTemplate?: boolean, footerTemplate?: string } + } + } + } + } + const rawValue = payload.ocs?.data?.policy?.value + if (typeof rawValue === 'string') { + return JSON.parse(rawValue) as { customizeFooterTemplate: boolean, footerTemplate: string } + } + + if (rawValue && typeof rawValue === 'object') { + return { + customizeFooterTemplate: Boolean(rawValue.customizeFooterTemplate), + footerTemplate: String(rawValue.footerTemplate ?? ''), + } + } + + return { customizeFooterTemplate: false, footerTemplate: '' } +} + +test('signature footer template editor updates preview and controls correctly', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await openSystemRuleEditor(dialog) + const { editorField, preview } = await getFooterEditorContext(ruleDialog) + + const initialTemplate = `
Playwright bootstrap ${Date.now()}
` + const initialPreviewRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorField, initialTemplate) + const initialPayload = initialPreviewRequest.then((request) => request.postDataJSON() as { + template: string + width: number + height: number + }) + + await expect(preview.locator('.signature-footer-rule-editor__preview-frame')).toBeVisible({ timeout: 15000 }) + await expect(preview.getByText(/Preview/i)).toBeVisible({ timeout: 15000 }) + await expect((await initialPayload).template).toContain('Playwright bootstrap') + + const zoomField = ruleDialog.getByRole('spinbutton', { name: 'Zoom level' }).first() + await expect(zoomField).toHaveValue('100') + await ruleDialog.getByRole('button', { name: 'Increase zoom level' }).click() + await expect(zoomField).toHaveValue('110') + await ruleDialog.getByRole('button', { name: 'Decrease zoom level' }).click() + await expect(zoomField).toHaveValue('100') + await zoomField.fill('140') + await zoomField.press('Tab') + await expect(zoomField).toHaveValue('140') + + const widthField = ruleDialog.getByRole('spinbutton', { name: 'Width' }).first() + const widthRequest = captureNextPreviewRequest(page) + await widthField.fill('620') + await widthField.press('Tab') + const widthPayload = await widthRequest.then((request) => request.postDataJSON() as { width: number }) + await expect(widthField).toHaveValue('620') + await expect(widthPayload.width).toBe(620) + + const heightField = ruleDialog.getByRole('spinbutton', { name: 'Height' }).first() + const heightRequest = captureNextPreviewRequest(page) + await heightField.fill('130') + await heightField.press('Tab') + const heightPayload = await heightRequest.then((request) => request.postDataJSON() as { height: number }) + await expect(heightField).toHaveValue('130') + await expect(heightPayload.height).toBe(130) + + const uniqueTemplate = `
Playwright footer ${Date.now()}
` + const templateRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorField, uniqueTemplate) + const templatePayload = await templateRequest.then((request) => request.postDataJSON() as { template: string }) + await expect(templatePayload.template).toContain('Playwright footer') +}) + +test('footer template persists customization after save and reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + + const customTemplate = `
Reset test ${Date.now()}
` + const capturedSavePayloads: string[] = [] + page.on('request', (request) => { + if (['POST', 'PUT', 'PATCH'].includes(request.method()) && request.url().includes(SYSTEM_FOOTER_POLICY_URL)) { + const payload = request.postDataJSON() as { value?: string } + capturedSavePayloads.push(String(payload.value ?? '')) + } + }) + let dialog = await openFooterPolicyEditor(page) + let ruleDialog = await openSystemRuleEditor(dialog) + let editorContext = await getFooterEditorContext(ruleDialog) + + const savePreviewRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorContext.editorField, customTemplate) + await savePreviewRequest + const { request: saveRequest, response: saveResponse } = await saveRule(ruleDialog) + await expect(saveResponse.status()).toBe(200) + const savePayload = saveRequest.postDataJSON() as { value?: string } + const decodedValue = JSON.parse(savePayload.value ?? '{}') as { footerTemplate?: string } + expect(decodedValue.footerTemplate ?? '').toBe(customTemplate) + const persistedAfterSave = await getPersistedSystemFooterPolicy(page) + expect(capturedSavePayloads.map((payload) => JSON.parse(payload).footerTemplate ?? '')).toContain(customTemplate) + expect(persistedAfterSave.customizeFooterTemplate).toBe(true) + expect(persistedAfterSave.footerTemplate).toBe(customTemplate) + + await page.reload() + const persistedAfterReload = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterReload.customizeFooterTemplate).toBe(true) + expect(persistedAfterReload.footerTemplate).toBe(customTemplate) + dialog = await openFooterPolicyEditor(page) + ruleDialog = await openSystemRuleEditor(dialog) + editorContext = await getFooterEditorContext(ruleDialog) + + await expect.poll(async () => { + const text = await editorContext.editorField.textContent() + return (text ?? '').trim() + }, { timeout: 10000 }).toContain(customTemplate) +}) + +test('footer template reset reverts to inherited default after save and reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + + const customTemplate = `
CUSTOM_${Date.now()}
` + let dialog = await openFooterPolicyEditor(page) + let ruleDialog = await openSystemRuleEditor(dialog) + let editorContext = await getFooterEditorContext(ruleDialog) + + const savePreviewRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorContext.editorField, customTemplate) + await savePreviewRequest + await saveRule(ruleDialog) + const persistedAfterCustomSave = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterCustomSave.customizeFooterTemplate).toBe(true) + expect(persistedAfterCustomSave.footerTemplate).toBe(customTemplate) + + dialog = await openFooterPolicyEditor(page) + ruleDialog = await openSystemRuleEditor(dialog) + editorContext = await getFooterEditorContext(ruleDialog) + const persistedBeforeReset = await getPersistedSystemFooterPolicy(page) + expect(persistedBeforeReset.customizeFooterTemplate).toBe(true) + expect(persistedBeforeReset.footerTemplate).toBe(customTemplate) + + const resetButton = ruleDialog.getByRole('button', { name: 'Reset template to inherited default' }).first() + if (await resetButton.isVisible().catch(() => false)) { + const resetPreviewRequest = captureNextPreviewRequest(page) + await resetButton.click() + await resetPreviewRequest + } else { + const customizeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }).first() + await expect(customizeSwitch).toBeVisible({ timeout: 10000 }) + const customizeCheckbox = customizeSwitch.locator('input[type="checkbox"]').first() + if (await customizeCheckbox.isChecked().catch(() => false)) { + await clickSwitch(customizeSwitch) + } + await expect(customizeCheckbox).not.toBeChecked() + } + await saveRule(ruleDialog) + const persistedAfterReset = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterReset.footerTemplate).not.toBe(customTemplate) + expect(persistedAfterReset.customizeFooterTemplate).toBe(false) + + await page.reload() + const persistedAfterReload = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterReload.footerTemplate).toBe(persistedAfterReset.footerTemplate) + expect(persistedAfterReload.customizeFooterTemplate).toBe(false) +}) diff --git a/playwright/e2e/visible-element-persistence.spec.ts b/playwright/e2e/visible-element-persistence.spec.ts index c713f70ebb..a58015478a 100644 --- a/playwright/e2e/visible-element-persistence.spec.ts +++ b/playwright/e2e/visible-element-persistence.spec.ts @@ -6,7 +6,7 @@ import { expect, test } from '@playwright/test' import type { Locator } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' function getVisiblePdfOverlay(dialog: Locator) { return dialog.locator('.overlay:visible').first() @@ -16,14 +16,25 @@ test('visible signature element persists and can be deleted', async ({ page }) = const requestSignatureTab = page.locator('#request-signature-tab') const setupSignaturePositionsButton = requestSignatureTab.getByRole('button', { name: 'Setup signature positions' }) const openSidebarButton = page.getByRole('button', { name: 'Open sidebar' }) + const signaturePositionsDialog = page.getByLabel('Signature positions') async function reopenFileFromUuid(uuid: string) { await page.goto(`./apps/libresign/f/filelist/sign?uuid=${uuid}`) - if (await openSidebarButton.isVisible()) { + await expect(page).toHaveURL(/\/apps\/libresign\/f\/filelist\/sign/) + + const setupVisible = await setupSignaturePositionsButton.isVisible({ timeout: 3000 }).catch(() => false) + if (!setupVisible) { + const draftRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row').filter({ hasText: 'Draft' }).first() + await expect(draftRow).toBeVisible({ timeout: 20000 }) + await draftRow.getByRole('button').first().click() + } + + if (await openSidebarButton.isVisible({ timeout: 2000 }).catch(() => false)) { await openSidebarButton.click() } - await expect(setupSignaturePositionsButton).toBeVisible() + await expect(setupSignaturePositionsButton).toBeVisible({ timeout: 15000 }) await setupSignaturePositionsButton.click() + await expect(signaturePositionsDialog).toBeVisible({ timeout: 30000 }) } await login( @@ -40,14 +51,15 @@ test('visible signature element persists and can be deleted', async ({ page }) = L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, - { name: 'email', enabled: false, mandatory: false }, - ]), + JSON.stringify({ + factors: [ + { name: 'account', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, requirement: 'optional' }, + ], + }), ) await page.goto('./apps/libresign') @@ -72,7 +84,6 @@ test('visible signature element persists and can be deleted', async ({ page }) = const requestUuid = createRequestBody.ocs.data.uuid as string await expect(setupSignaturePositionsButton).toBeVisible() await setupSignaturePositionsButton.click() - const signaturePositionsDialog = page.getByLabel('Signature positions') const visiblePageOverlay = getVisiblePdfOverlay(signaturePositionsDialog) const addInstruction = signaturePositionsDialog.getByText('Click on the place you want to add.') const cancelPlacementButton = signaturePositionsDialog.getByRole('button', { name: 'Cancel' }) @@ -97,29 +108,25 @@ test('visible signature element persists and can be deleted', async ({ page }) = await expect(addInstruction).toBeHidden() await expect(cancelPlacementButton).toBeHidden() await expect(editSignerLink).toBeVisible() + const signaturePosition = signaturePositionsDialog.getByRole('img', { name: /Signature position for/i }).first() - await expect( - signaturePositionsDialog.getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeVisible() + await expect(signaturePosition).toBeVisible({ timeout: 10000 }) // Save closes the modal and persists the element via API await page.getByLabel('Signature positions').getByRole('button', { name: 'Save' }).click() + await expect(page.getByLabel('Signature positions')).toBeHidden() // Open the document again through the Files route using the request uuid to force a fresh load await reopenFileFromUuid(requestUuid) // Verify the element survived the round-trip to the server - await expect( - page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeVisible() + await expect(page.getByLabel('Signature positions').getByRole('img', { name: /Signature position for/i }).first()).toBeVisible({ timeout: 30000 }) // Select the element so the toolbar (Duplicate / Delete) appears, then delete it - await page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }).click() + await page.getByLabel('Signature positions').getByRole('img', { name: /Signature position for/i }).first().click() await page.getByLabel('Signature positions').getByRole('button', { name: 'Delete' }).click() - await expect( - page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeHidden() + await expect(page.getByLabel('Signature positions').getByRole('img', { name: /Signature position for/i }).first()).toBeHidden() // Save the now-empty element list await page.getByLabel('Signature positions').getByRole('button', { name: 'Save' }).click() @@ -129,9 +136,8 @@ test('visible signature element persists and can be deleted', async ({ page }) = // Re-open the document one last time and confirm the element is gone await reopenFileFromUuid(requestUuid) - await expect(getVisiblePdfOverlay(signaturePositionsDialog)).toBeVisible() + await expect(signaturePositionsDialog).toBeVisible() + await expect(getVisiblePdfOverlay(signaturePositionsDialog)).toBeVisible({ timeout: 30000 }) - await expect( - signaturePositionsDialog.getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeHidden() + await expect(signaturePositionsDialog.getByRole('img', { name: /Signature position for/i }).first()).toBeHidden() }) diff --git a/playwright/support/footer-policy-workbench.ts b/playwright/support/footer-policy-workbench.ts new file mode 100644 index 0000000000..23a6ee92eb --- /dev/null +++ b/playwright/support/footer-policy-workbench.ts @@ -0,0 +1,118 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Locator, type Page } from '@playwright/test' +import { login } from './nc-login' +import { configureOpenSsl, setSystemPolicy } from './nc-provisioning' + +async function clickSwitchContent(switchContainer: Locator): Promise { + await switchContainer.locator('.checkbox-radio-switch__content').first().click() +} + +export async function bootstrapLibreSignAdmin(page: Page): Promise { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await setSystemPolicy(page.request, 'groups_request_sign', JSON.stringify({ allowGroups: ['admin'], denyGroups: [] })) + await setSystemPolicy(page.request, 'add_footer', JSON.stringify(true)) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) +} + +export async function ensureCatalogSettingCardVisible( + page: Page, + settingName: RegExp, + searchTerm: string, +): Promise { + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = page.getByRole('button', { + name: /Collapse settings categories|Expand settings categories/i, + }).first() + if (/Expand settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await collapseButton.click() + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + + const viewButton = page.getByRole('button', { + name: /Switch to compact view|Switch to card view/i, + }).first() + if (/Switch to card view/i.test((await viewButton.getAttribute('aria-label')) ?? '')) { + await viewButton.click() + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + } + + await searchField.fill(searchTerm) + const settingCard = page.getByRole('button', { name: settingName }).first() + await expect(settingCard).toBeVisible({ timeout: 20000 }) + return settingCard +} + +export async function openSystemFooterRuleEditor(page: Page): Promise { + await page.goto('./settings/admin/libresign') + + const footerCard = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await footerCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + + const changeButton = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeButton.isVisible().catch(() => false)) { + await changeButton.click() + } else { + const createButton = dialog.getByRole('button', { name: /Create rule/i }).first() + await expect(createButton).toBeVisible({ timeout: 10000 }) + await createButton.click() + const everyoneOption = page.locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible().catch(() => false)) { + await everyoneOption.click() + } + } + + const ruleDialog = page.getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 10000 }) + return ruleDialog +} + +export async function ensureFooterTemplateEnabled(scope: Locator): Promise { + const addFooterSwitch = scope.locator('.checkbox-radio-switch') + .filter({ hasText: /Add visible footer(?: with signature details)?/i }) + .first() + await expect(addFooterSwitch).toBeVisible({ timeout: 10000 }) + const addFooterCheckbox = addFooterSwitch.locator('input[type="checkbox"]').first() + if (!await addFooterCheckbox.isChecked()) { + await clickSwitchContent(addFooterSwitch) + await expect(addFooterCheckbox).toBeChecked() + } + + const customizeSwitch = scope.locator('.checkbox-radio-switch') + .filter({ hasText: /Customize footer template/i }) + .first() + await expect(customizeSwitch).toBeVisible({ timeout: 10000 }) + const customizeCheckbox = customizeSwitch.locator('input[type="checkbox"]').first() + if (!await customizeCheckbox.isChecked()) { + await clickSwitchContent(customizeSwitch) + await expect(customizeCheckbox).toBeChecked() + } +} + +export async function fillTemplateEditor(scope: Locator, value: string): Promise { + const editor = scope.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(editor).toBeVisible({ timeout: 10000 }) + await editor.click() + await editor.press('Control+a') + await editor.fill(value) +} diff --git a/playwright/support/mailpit.ts b/playwright/support/mailpit.ts index be7cce03cc..59940e860b 100644 --- a/playwright/support/mailpit.ts +++ b/playwright/support/mailpit.ts @@ -75,13 +75,18 @@ export async function waitForEmailTo( /** Extracts a LibreSign sign link from an email body matching /p/sign/{uuid}. */ export function extractSignLink(body: string): string | null { - const match = body.match(/\S+\/p\/sign\/[\w-]+/) - if (!match) { + const match = body.match(/(?:https?:\/\/[^\s"'<>)]*)?\/(?:index\.php\/)?(?:[^\s"'<>)]*\/)?p\/sign\/[\w-]+(?:\?[^\s"'<>)]*)?(?:#[^\s"'<>)]*)?/) + if (!match?.[0]) { return null } - const parsedUrl = new URL(match[0]) - return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}` + const normalizedMatch = match[0].replace(/[).,;]+$/, '') + if (normalizedMatch.startsWith('http://') || normalizedMatch.startsWith('https://')) { + const parsedUrl = new URL(normalizedMatch) + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`.replace(/^\/index\.php/, '') + } + + return normalizedMatch.replace(/^\/index\.php/, '') } /** Extracts a numeric token from an email body. Default pattern: 4-8 digit sequence. */ diff --git a/playwright/support/nc-files.ts b/playwright/support/nc-files.ts new file mode 100644 index 0000000000..c6f473fdf3 --- /dev/null +++ b/playwright/support/nc-files.ts @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type APIRequestContext, type Page } from '@playwright/test' + +import { getSmallValidPdfBuffer } from './pdf-fixtures' + +type RegisteredEntry = { + id?: string +} + +type NextcloudFilesWindow = Window & { + _nc_newfilemenu?: { + _entries?: RegisteredEntry[] + getEntries?: () => RegisteredEntry[] + } + _nc_fileactions?: RegisteredEntry[] + _nc_files_scope?: { + v4_0?: { + newFileMenu?: { + _entries?: RegisteredEntry[] + getEntries?: () => RegisteredEntry[] + } + fileActions?: Map | RegisteredEntry[] + } + } +} + +type OcsFileCreateResponse = { + ocs?: { + data?: { + id?: number + } + } +} + +const initializedFilesHomes = new Set() + +export async function ensureFilesHomeInitialized( + request: APIRequestContext, + userId = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', +): Promise { + if (initializedFilesHomes.has(userId)) { + return + } + + const fileName = `libresign-home-init-${Date.now()}` + const pdfBuffer = await getSmallValidPdfBuffer() + const headers = { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + } + + const response = await request.post('./ocs/v2.php/apps/libresign/api/v1/file', { + headers, + multipart: { + name: fileName, + file: { + name: `${fileName}.pdf`, + mimeType: 'application/pdf', + buffer: pdfBuffer, + }, + }, + failOnStatusCode: false, + }) + + if (!response.ok()) { + throw new Error(`Failed to initialize Files home for user "${userId}": ${response.status()} ${await response.text()}`) + } + + initializedFilesHomes.add(userId) + + const body = await response.json() as OcsFileCreateResponse + const fileId = body.ocs?.data?.id + if (typeof fileId === 'number' && fileId > 0) { + await request.delete(`./ocs/v2.php/apps/libresign/api/v1/file/file_id/${fileId}`, { + headers, + failOnStatusCode: false, + }) + } +} + +export async function waitForFilesNewMenuEntry(page: Page, entryId: string): Promise { + await expect.poll(async () => { + return page.evaluate((expectedId) => { + const nextcloudWindow = window as NextcloudFilesWindow + const menu = nextcloudWindow._nc_newfilemenu + ?? nextcloudWindow._nc_files_scope?.v4_0?.newFileMenu + const entries = Array.isArray(menu?._entries) + ? menu._entries + : menu?.getEntries?.() ?? [] + + return Array.isArray(entries) + && entries.some((entry) => entry?.id === expectedId) + }, entryId) + }, { timeout: 15000 }).toBe(true) +} + +export async function waitForFilesAction(page: Page, actionId: string): Promise { + await expect.poll(async () => { + return page.evaluate((expectedId) => { + const nextcloudWindow = window as NextcloudFilesWindow + const scopedActions = nextcloudWindow._nc_files_scope?.v4_0?.fileActions + const registeredActions = Array.isArray(nextcloudWindow._nc_fileactions) + ? nextcloudWindow._nc_fileactions + : scopedActions instanceof Map + ? Array.from(scopedActions.values()) + : Array.isArray(scopedActions) + ? scopedActions + : [] + + return registeredActions.some((entry) => entry?.id === expectedId) + }, actionId) + }, { timeout: 15000 }).toBe(true) +} + +export async function uploadFileToFilesApp( + page: Page, + file: { + name: string + mimeType: string + buffer: Buffer + }, + userId = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', +): Promise { + await ensureFilesHomeInitialized(page.request, userId) + const uploadPickerInput = page.locator('[data-cy-upload-picker-input]').first() + await expect(uploadPickerInput).toBeAttached({ timeout: 15000 }) + await uploadPickerInput.setInputFiles(file) +} diff --git a/playwright/support/nc-login.ts b/playwright/support/nc-login.ts index f97f681ac6..62de16ebe8 100644 --- a/playwright/support/nc-login.ts +++ b/playwright/support/nc-login.ts @@ -4,6 +4,7 @@ */ import type { APIRequestContext } from '@playwright/test' +import { ensureLibresignAppEnabled } from './nc-provisioning' /** * Login to Nextcloud via API (no browser form involved). @@ -25,9 +26,36 @@ export async function login( user: string, password: string, ): Promise { - const tokenResponse = await request.get('./csrftoken', { - failOnStatusCode: true, - }) + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + await ensureLibresignAppEnabled(request, adminUser, adminPassword) + + // Ensure a previous authenticated session does not leak across persona switches. + await request.get('./logout', { + failOnStatusCode: false, + maxRedirects: 0, + }).catch(() => {}) + + let tokenResponse: Awaited> | null = null + let lastTokenError: Error | null = null + for (let attempt = 1; attempt <= 5; attempt++) { + try { + tokenResponse = await request.get('./csrftoken', { + failOnStatusCode: true, + timeout: 20000, + }) + break + } catch (error) { + lastTokenError = error instanceof Error ? error : new Error(String(error)) + if (attempt < 5) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)) + } + } + } + + if (!tokenResponse) { + throw lastTokenError ?? new Error('Failed to fetch csrftoken') + } const { token: requesttoken } = await tokenResponse.json() as { token: string } diff --git a/playwright/support/nc-navigation.ts b/playwright/support/nc-navigation.ts new file mode 100644 index 0000000000..eb58ee0c74 --- /dev/null +++ b/playwright/support/nc-navigation.ts @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Browser-level navigation helpers for the Nextcloud / LibreSign UI. + */ + +import type { Page } from '@playwright/test' + +/** + * Ensures the "Settings" section of the Nextcloud left sidebar is expanded + * so that links like "Account" and "Policies" are visible. + * + * Works both when the sidebar is already expanded and when it still shows + * only the collapsed "Settings" toggle button. + */ +export async function expandSettingsMenu(page: Page): Promise { + await page.keyboard.press('Escape').catch(() => {}) + const sidebar = page.locator('#app-navigation-vue') + const settingsLink = sidebar.getByRole('link', { name: 'Account' }) + if (await settingsLink.count()) { + return + } + + const settingsToggle = sidebar.getByRole('button', { name: 'Settings' }) + if (await settingsToggle.count()) { + await settingsToggle.first().click() + } +} diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts index 39e5665c37..abbfb0c6ee 100644 --- a/playwright/support/nc-provisioning.ts +++ b/playwright/support/nc-provisioning.ts @@ -27,6 +27,100 @@ type SignatureElementResponse = { }> } +type HasRootCertResponse = { + hasRootCert?: boolean +} + +type AppConfigResponse = { + data?: string +} + +let libresignAppEnablePromise: Promise | null = null + +function buildOcsHeaders(adminUser: string, adminPassword: string): Record { + const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64') + return { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + } +} + +export async function ensureLibresignAppEnabled( + request: APIRequestContext, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + if (libresignAppEnablePromise) { + await libresignAppEnablePromise + return + } + + libresignAppEnablePromise = (async () => { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= 6; attempt++) { + try { + const response = await request.post('./ocs/v2.php/cloud/apps/libresign?format=json', { + headers: buildOcsHeaders(adminUser, adminPassword), + failOnStatusCode: false, + }) + + if (!response.ok()) { + const body = await response.text() + if ([502, 503, 504].includes(response.status()) && attempt < 6) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)) + continue + } + throw new Error(`Failed to enable LibreSign app: ${response.status()} ${body}`) + } + + const rawBody = await response.text() + if (!rawBody) { + return + } + + const body = JSON.parse(rawBody) as OcsResponse + if (body.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to enable LibreSign app: ${body.ocs.meta.message}`) + } + + return + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + if (attempt < 6) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)) + continue + } + } + } + + throw lastError ?? new Error('Failed to enable LibreSign app') + })() + + try { + await libresignAppEnablePromise + } catch (error) { + libresignAppEnablePromise = null + throw error + } +} + +function toStringList(data: unknown): string[] { + if (Array.isArray(data)) { + return data.filter((item): item is string => typeof item === 'string') + } + + if (data && typeof data === 'object') { + const nested = data as { groups?: unknown[] } + if (Array.isArray(nested.groups)) { + return nested.groups.filter((item): item is string => typeof item === 'string') + } + } + + return [] +} + async function ocsRequest( request: APIRequestContext, method: 'GET' | 'POST' | 'PUT' | 'DELETE', @@ -36,15 +130,16 @@ async function ocsRequest( body?: Record, jsonBody?: unknown, ): Promise> { - const url = `./ocs/v2.php${path}` - const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64') - const headers: Record = { - 'OCS-ApiRequest': 'true', - Accept: 'application/json', - Authorization: auth, + if (path.startsWith('/apps/libresign/')) { + await ensureLibresignAppEnabled(request, adminUser, adminPassword) } + + const url = `./ocs/v2.php${path}` + const headers: Record = buildOcsHeaders(adminUser, adminPassword) if (jsonBody !== undefined) { headers['Content-Type'] = 'application/json' + } else if (body !== undefined) { + headers['Content-Type'] = 'application/x-www-form-urlencoded' } const response = await request[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](url, { headers, @@ -120,8 +215,133 @@ export async function ensureUserExists( export async function deleteUser( request: APIRequestContext, userId: string, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + await ocsRequest(request, 'DELETE', `/cloud/users/${userId}`, adminUser, adminPassword) +} + +/** + * Forces a user's Nextcloud language via Provisioning API. + */ +export async function setUserLanguage( + request: APIRequestContext, + userId: string, + language: string, +): Promise { + const result = await ocsRequest( + request, + 'PUT', + `/cloud/users/${encodeURIComponent(userId)}`, + undefined, + undefined, + { key: 'language', value: language }, + ) + + if (result.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to set language for user "${userId}" to "${language}": ${result.ocs.meta.message}`) + } +} + +// --------------------------------------------------------------------------- +// Groups and delegated administration +// --------------------------------------------------------------------------- + +/** + * Creates a group if it does not exist. + */ +export async function ensureGroupExists( + request: APIRequestContext, + groupId: string, +): Promise { + const check = await ocsRequest(request, 'GET', `/cloud/groups?search=${encodeURIComponent(groupId)}`) + const groups = toStringList(check.ocs.data) + if (groups.includes(groupId)) { + return + } + + const create = await ocsRequest(request, 'POST', '/cloud/groups', undefined, undefined, { + groupid: groupId, + }) + if (create.ocs.meta.statuscode !== 200 && create.ocs.meta.statuscode !== 102) { + throw new Error(`Failed to create group "${groupId}": ${create.ocs.meta.message}`) + } +} + +/** + * Deletes a group. Silently succeeds if the group doesn't exist. + */ +export async function deleteGroup( + request: APIRequestContext, + groupId: string, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + await ocsRequest(request, 'DELETE', `/cloud/groups/${groupId}`, adminUser, adminPassword) +} + +/** + * Adds a user to a group. + */ +export async function ensureUserInGroup( + request: APIRequestContext, + userId: string, + groupId: string, +): Promise { + const groupsResponse = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/groups`) + const groups = toStringList(groupsResponse.ocs.data) + if (groups.includes(groupId)) { + return + } + + const add = await ocsRequest( + request, + 'POST', + `/cloud/users/${encodeURIComponent(userId)}/groups`, + undefined, + undefined, + { groupid: groupId }, + ) + if (add.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to add user "${userId}" to group "${groupId}": ${add.ocs.meta.message}`) + } + + const verify = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/groups`) + if (!toStringList(verify.ocs.data).includes(groupId)) { + throw new Error(`User "${userId}" is not in group "${groupId}" after assignment.`) + } +} + +/** + * Grants subadmin rights for a specific group. + */ +export async function ensureSubadminOfGroup( + request: APIRequestContext, + userId: string, + groupId: string, ): Promise { - await ocsRequest(request, 'DELETE', `/cloud/users/${userId}`) + const subadmins = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/subadmins`) + const groups = toStringList(subadmins.ocs.data) + if (groups.includes(groupId)) { + return + } + + const grant = await ocsRequest( + request, + 'POST', + `/cloud/users/${encodeURIComponent(userId)}/subadmins`, + undefined, + undefined, + { groupid: groupId }, + ) + if (grant.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to grant subadmin for user "${userId}" in group "${groupId}": ${grant.ocs.meta.message}`) + } + + const verify = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/subadmins`) + if (!toStringList(verify.ocs.data).includes(groupId)) { + throw new Error(`User "${userId}" was not granted subadmin rights for group "${groupId}".`) + } } // --------------------------------------------------------------------------- @@ -151,6 +371,26 @@ export async function setAppConfig( } } +export async function getAppConfig( + request: APIRequestContext, + appId: string, + key: string, +): Promise { + const result = await ocsRequest( + request, + 'GET', + `/apps/provisioning_api/api/v1/config/apps/${appId}/${key}`, + ) + + if (result.ocs.meta.statuscode === 404) { + return null + } + + return typeof result.ocs.data?.data === 'string' + ? result.ocs.data.data + : null +} + /** * Deletes an app config value. * Equivalent to: `occ config:app:delete ` @@ -163,6 +403,68 @@ export async function deleteAppConfig( await ocsRequest(request, 'DELETE', `/apps/provisioning_api/api/v1/config/apps/${appId}/${key}`) } +// --------------------------------------------------------------------------- +// LibreSign Policy helpers +// --------------------------------------------------------------------------- + +type SystemPolicyResponse = { + policy?: { + policyKey?: string + value?: string | null + effectiveValue?: unknown + } +} + +/** + * Sets a system-level LibreSign policy via the policy API. + * Equivalent to: POST /apps/libresign/api/v1/policies/system/{policyKey} + */ +export async function setSystemPolicy( + request: APIRequestContext, + policyKey: string, + value: string, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + const result = await ocsRequest( + request, + 'POST', + `/apps/libresign/api/v1/policies/system/${policyKey}`, + adminUser, + adminPassword, + undefined, + { value }, + ) + if (result.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to set policy ${policyKey}: ${result.ocs.meta.message}`) + } +} + +/** + * Reads the stored value of a system-level LibreSign policy. + * Returns null when the policy has no explicit stored value. + */ +export async function getSystemPolicyValue( + request: APIRequestContext, + policyKey: string, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + const result = await ocsRequest( + request, + 'GET', + `/apps/libresign/api/v1/policies/system/${policyKey}`, + adminUser, + adminPassword, + ) + if (result.ocs.meta.statuscode !== 200) { + return null + } + return typeof result.ocs.data?.policy?.value === 'string' + ? result.ocs.data.policy.value + : null +} + // --------------------------------------------------------------------------- // LibreSign-specific helpers // --------------------------------------------------------------------------- @@ -197,6 +499,17 @@ export async function configureOpenSsl( commonName: string, names: OpenSslCertNames = {}, ): Promise { + const rootCertCheck = await ocsRequest( + request, + 'GET', + '/apps/libresign/api/v1/setting/has-root-cert', + ) + + if (rootCertCheck.ocs.data?.hasRootCert) { + await clearSignatureElements(request) + return + } + const normalised: OpenSslCertNames = { ...names } if (typeof normalised.OU === 'string') { normalised.OU = [normalised.OU] @@ -221,3 +534,27 @@ export async function configureOpenSsl( await clearSignatureElements(request) } + +/** + * Sets the certificate engine via the LibreSign admin API. + * Equivalent to: POST /apps/libresign/api/v1/admin/certificate/engine + */ +export async function setCertificateEngine( + request: APIRequestContext, + engine: string, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + const result = await ocsRequest( + request, + 'POST', + '/apps/libresign/api/v1/admin/certificate/engine', + adminUser, + adminPassword, + undefined, + { engine }, + ) + if (result.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to set certificate engine to "${engine}": ${result.ocs.meta.message}`) + } +} diff --git a/playwright/support/pdf-fixtures.ts b/playwright/support/pdf-fixtures.ts new file mode 100644 index 0000000000..dede4e8935 --- /dev/null +++ b/playwright/support/pdf-fixtures.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const SMALL_VALID_PDF_PATH = resolve(process.cwd(), 'tests/php/fixtures/pdfs/small_valid.pdf') + +let smallValidPdfBufferPromise: Promise | null = null +let smallValidPdfBase64Promise: Promise | null = null + +export function getSmallValidPdfBuffer(): Promise { + if (!smallValidPdfBufferPromise) { + smallValidPdfBufferPromise = readFile(SMALL_VALID_PDF_PATH) + } + return smallValidPdfBufferPromise +} + +export function getSmallValidPdfBase64(): Promise { + if (!smallValidPdfBase64Promise) { + smallValidPdfBase64Promise = getSmallValidPdfBuffer().then((buffer) => buffer.toString('base64')) + } + return smallValidPdfBase64Promise +} \ No newline at end of file diff --git a/playwright/support/policy-api.ts b/playwright/support/policy-api.ts new file mode 100644 index 0000000000..7b6ddfd240 --- /dev/null +++ b/playwright/support/policy-api.ts @@ -0,0 +1,264 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Generic helpers for the LibreSign Policy OCS API, shared across all + * policy-related spec files. + */ + +import { expect, request, type APIRequestContext } from '@playwright/test' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type OcsPolicyResponse = { + ocs?: { + meta?: { statuscode?: number; message?: string } + data?: Record + } +} + +export type PolicyApiResult = { + httpStatus: number + statusCode: number + message: string + data: Record +} + +export type EffectivePolicyEntry = { + effectiveValue?: unknown + sourceScope?: string + canSaveAsUserDefault?: boolean + editableByCurrentActor?: boolean + allowedValues?: unknown[] + everyoneCount?: number +} + +export type SystemPolicySnapshot = { + exists: boolean + value: unknown + allowChildOverride: boolean +} + +// --------------------------------------------------------------------------- +// HTTP context +// --------------------------------------------------------------------------- + +/** + * Creates a Playwright `APIRequestContext` pre-configured with OCS headers + * and Basic authentication for the given user. + */ +export async function createAuthenticatedRequestContext( + authUser: string, + authPassword: string, +): Promise { + const auth = 'Basic ' + Buffer.from(`${authUser}:${authPassword}`).toString('base64') + + return request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + 'Content-Type': 'application/json', + }, + }) +} + +// --------------------------------------------------------------------------- +// Low-level OCS request wrapper +// --------------------------------------------------------------------------- + +/** + * Issues an OCS request to the LibreSign policy API and returns a normalised + * result object. Never throws on non-2xx — callers decide what is acceptable. + */ +export async function policyRequest( + requestContext: APIRequestContext, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: Record, +): Promise { + const requestUrl = `./ocs/v2.php${path}` + const requestOptions = { data: body, failOnStatusCode: false } + + const response = method === 'GET' + ? await requestContext.get(requestUrl, requestOptions) + : method === 'POST' + ? await requestContext.post(requestUrl, requestOptions) + : method === 'PUT' + ? await requestContext.put(requestUrl, requestOptions) + : await requestContext.delete(requestUrl, requestOptions) + + const text = await response.text() + const parsed = text ? JSON.parse(text) as OcsPolicyResponse : { ocs: { data: {} } } + + return { + httpStatus: response.status(), + statusCode: parsed.ocs?.meta?.statuscode ?? response.status(), + message: parsed.ocs?.meta?.message ?? '', + data: parsed.ocs?.data ?? {}, + } +} + +// --------------------------------------------------------------------------- +// Policy read helpers +// --------------------------------------------------------------------------- + +/** + * Returns the effective policy entry for `policyKey` from the + * `/policies/effective` endpoint, or `null` when the key is absent. + */ +export async function getEffectivePolicy( + requestContext: APIRequestContext, + policyKey: string, +): Promise { + const result = await policyRequest(requestContext, 'GET', '/apps/libresign/api/v1/policies/effective') + const policies = (result.data.policies ?? {}) as Record + return policies[policyKey] ?? null +} + +/** + * Reads the current system-layer payload together with the effective rule count + * so tests can distinguish an explicit persisted system rule from the implicit + * built-in default. + */ +export async function getSystemPolicySnapshot( + ctx: APIRequestContext, + policyKey: string, +): Promise { + const [systemResult, effectivePolicy] = await Promise.all([ + policyRequest(ctx, 'GET', `/apps/libresign/api/v1/policies/system/${policyKey}`), + getEffectivePolicy(ctx, policyKey), + ]) + + expect( + systemResult.httpStatus, + `getSystemPolicySnapshot(${policyKey}): expected 200 but got ${systemResult.httpStatus}`, + ).toBe(200) + + const policy = (systemResult.data.policy ?? {}) as { + value?: unknown + allowChildOverride?: boolean + } + + return { + exists: (effectivePolicy?.everyoneCount ?? 0) > 0, + value: policy.value ?? null, + allowChildOverride: policy.allowChildOverride === true, + } +} + +/** + * Restores a previously captured system-layer snapshot. + * + * When the original state had no explicit persisted system rule, restore by + * posting the built-in default with `allowChildOverride=false`, which triggers + * `PolicySource::clearSystemPolicy()` and removes the leaked global delegation. + */ +export async function restoreSystemPolicySnapshot( + ctx: APIRequestContext, + policyKey: string, + snapshot: SystemPolicySnapshot, +): Promise { + if (!snapshot.exists) { + await setSystemPolicyEntry(ctx, policyKey, null, false) + return + } + + const response = await policyRequest( + ctx, + 'POST', + `/apps/libresign/api/v1/policies/system/${policyKey}`, + { + value: snapshot.value ?? null, + allowChildOverride: snapshot.allowChildOverride, + }, + ) + + expect( + response.httpStatus, + `restoreSystemPolicySnapshot(${policyKey}): expected 200 but got ${response.httpStatus}`, + ).toBe(200) +} + +/** + * Polls until `canSaveAsUserDefault` reaches the expected value. + * Throws after `maxAttempts` unsuccessful reads. + */ +export async function waitForPolicyCanSaveAsUserDefault( + requestContext: APIRequestContext, + policyKey: string, + expected: boolean, + maxAttempts = 10, +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const entry = await getEffectivePolicy(requestContext, policyKey) + if (entry?.canSaveAsUserDefault === expected) { + return + } + } + + throw new Error(`Policy ${policyKey} did not reach canSaveAsUserDefault=${expected} after ${maxAttempts} attempts`) +} + +// --------------------------------------------------------------------------- +// Policy write helpers +// --------------------------------------------------------------------------- + +/** + * Sets a system-level policy entry and asserts HTTP 200. + * Pass `value: null` to clear an explicit system value. + */ +export async function setSystemPolicyEntry( + ctx: APIRequestContext, + policyKey: string, + value: string | null, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest( + ctx, + 'POST', + `/apps/libresign/api/v1/policies/system/${policyKey}`, + { value, allowChildOverride }, + ) + expect(response.httpStatus, `setSystemPolicyEntry(${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +/** + * Sets a group-level policy entry and asserts HTTP 200. + */ +export async function setGroupPolicyEntry( + ctx: APIRequestContext, + groupId: string, + policyKey: string, + value: string, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest( + ctx, + 'PUT', + `/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`, + { value, allowChildOverride }, + ) + expect(response.httpStatus, `setGroupPolicyEntry(${groupId}/${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +/** + * Deletes the authenticated user's own preference for `policyKey`. + * Accepted statuses default to `[200, 500]`; pass `[200, 401, 500]` when the + * user may not yet exist at cleanup time. + */ +export async function clearUserPolicyPreference( + ctx: APIRequestContext, + policyKey: string, + acceptedStatuses: number[] = [200, 500], +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/user/${policyKey}`) + expect( + acceptedStatuses, + `clearUserPolicyPreference(${policyKey}): expected ${acceptedStatuses.join(' or ')} but got ${response.httpStatus}`, + ).toContain(response.httpStatus) +} diff --git a/playwright/support/policy-workbench-rules.ts b/playwright/support/policy-workbench-rules.ts new file mode 100644 index 0000000000..1b97e3e629 --- /dev/null +++ b/playwright/support/policy-workbench-rules.ts @@ -0,0 +1,151 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' + +const defaultRemoveExceptionButtonName = /Remove exception|Remove rule/i + +export async function waitForPolicyWorkbenchIdle(page: Page): Promise { + const savingOverlays = page.locator('[aria-busy="true"]') + await savingOverlays.first().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}) +} + +async function clickRemoveAction(page: Page): Promise { + const actionItem = page + .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible') + .filter({ hasText: /^(Remove|Delete)$/i }) + .first() + + if (!(await actionItem.isVisible().catch(() => false))) { + return false + } + + await actionItem.scrollIntoViewIfNeeded().catch(() => {}) + + const clickedNormally = await actionItem + .click({ timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (clickedNormally) { + return true + } + + const clickedForced = await actionItem + .click({ timeout: 1500, force: true }) + .then(() => true) + .catch(() => false) + + return clickedForced +} + +export async function clearPolicyWorkbenchRules( + dialog: Locator, + options?: { + maxRounds?: number + removeExceptionButtonName?: RegExp + }, +): Promise { + const page = dialog.page() + const maxRounds = options?.maxRounds ?? 8 + const removeExceptionButtonName = options?.removeExceptionButtonName ?? defaultRemoveExceptionButtonName + + for (let round = 0; round < maxRounds; round += 1) { + let removedInRound = false + const actions = dialog.getByRole('button', { name: 'Rule actions' }) + + while ((await actions.count()) > 0) { + const firstAction = actions.first() + if (!(await firstAction.isVisible().catch(() => false))) { + break + } + + const clickedAction = await firstAction.click({ timeout: 1500 }).then(() => true).catch(() => false) + if (!clickedAction) { + await page.waitForTimeout(150) + continue + } + + const removed = await clickRemoveAction(page) + if (!removed) { + // Close the action popup only — avoid pressing Escape which would close the parent dialog + const openMenus = page.locator('[role="menu"]:visible, .action-item__menutoggle--open') + if (await openMenus.count() > 0) { + await page.keyboard.press('Escape').catch(() => {}) + } + break + } + + const removeExceptionButton = page.getByRole('button', { name: removeExceptionButtonName }).first() + if (await removeExceptionButton.isVisible().catch(() => false)) { + await removeExceptionButton.click() + } else { + const removeExceptionText = page.getByText(/^Remove exception$/i).first() + if (await removeExceptionText.isVisible().catch(() => false)) { + await removeExceptionText.click() + } + } + + await waitForPolicyWorkbenchIdle(page) + await page.waitForTimeout(150) + removedInRound = true + } + + if (!removedInRound) { + await page.waitForTimeout(700) + if ((await actions.count()) === 0) { + break + } + } + } +} + +export async function openPolicyWorkbenchSystemRuleEditor( + dialog: Locator, + options?: { + createButtonName?: RegExp + ruleDialogName?: RegExp + }, +): Promise { + const createButtonName = options?.createButtonName ?? /Create rule|Create policy rule/i + const ruleDialogName = options?.ruleDialogName ?? /Edit rule|Create rule/i + + const changeButton = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await changeButton.click() + } else { + const createButton = dialog.getByRole('button', { name: createButtonName }).first() + await expect(createButton).toBeVisible({ timeout: 10000 }) + await createButton.click() + + const page = dialog.page() + const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() + if (await createScopeDialog.isVisible({ timeout: 3000 }).catch(() => false)) { + const everyoneOption = createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first() + const everyoneRadio = createScopeDialog.getByRole('radio', { name: /^Everyone\b/i }).first() + const everyoneButton = createScopeDialog.getByRole('button', { name: /^Everyone\b/i }).first() + + if (await everyoneOption.isVisible().catch(() => false)) { + await everyoneOption.click() + } else if (await everyoneRadio.isVisible().catch(() => false)) { + await everyoneRadio.click({ force: true }) + } else if (await everyoneButton.isVisible().catch(() => false)) { + await everyoneButton.click() + } else { + await createScopeDialog.getByText(/^Everyone\b/i).first().click({ force: true }) + } + + const confirmScopeButton = createScopeDialog.getByRole('button', { name: /Create rule|Continue|Next/i }).first() + if (await confirmScopeButton.isVisible().catch(() => false)) { + await confirmScopeButton.click() + } + } + } + + const ruleDialog = dialog.page().getByRole('dialog', { name: ruleDialogName }).last() + await expect(ruleDialog).toBeVisible({ timeout: 10000 }) + return ruleDialog +} diff --git a/playwright/support/system-policies.ts b/playwright/support/system-policies.ts new file mode 100644 index 0000000000..21a162b4f2 --- /dev/null +++ b/playwright/support/system-policies.ts @@ -0,0 +1,141 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Helpers for managing LibreSign system policies from Playwright tests. + * + * The `useFooterPolicyGuard()` function registers `test.beforeEach` / + * `test.afterEach` hooks that disable the footer policy before each test and + * restore the original value afterwards. Call it once at the top level of any + * spec file that triggers document signing, because the footer merge step + * requires PDFtk/Java which may not be available in every environment. + */ + +import { test, request, type APIRequestContext } from '@playwright/test' + +import { + deleteAppConfig, + getAppConfig, + setAppConfig, +} from './nc-provisioning' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const FOOTER_POLICY_KEY = 'add_footer' +export const REQUEST_SIGN_POLICY_KEY = 'groups_request_sign' + +export const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +export const REQUEST_SIGN_ADMIN_BASELINE_VALUE = JSON.stringify({ + allowGroups: ['admin'], + denyGroups: [], +}) + +// --------------------------------------------------------------------------- +// Low-level helpers +// --------------------------------------------------------------------------- + +/** + * Creates a standalone admin `APIRequestContext` suitable for use in + * `beforeEach`/`afterEach` hooks where no `page` fixture is available. + */ +export async function makeAdminContext(): Promise { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + return request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64'), + 'Content-Type': 'application/json', + }, + }) +} + +// --------------------------------------------------------------------------- +// Spec-level hook +// --------------------------------------------------------------------------- + +/** + * Registers `test.beforeEach` / `test.afterEach` hooks that disable the + * footer policy for the duration of each test and restore it afterwards. + * + * Call once at the top level of any spec file that exercises document signing: + * + * ```ts + * import { useFooterPolicyGuard } from '../support/system-policies' + * useFooterPolicyGuard() + * ``` + */ +export function useFooterPolicyGuard(): void { + let adminContext: APIRequestContext + let originalFooterPolicy: string | null = null + + test.beforeEach(async () => { + adminContext = await makeAdminContext() + originalFooterPolicy = null + originalFooterPolicy = await getAppConfig(adminContext, 'libresign', FOOTER_POLICY_KEY) + await setAppConfig(adminContext, 'libresign', FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE) + }) + + test.afterEach(async () => { + try { + if (adminContext) { + if (originalFooterPolicy === null) { + await deleteAppConfig(adminContext, 'libresign', FOOTER_POLICY_KEY) + } else { + await setAppConfig(adminContext, 'libresign', FOOTER_POLICY_KEY, originalFooterPolicy) + } + } + } finally { + await adminContext?.dispose() + } + }) +} + +/** + * Registers hooks that ensure request-sign access starts from a predictable + * baseline and is restored after each test. This prevents policy-management + * specs from leaking a restrictive `groups_request_sign` rule into unrelated + * request-flow specs that expect the admin to be allowed to request + * signatures. + * + * @param baselineValue Policy value to force during the test body. + */ +export function useRequestSignPolicyGuard( + baselineValue: string = REQUEST_SIGN_ADMIN_BASELINE_VALUE, +): void { + let adminContext: APIRequestContext + let originalRequestSignPolicy: string | null = null + + test.beforeEach(async () => { + adminContext = await makeAdminContext() + originalRequestSignPolicy = null + originalRequestSignPolicy = await getAppConfig(adminContext, 'libresign', REQUEST_SIGN_POLICY_KEY) + await setAppConfig(adminContext, 'libresign', REQUEST_SIGN_POLICY_KEY, baselineValue) + }) + + test.afterEach(async () => { + try { + if (adminContext) { + if (originalRequestSignPolicy === null || originalRequestSignPolicy === '') { + await deleteAppConfig(adminContext, 'libresign', REQUEST_SIGN_POLICY_KEY) + } else { + await setAppConfig(adminContext, 'libresign', REQUEST_SIGN_POLICY_KEY, originalRequestSignPolicy) + } + } + } finally { + await adminContext?.dispose() + } + }) +} diff --git a/src/components/CodeEditor.vue b/src/components/CodeEditor.vue index 200ef5e897..8be4d7e237 100644 --- a/src/components/CodeEditor.vue +++ b/src/components/CodeEditor.vue @@ -4,9 +4,14 @@ -->
+ {{ t('libresign', 'Name') }}: {{ getSignerDisplayName(signer) }}
+ {{ t('libresign', 'Method') }}:
+ {{ t('libresign', 'Contact') }}: {{ getSignerIdentify(signer) }}
+ {{ t('libresign', 'Status') }}:
+ {{ t('libresign', 'Order') }}: {{ signer.signingOrder || 1 }}
+ {{ t('libresign', 'Signed at') }}: {{ formatDate(signer.signDate) }}
@@ -82,7 +88,7 @@
-
{{ t('libresign', 'COMPLETED') }}
+
{{ completedStageLabel }}
@@ -132,6 +138,19 @@ const props = withDefaults(defineProps<{ senderName: '', }) +// TRANSLATORS Stage title for sender/source of signature request flow. +const senderStageLabel = t('libresign', 'SENDER') +// TRANSLATORS Final stage title indicating signing workflow completion. +const completedStageLabel = t('libresign', 'COMPLETED') +// TRANSLATORS Fallback display name for signer when no name/identifier is available. +const fallbackSignerLabel = t('libresign', 'Signer') +// TRANSLATORS Status chip label indicating signer has completed signing. +const statusSignedLabel = t('libresign', 'Signed') +// TRANSLATORS Status chip label indicating signer entry is still in draft state. +const statusDraftLabel = t('libresign', 'Draft') +// TRANSLATORS Status chip label indicating signer is waiting to sign. +const statusPendingLabel = t('libresign', 'Pending') + const uniqueOrders = computed(() => { const orders: number[] = props.signers.map((signer) => Number(signer.signingOrder || 1)) return [...new Set(orders)].sort((a, b) => a - b) @@ -142,7 +161,7 @@ function getSignersByOrder(order: number) { } function getSignerDisplayName(signer: Signer) { - return signer.displayName || signer.identifyMethods?.[0]?.value || t('libresign', 'Signer') + return signer.displayName || signer.identifyMethods?.[0]?.value || fallbackSignerLabel } function getSignerIdentify(signer: Signer) { @@ -162,9 +181,9 @@ function getIdentifyMethods(signer: Signer) { } function getStatusLabel(signer: Signer) { - if (signer.signed) return t('libresign', 'Signed') - if (signer.me?.status === 0) return t('libresign', 'Draft') - return t('libresign', 'Pending') + if (signer.signed) return statusSignedLabel + if (signer.me?.status === 0) return statusDraftLabel + return statusPendingLabel } function getStatusIconPath(signer: Signer) { diff --git a/src/components/markdown-editor/HeadingMenu.vue b/src/components/markdown-editor/HeadingMenu.vue new file mode 100644 index 0000000000..2e86000729 --- /dev/null +++ b/src/components/markdown-editor/HeadingMenu.vue @@ -0,0 +1,265 @@ + + + + + + diff --git a/src/components/validation/EnvelopeValidation.vue b/src/components/validation/EnvelopeValidation.vue index 709f35a671..1b141c105f 100644 --- a/src/components/validation/EnvelopeValidation.vue +++ b/src/components/validation/EnvelopeValidation.vue @@ -262,12 +262,14 @@ function getFileStatusText(file: EnvelopeFile) { } function getName(signer: EnvelopeSigner) { + // TRANSLATORS Fallback signer name shown when no display name and no email are available. return signer.displayName || signer.email || t('libresign', 'Unknown') } function getSignerProgressText(signer: EnvelopeSigner) { const progress = signer.documentsSignedCount || 0 const total = signer.totalDocuments || 0 + // TRANSLATORS {progress} is how many envelope documents this signer has already signed. {total} is total documents assigned to this signer. return n('libresign', '{progress} of {total} document signed', '{progress} of {total} documents signed', total, { progress, total }) } diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index 7e26bc1413..d0da80e575 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -196,6 +196,7 @@ @@ -348,12 +349,19 @@ const MODIFICATION_UNMODIFIED = 1 const MODIFICATION_ALLOWED = 2 const MODIFICATION_VIOLATION = 3 const crlStatusMap: Record = { + // TRANSLATORS CRL status text indicating certificate was checked and is not revoked. valid: { icon: mdiCheckCircle, text: t('libresign', 'CRL: Not revoked'), class: 'icon-success' }, + // TRANSLATORS CRL status text indicating certificate is revoked. revoked: { icon: mdiCloseCircle, text: t('libresign', 'CRL: Certificate revoked'), class: 'icon-error' }, + // TRANSLATORS CRL status text indicating revocation information is unavailable. missing: { icon: mdiAlertCircle, text: t('libresign', 'CRL: No information'), class: 'icon-warning' }, + // TRANSLATORS CRL status text indicating no CRL distribution URLs were found in certificate metadata. no_urls: { icon: mdiAlertCircle, text: t('libresign', 'CRL: No URLs found'), class: 'icon-warning' }, + // TRANSLATORS CRL status text indicating CRL URLs exist but were unreachable. urls_inaccessible: { icon: mdiHelpCircle, text: t('libresign', 'CRL: URLs inaccessible'), class: 'icon-warning' }, + // TRANSLATORS CRL status text indicating CRL validation process failed. validation_failed: { icon: mdiHelpCircle, text: t('libresign', 'CRL: Validation failed'), class: 'icon-warning' }, + // TRANSLATORS CRL status text indicating unexpected error during CRL validation. validation_error: { icon: mdiHelpCircle, text: t('libresign', 'CRL: Validation error'), class: 'icon-warning' }, } @@ -365,6 +373,7 @@ function toggleOpen() { } function getName(signer: SignerModel) { + // TRANSLATORS Fallback signer name when no display name, email, or name is available. return signer.displayName || signer.email || signer.name || t('libresign', 'Unknown') } @@ -424,16 +433,20 @@ function hasValidationStatus(signer: SignerModel) { function getSignatureValidationMessage(signer: SignerModel) { if (signer.signature_validation?.id === 1) { + // TRANSLATORS Validation message indicating signature cryptographic integrity check passed. return t('libresign', 'Document integrity verified') } + // TRANSLATORS Fallback validation message when signature integrity check fails and backend does not provide custom detail. return signer.signature_validation?.message || t('libresign', 'Document integrity check failed') } function getCertificateTrustMessage(signer: SignerModel) { if (signer.certificate_validation?.id === 1) { const trustedBy = signer.certificate_validation?.trustedBy || 'LibreSign CA' + // TRANSLATORS Trust-chain status. {trustedBy} is the certificate authority or trust source name. return t('libresign', 'Trust Chain: Trusted ({trustedBy})', { trustedBy }) } + // TRANSLATORS Fallback trust-chain status shown when certificate chain validation fails. return signer.certificate_validation?.message || t('libresign', 'Trust Chain: Not Trusted') } @@ -480,15 +493,19 @@ function getCrlStatusText(signer: SignerModel) { if (isRevokedBeforeSigning(signer)) { if (signer.crl_revoked_at) { const revokedAt = dateFromSqlAnsiWithSeconds(signer.crl_revoked_at) + // TRANSLATORS CRL status detail. {revokedAt} is the revocation date/time showing certificate was revoked before signing happened. return t('libresign', 'CRL: Certificate revoked before signing (revocation date: {revokedAt})', { revokedAt }) } + // TRANSLATORS CRL status detail shown when revocation happened before signing and exact revocation date is unavailable. return t('libresign', 'CRL: Certificate revoked before signing') } if (signer.crl_revoked_at) { const revokedAt = dateFromSqlAnsiWithSeconds(signer.crl_revoked_at) + // TRANSLATORS CRL status detail. {revokedAt} is revocation date/time showing certificate was valid at signing time but revoked later. return t('libresign', 'CRL: Valid at signing time (revocation date: {revokedAt})', { revokedAt }) } + // TRANSLATORS CRL status detail indicating certificate was valid when signature was applied. return t('libresign', 'CRL: Valid at signing time') } @@ -525,8 +542,10 @@ function formatTimestamp(timestamp?: number | null) { const toggleDetailsAriaLabel = computed(() => { const signerName = getName(props.signer) if (isOpen.value) { + // TRANSLATORS ARIA label for action that collapses signer details section. {signerName} is signer display name. return t('libresign', 'Collapse details of {signerName}', { signerName }) } + // TRANSLATORS ARIA label for action that expands signer details section. {signerName} is signer display name. return t('libresign', 'Expand details of {signerName}', { signerName }) }) diff --git a/src/components/validation/SignerTimestamp.vue b/src/components/validation/SignerTimestamp.vue index 1270fde642..7bc47fc8f7 100644 --- a/src/components/validation/SignerTimestamp.vue +++ b/src/components/validation/SignerTimestamp.vue @@ -6,12 +6,12 @@