diff --git a/composer.json b/composer.json index 085c2a5985..94228cd5e8 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8", "nextcloud/ocp": "dev-master", + "psr/http-client": "^1.0", "roave/security-advisories": "dev-latest" }, "config": { diff --git a/composer.lock b/composer.lock index d4a85967d6..c7c372f2ac 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "683fc6c9ae20a480af7b531acfea05bb", + "content-hash": "bff2849d977248d68622e13c32586bd9", "packages": [ { "name": "cweagans/composer-configurable-plugin", @@ -492,12 +492,12 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "134c1cdd11e8ed6c8814a370a4ab03e567bd9659" + "reference": "07722b9013ea9e57f79d3a75ccc68d4278bd7fd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/134c1cdd11e8ed6c8814a370a4ab03e567bd9659", - "reference": "134c1cdd11e8ed6c8814a370a4ab03e567bd9659", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/07722b9013ea9e57f79d3a75ccc68d4278bd7fd6", + "reference": "07722b9013ea9e57f79d3a75ccc68d4278bd7fd6", "shasum": "" }, "require": { @@ -512,7 +512,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "34.0.0-dev" + "dev-master": "35.0.0-dev" } }, "notification-url": "https://packagist.org/downloads/", @@ -534,7 +534,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/master" }, - "time": "2026-04-23T01:28:52+00:00" + "time": "2026-05-15T08:42:57+00:00" }, { "name": "psr/clock", @@ -848,18 +848,18 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9a1d6c95c513ebdc27e74ab3cd0fed99b7035c2e" + "reference": "80f4d3dbb1fc1ef5bb1eee68dbfb44060e1fb4e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9a1d6c95c513ebdc27e74ab3cd0fed99b7035c2e", - "reference": "9a1d6c95c513ebdc27e74ab3cd0fed99b7035c2e", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/80f4d3dbb1fc1ef5bb1eee68dbfb44060e1fb4e8", + "reference": "80f4d3dbb1fc1ef5bb1eee68dbfb44060e1fb4e8", "shasum": "" }, "conflict": { "3f/pygmentize": "<1.2", "adaptcms/adaptcms": "<=1.3", - "admidio/admidio": "<5.0.8", + "admidio/admidio": "<=5.0.8", "adodb/adodb-php": "<=5.22.9", "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", @@ -876,6 +876,7 @@ "alextselegidis/easyappointments": "<=1.5.2", "alexusmai/laravel-file-manager": "<=3.3.1", "algolia/algoliasearch-magento-2": "<=3.16.1|>=3.17.0.0-beta1,<=3.17.1", + "almirhodzic/nova-toggle-5": "<1.3", "alt-design/alt-redirect": "<1.6.4", "altcha-org/altcha": "<1.3.1", "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", @@ -912,14 +913,14 @@ "awesome-support/awesome-support": "<=6.0.7", "aws/aws-sdk-php": "<=3.371.3", "ayacoo/redirect-tab": "<2.1.2|>=3,<3.1.7|>=4,<4.0.5", - "azuracast/azuracast": "<=0.23.3", + "azuracast/azuracast": "<=0.23.5", "b13/seo_basics": "<0.8.2", "backdrop/backdrop": "<=1.32", "backpack/crud": "<3.4.9", "backpack/filemanager": "<2.0.2|>=3,<3.0.9", "bacula-web/bacula-web": "<9.7.1", "badaso/core": "<=2.9.11", - "bagisto/bagisto": "<2.3.10", + "bagisto/bagisto": "<=2.3.15", "barrelstrength/sprout-base-email": "<1.2.7", "barrelstrength/sprout-forms": "<3.9", "barryvdh/laravel-translation-manager": "<0.6.8", @@ -967,13 +968,13 @@ "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "chrome-php/chrome": "<1.14", - "ci4-cms-erp/ci4ms": "<0.31.5", + "ci4-cms-erp/ci4ms": "<=0.31.7", "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", "ckeditor/ckeditor": "<4.25", "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", - "cockpit-hq/cockpit": "<2.13.5", - "code16/sharp": "<9.20", + "cockpit-hq/cockpit": "<2.14", + "code16/sharp": "<9.22", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.6.2", @@ -983,7 +984,7 @@ "codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5", "commerceteam/commerce": ">=0.9.6,<0.9.9", "components/jquery": ">=1.0.3,<3.5", - "composer/composer": "<2.2.27|>=2.3,<2.9.6", + "composer/composer": "<2.2.28|>=2.3,<2.9.8", "concrete5/concrete5": "<9.4.8", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", @@ -993,14 +994,14 @@ "contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", - "coreshop/core-shop": "<4.1.9", + "coreshop/core-shop": "<4.1.9|==5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", "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.8|>=5,<5.9.15", + "craftcms/cms": "<4.17.12|>=5,<5.9.18", "craftcms/commerce": ">=4,<4.11|>=5,<5.6", "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", @@ -1019,6 +1020,7 @@ "david-garcia/phpwhois": "<=4.3.1", "dbrisinajumi/d2files": "<1", "dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta", + "dedoc/scramble": ">=0.13.2,<0.13.22", "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", "desperado/xml-bundle": "<=0.1.7", @@ -1040,7 +1042,7 @@ "doctrine/mongodb-odm": "<1.0.2", "doctrine/mongodb-odm-bundle": "<3.0.1", "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", - "dolibarr/dolibarr": "<=22.0.4", + "dolibarr/dolibarr": "<=23.0.2", "dompdf/dompdf": "<2.0.4", "doublethreedigital/guest-entries": "<3.1.2", "dreamfactory/df-core": "<1.0.4", @@ -1119,7 +1121,7 @@ "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<=4.2", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<2025.81", + "facturascripts/facturascripts": "<=2025.92|>=2026,<=2026.1", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", @@ -1142,6 +1144,7 @@ "flarum/nicknames": "<1.8.3", "flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15", "flarum/tags": "<=0.1.0.0-beta13", + "flightphp/core": "<3.18.1", "floriangaerber/magnesium": "<0.3.1", "fluidtypo3/vhs": "<5.1.1", "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", @@ -1165,14 +1168,16 @@ "froxlor/froxlor": "<2.3.6", "frozennode/administrator": "<=5.0.12", "fuel/core": "<1.8.1", - "funadmin/funadmin": "<=7.1.0.0-RC4", + "funadmin/funadmin": "<=7.1.0.0-RC6", "gaoming13/wechat-php-sdk": "<=1.10.2", "genix/cms": "<=1.1.11", "georgringer/news": "<1.3.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", - "getgrav/grav": "<1.11.0.0-beta1", - "getkirby/cms": "<=5.2.1", + "getgrav/grav": "<=2.0.0.0-RC1", + "getgrav/grav-plugin-api": "<1.0.0.0-beta15", + "getgrav/grav-plugin-form": "<9.1", + "getkirby/cms": "<4.9|>=5,<5.4", "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", @@ -1231,8 +1236,9 @@ "innologi/typo3-appointments": "<2.0.6", "intelliants/subrion": "<4.2.2", "inter-mediator/inter-mediator": "==5.5", + "intercom/intercom-php": "==5.0.2", "invoiceninja/invoiceninja": "<5.13.4", - "ipl/web": "<0.10.1", + "ipl/web": "<=0.13", "islandora/crayfish": "<4.1", "islandora/islandora": ">=2,<2.4.1", "ivankristianto/phpwhois": "<=4.3", @@ -1270,7 +1276,7 @@ "kelvinmo/simplexrd": "<3.1.1", "kevinpapst/kimai2": "<1.16.7", "khodakhah/nodcms": "<=3.4.1", - "kimai/kimai": "<=2.53", + "kimai/kimai": "<=2.55", "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", "klaviyo/magento2-extension": ">=1,<3", "knplabs/knp-snappy": "<=1.4.2", @@ -1328,7 +1334,7 @@ "maikuolan/phpmussel": ">=1,<1.6", "mainwp/mainwp": "<=4.4.3.3", "manogi/nova-tiptap": "<=3.2.6", - "mantisbt/mantisbt": "<2.28.1", + "mantisbt/mantisbt": "<2.28.2", "marcwillmann/turn": "<0.3.3", "markhuot/craftql": "<=1.3.7", "marshmallow/nova-tiptap": "<5.7", @@ -1338,6 +1344,7 @@ "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", + "mckenziearts/livewire-markdown-editor": "<1.3", "mdanter/ecc": "<2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", "mediawiki/cargo": "<3.8.3", @@ -1362,6 +1369,7 @@ "miniorange/miniorange-saml": "<1.4.3", "miraheze/ts-portal": "<=33", "mittwald/typo3_forum": "<1.2.1", + "mix/mix": ">=2,<=2.2.17", "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", @@ -1381,6 +1389,7 @@ "munkireport/softwareupdate": "<1.6", "mustache/mustache": ">=2,<2.14.1", "mwdelaney/wp-enable-svg": "<=0.2", + "nabeel/phpvms": "<7.0.6", "namshi/jose": "<2.2", "nasirkhan/laravel-starter": "<11.11", "nategood/httpful": "<1", @@ -1421,7 +1430,7 @@ "open-web-analytics/open-web-analytics": "<1.8.1", "opencart/opencart": ">=0", "openid/php-openid": "<2.3", - "openmage/magento-lts": "<20.17", + "openmage/magento-lts": "<=20.17", "opensolutions/vimbadmin": "<=3.0.15", "opensource-workshop/connect-cms": "<1.41.1|>=2,<2.41.1", "orchid/platform": ">=8,<14.43", @@ -1464,13 +1473,13 @@ "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<=4.1", + "phpmyfaq/phpmyfaq": "<=4.1.1", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", - "phpoffice/phpspreadsheet": "<1.30|>=2,<2.1.12|>=2.2,<2.4|>=3,<3.10|>=4,<5", + "phpoffice/phpspreadsheet": "<=1.30.3|>=2,<=2.1.15|>=2.2,<=2.4.4|>=3,<=3.10.4|>=4,<=5.6", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<2.0.53|>=3,<3.0.51", + "phpseclib/phpseclib": "<=2.0.53|>=3,<=3.0.51", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<3.4.3", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6", @@ -1486,7 +1495,7 @@ "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3", + "pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3|==12.3.3", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", @@ -1500,9 +1509,9 @@ "prestashop/blockwishlist": ">=2,<2.1.1", "prestashop/contactform": ">=1.0.1,<4.3", "prestashop/gamification": "<2.3.2", - "prestashop/prestashop": "<8.2.5|>=9.0.0.0-alpha1,<9.1", + "prestashop/prestashop": "<8.2.6|>=9,<9.1.1", "prestashop/productcomments": "<5.0.2", - "prestashop/ps_checkout": "<4.4.1|>=5,<5.0.5", + "prestashop/ps_checkout": "<5.3", "prestashop/ps_contactinfo": "<=3.3.2", "prestashop/ps_emailsubscription": "<2.6.1", "prestashop/ps_facetedsearch": "<3.4.1", @@ -1539,6 +1548,7 @@ "rhukster/dom-sanitizer": "<1.0.10", "rmccue/requests": ">=1.6,<1.8", "roadiz/documents": "<2.3.42|>=2.4,<2.5.44|>=2.6,<2.6.28|>=2.7,<2.7.9", + "roadiz/openid": "<2.3.43|>=2.5,<2.5.45|>=2.6,<2.6.31|>=2.7,<2.7.18", "robrichards/xmlseclibs": "<3.1.5", "roots/soil": "<4.1", "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11|>=1.7.0.0-beta,<1.7.0.0-RC5-dev", @@ -1563,7 +1573,7 @@ "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev", "shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", "shopxo/shopxo": "<=6.4", - "showdoc/showdoc": "<2.10.4", + "showdoc/showdoc": "<3.8.1", "shuchkin/simplexlsx": ">=1.0.12,<1.1.13", "silverstripe-australia/advancedreports": ">=1,<=2", "silverstripe/admin": "<1.13.19|>=2,<2.1.8", @@ -1588,6 +1598,7 @@ "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19", "simplesamlphp/saml2-legacy": "<=4.16.15", "simplesamlphp/simplesamlphp": "<1.18.6", + "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2", "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", "simplesamlphp/simplesamlphp-module-openid": "<1", "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", @@ -1602,7 +1613,7 @@ "slim/slim": "<2.6", "slub/slub-events": "<3.0.3", "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<8.3.7", + "snipe/snipe-it": "<8.4.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", "solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6", @@ -1620,9 +1631,9 @@ "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", - "statamic/cms": "<5.73.20|>=6,<6.13", + "statamic/cms": "<5.73.21|>=6,<6.15", "stormpath/sdk": "<9.9.99", - "studio-42/elfinder": "<2.1.67", + "studio-42/elfinder": "<=2.1.67", "studiomitte/friendlycaptcha": "<0.1.4", "subhh/libconnect": "<7.0.8|>=8,<8.1", "sukohi/surpass": "<1", @@ -1694,7 +1705,7 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<4.1.1", + "thorsten/phpmyfaq": "<=4.1.1", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.2", @@ -1714,7 +1725,7 @@ "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19", "typicms/core": "<16.1.7", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", - "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", + "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1|==14.2", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", @@ -1767,7 +1778,7 @@ "wallabag/wallabag": "<2.6.11", "wanglelecc/laracms": "<=1.0.3", "wapplersystems/a21glossary": "<=0.4.10", - "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4", + "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1", "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", @@ -1776,7 +1787,7 @@ "webcoast/deferred-image-processing": "<1.0.2", "webklex/laravel-imap": "<5.3", "webklex/php-imap": "<5.3", - "webonyx/graphql-php": "<=15.31.4", + "webonyx/graphql-php": "<=15.32.2", "webpa/webpa": "<3.1.2", "webreinvent/vaahcms": "<=2.3.1", "wikibase/wikibase": "<=1.39.3", @@ -1806,7 +1817,7 @@ "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", "yiisoft/yii": "<1.1.31", - "yiisoft/yii2": "<2.0.52", + "yiisoft/yii2": "<2.0.55", "yiisoft/yii2-authclient": "<2.2.15", "yiisoft/yii2-bootstrap": "<2.0.4", "yiisoft/yii2-dev": "<=2.0.45", @@ -1896,7 +1907,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T21:20:32+00:00" + "time": "2026-05-15T18:36:41+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/AppInfo/Application.php b/lib/AppInfo/Application.php index 43238b34fd..6cfef858ca 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 4123ef0840..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 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 9bfa1862bd..f526ddbbbc 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 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 6497895d34..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 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..c9c4e1793a 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,17 @@ 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(); + } catch (\Throwable) { + } + } } diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 09498f04dd..a278ff6de5 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -8,34 +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\ConfigureCheckService; use OCA\Libresign\Service\Install\InstallService; -use OCA\Libresign\Service\ReminderService; 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\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; @@ -56,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 +62,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, ) { @@ -233,14 +217,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); } @@ -264,26 +242,7 @@ public function configureCheck(): DataResponse { ); } - /** - * 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 @@ -345,29 +304,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']); @@ -411,23 +350,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 * @@ -446,182 +368,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']); @@ -645,15 +405,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']); } /** @@ -685,394 +455,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 - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @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)'])] - 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 9da50bab63..ba49a69748 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -183,7 +183,7 @@ public function validateBinary(): DataResponse { ->toArray(); $statusCode = Http::STATUS_OK; } catch (InvalidArgumentException $e) { - $message = $this->l10n->t($e->getMessage()); + $message = $e->getMessage(); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] @@ -255,15 +255,15 @@ private function validate( ->toArray(); $statusCode = Http::STATUS_OK; } catch (LibresignException $e) { - $message = $this->l10n->t($e->getMessage()); + $message = $e->getMessage(); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] ]; $statusCode = Http::STATUS_NOT_FOUND; } catch (\Throwable $th) { - $message = $this->l10n->t($th->getMessage()); - $this->logger->error($message); + $this->logger->error($th->getMessage(), ['exception' => $th]); + $message = $this->l10n->t('Internal error. Contact admin.'); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] 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..3e0c5221ce 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); } @@ -51,7 +55,7 @@ public function __construct( * Used to identify who can sign the document. The return of this endpoint is related with Administration Settiongs > 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 46f41b8a98..13a52daa20 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,17 +12,18 @@ use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\FooterHandler; 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\PolicyService; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SessionService; use OCA\Libresign\Service\SignerElementsService; @@ -60,6 +61,8 @@ public function __construct( private AccountService $accountService, protected SignFileService $signFileService, protected RequestSignatureService $requestSignatureService, + private PolicyService $policyService, + private FooterHandler $footerHandler, private SignerElementsService $signerElementsService, protected IL10N $l10n, private IdentifyMethodService $identifyMethodService, @@ -72,7 +75,6 @@ public function __construct( private ValidateHelper $validateHelper, private IEventDispatcher $eventDispatcher, private IURLGenerator $urlGenerator, - private ConfigService $docMdpConfigService, ) { parent::__construct( request: $request, @@ -107,10 +109,14 @@ 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')); + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $resolvedPolicies, + ]); + $this->initialState->provideInitialState('footer_template', $this->footerHandler->getTemplate()); Util::addScript(Application::APP_ID, 'libresign-main'); Util::addStyle(Application::APP_ID, 'libresign-main'); @@ -637,7 +643,13 @@ public function validationFilePublic(string $uuid): TemplateResponse { $this->fileService->setSignRequest($signRequest); } - $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $resolvedPolicies, + ]); $this->initialState->provideInitialState('file_info', $this->fileService diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php new file mode 100644 index 0000000000..b119a2d019 --- /dev/null +++ b/lib/Controller/PolicyController.php @@ -0,0 +1,645 @@ + + * + * 200: OK + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])] + public function effective(): DataResponse { + $user = $this->userSession->getUser(); + $ruleCounts = $this->resolveRuleCountsForActor($user); + + /** @var array $policies */ + $policies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + /** @var LibresignEffectivePolicyState $policyState */ + $policyState = $resolvedPolicy->toArray(); + $policyState['groupCount'] = $ruleCounts[$policyKey]['groupCount'] ?? 0; + $policyState['userCount'] = $ruleCounts[$policyKey]['userCount'] ?? 0; + $policies[$policyKey] = $policyState; + } + + /** @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->canManageGroupPolicy($groupId)) { + return $this->forbiddenGroupPolicyResponse(); + } + + $policy = $this->policyService->getGroupPolicy($policyKey, $groupId); + + /** @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 { + $records = $this->policyService->listGroupPolicies($policyKey); + $policies = []; + + foreach ($records as $record) { + $groupId = (string)($record['targetId'] ?? ''); + $policy = $record['policy'] ?? null; + if ($groupId === '' || !$policy instanceof PolicyLayer) { + continue; + } + + if (!$this->canManageGroupPolicy($groupId)) { + 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 + */ + #[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->canManageUserPolicy($userId)) { + 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 + */ + #[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->canManageUserPolicy($userId)) { + 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->canManageGroupPolicy($groupId)) { + return $this->forbiddenGroupPolicyResponse(); + } + + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value); + $policy = $this->policyService->saveGroupPolicy($policyKey, $groupId, $value, $allowChildOverride); + /** @var LibresignGroupPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeGroupPolicy($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 + * + * 200: OK + * 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->canManageGroupPolicy($groupId)) { + return $this->forbiddenGroupPolicyResponse(); + } + + try { + $policy = $this->policyService->clearGroupPolicy($policyKey, $groupId); + /** @var LibresignGroupPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } 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 + */ + #[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->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $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 + */ + #[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->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $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() ?? [], + ]; + } + + /** @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 canManageGroupPolicy(string $groupId): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + $group = $this->groupManager->get($groupId); + if ($group === null) { + return false; + } + + return $this->subAdmin->isSubAdminOfGroup($user, $group); + } + + private function canManageUserPolicy(string $userId): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + 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 resolveRuleCountsForActor(?IUser $user): array { + if ($user === null) { + return []; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + $groupIds = array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->groupManager->search(''), + )); + $userIds = array_values(array_map( + static fn ($candidate): string => $candidate->getUID(), + $this->userManager->searchDisplayName(''), + )); + + return $this->policyService->getRuleCounts($groupIds, $userIds); + } + + if ($this->subAdmin->isSubAdmin($user)) { + $groupIds = array_map( + static fn ($group) => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + ); + return $this->policyService->getRuleCounts($groupIds, []); + } + + return []; + } + + 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..c64de56605 100644 --- a/lib/Controller/RequestSignatureController.php +++ b/lib/Controller/RequestSignatureController.php @@ -68,7 +68,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,10 +87,13 @@ public function requestSignature( array $files = [], ?string $callback = null, ?int $status = 1, - ?string $signatureFlow = null, + ?array $policy = null, ): DataResponse { try { $user = $this->userSession->getUser(); + $policyOverrides = $this->extractPolicyOverrides($policy); + $policyActiveContext = $this->extractPolicyActiveContext($policy); + return $this->createSignatureRequest( $user, $file, @@ -100,7 +103,8 @@ public function requestSignature( $signers, $status, $callback, - $signatureFlow + $policyOverrides, + $policyActiveContext, ); } catch (LibresignException $e) { $errorMessage = $e->getMessage(); @@ -135,7 +139,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 +159,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 = [], @@ -164,6 +168,8 @@ public function updateSignatureRequest( $user = $this->userSession->getUser(); $signers = is_array($signers) ? $signers : []; $file = is_array($file) ? $file : []; + $policyOverrides = $this->extractPolicyOverrides($policy); + $policyActiveContext = $this->extractPolicyActiveContext($policy); if (empty($uuid)) { return $this->createSignatureRequest( @@ -175,7 +181,8 @@ public function updateSignatureRequest( $signers, $status, null, - $signatureFlow, + $policyOverrides, + $policyActiveContext, $visibleElements ); } @@ -186,7 +193,8 @@ public function updateSignatureRequest( 'signers' => $signers, 'userManager' => $user, 'visibleElements' => $visibleElements, - 'signatureFlow' => $signatureFlow, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, 'name' => $name, 'settings' => $settings, ]; @@ -230,7 +238,8 @@ private function createSignatureRequest( array $signers, ?int $status, ?string $callback, - ?string $signatureFlow, + array $policyOverrides = [], + ?array $policyActiveContext = null, ?array $visibleElements = null, ): DataResponse { $isEnvelope = !empty($files); @@ -247,7 +256,8 @@ private function createSignatureRequest( 'signers' => $signers, 'callback' => $callback, 'userManager' => $user, - 'signatureFlow' => $signatureFlow, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, 'settings' => !empty($settings) ? $settings : ($file['settings'] ?? []), ]; @@ -370,4 +380,18 @@ private function loadChildFilesIfEnvelope($fileEntity): array { ? $this->fileMapper->getChildrenFiles($fileEntity->getId()) : []; } + + /** @return array */ + private function extractPolicyOverrides(?array $policy): array { + $overrides = $policy['overrides'] ?? null; + + return is_array($overrides) ? $overrides : []; + } + + /** @return array|null */ + private function extractPolicyActiveContext(?array $policy): ?array { + $activeContext = $policy['activeContext'] ?? null; + + return is_array($activeContext) ? $activeContext : null; + } } 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/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..7b1c7b1cfc --- /dev/null +++ b/lib/Db/PermissionSet.php @@ -0,0 +1,108 @@ +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 @@ +policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + return [ 'certificate_ok' => $this->certificateEngineFactory->getEngine()->isSetupOk(), - 'identify_methods' => $this->identifyMethodService->getIdentifyMethodsSettings(), - 'signature_flow' => $this->getSignatureFlow(), - 'docmdp_config' => $this->docMdpConfigService->getConfig(), + 'effective_policies' => [ + 'policies' => $resolvedPolicies, + ], '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/Handler/CertificateEngine/AEngineHandler.php b/lib/Handler/CertificateEngine/AEngineHandler.php index 7aca24face..be7c25113f 100644 --- a/lib/Handler/CertificateEngine/AEngineHandler.php +++ b/lib/Handler/CertificateEngine/AEngineHandler.php @@ -19,6 +19,9 @@ use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\Crl\CrlDistributionPointsExtractor; use OCA\Libresign\Service\Crl\CrlRevocationChecker; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ExpirationRules\ExpirationRulesPolicy; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IAppData; use OCP\Files\SimpleFS\ISimpleFolder; @@ -85,6 +88,7 @@ public function __construct( protected CertificatePolicyService $certificatePolicyService, protected IURLGenerator $urlGenerator, protected CaIdentifierService $caIdentifierService, + protected PolicyService $policyService, protected LoggerInterface $logger, private CrlRevocationChecker $crlRevocationChecker, ) { @@ -204,10 +208,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); + $certData['crl_validation'] = ($emptyCrlValidation['status'] ?? CrlValidationStatus::NO_URLS) === CrlValidationStatus::DISABLED + ? CrlValidationStatus::DISABLED + : CrlValidationStatus::MISSING; $certData['crl_urls'] = []; } @@ -276,9 +280,9 @@ public function translateToLong($name): string { #[\Override] public function setEngine(string $engine): void { + $this->configureIdentifyMethodsForEngine($engine); $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine); $this->engine = $engine; - $this->configureIdentifyMethodsForEngine($engine); } /** @@ -305,7 +309,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] @@ -477,7 +481,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; } diff --git a/lib/Handler/CertificateEngine/CfsslHandler.php b/lib/Handler/CertificateEngine/CfsslHandler.php index d55adf4074..4e869b6f7e 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; @@ -58,6 +59,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 +74,7 @@ public function __construct( $certificatePolicyService, $urlGenerator, $caIdentifierService, + $policyService, $logger, $crlRevocationChecker, ); 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 ad0f1d2afd..f65d277a55 100644 --- a/lib/Handler/DocMdpHandler.php +++ b/lib/Handler/DocMdpHandler.php @@ -329,14 +329,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 +348,10 @@ 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', + DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation - no changes allowed)'), + DocMdpLevel::CERTIFIED_FORM_FILLING => $this->l10n->t('Document form fields were modified (allowed by DocMDP P=2)'), + DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $this->l10n->t('Document form fields or annotations were modified (allowed by DocMDP P=3)'), + default => $this->l10n->t('Document was modified after signing'), }; } diff --git a/lib/Handler/FooterHandler.php b/lib/Handler/FooterHandler.php index fd5ea2c882..fbb54281bc 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; @@ -31,6 +34,10 @@ class FooterHandler { private QrCode $qrCode; + /** @var array */ + private array $requestPolicyOverrides = []; + private ?string $templateOverride = null; + private ?bool $writeQrcodeOnFooterOverride = null; private const MIN_QRCODE_SIZE = 100; private const POINT_TO_MILIMETER = 0.3527777778; @@ -41,17 +48,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 +101,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 +119,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 +168,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 +191,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,9 +207,17 @@ 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(); } @@ -204,4 +249,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 2d7821cd72..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,17 +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 d699ce192c..dfb6ef14ff 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 MIN_PDF_VERSION_SHA256 = 1.6; private const TARGET_PDF_VERSION_SHA256 = '1.6'; private const MIN_PDF_VERSION_SHA1_REJECT = 1.7; - private const SIGNATURE_DEFAULT_FONT_SIZE = 10.0; private const PAGE_FIRST = 1; private const 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 @@ -226,7 +231,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'; } @@ -313,7 +318,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; } @@ -641,24 +646,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'; @@ -670,6 +676,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/SigningErrorHandler.php b/lib/Handler/SigningErrorHandler.php index f3cd539c52..c6a6383f35 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,15 @@ private function isKnownError(string $message): bool { ], true); } + private function translateKnownError(string $message): string { + return match ($message) { + 'Host violates local access rules.' => $this->l10n->t('Host violates local access rules.'), + 'Certificate Password Invalid.' => $this->l10n->t('Certificate Password Invalid.'), + 'Certificate Password is Empty.' => $this->l10n->t('Certificate Password is Empty.'), + default => $message, + }; + } + /** * @return list */ diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index c58327cd1d..cf8ee27222 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, ) { } @@ -407,7 +404,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,21 +501,7 @@ 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)) { - 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)) { + if (!$this->requestSignAuthorizationService->canRequestSign($user)) { throw new LibresignException( json_encode([ 'action' => JSActions::ACTION_DO_NOTHING, @@ -879,6 +861,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 +933,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 +950,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/InjectionMiddleware.php b/lib/Middleware/InjectionMiddleware.php index 1f0d9b58e9..5b3f342d8b 100644 --- a/lib/Middleware/InjectionMiddleware.php +++ b/lib/Middleware/InjectionMiddleware.php @@ -29,6 +29,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 +43,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; @@ -63,8 +64,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, protected ?string $userId, ) { @@ -114,7 +115,9 @@ private function privateValidation(\ReflectionMethod $reflectionMethod): void { if ($this->userSession->isLoggedIn()) { return; } - $isValidationUrlPrivate = (bool)$this->appConfig->getValueBool(Application::APP_ID, 'make_validation_url_private', false); + $isValidationUrlPrivate = $this->policyService + ->resolve(ValidationAccessPolicy::KEY) + ->getEffectiveValueAsBool(); if (!$isValidationUrlPrivate) { return; } 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..e191a33804 --- /dev/null +++ b/lib/Migration/Version18003Date20260517000000.php @@ -0,0 +1,719 @@ +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(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE); + // 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(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE_FONT_SIZE); + $legacySignatureFontSize = $this->readLegacyFloat(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_FONT_SIZE); + $legacySignatureWidth = $this->readLegacyFloat(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_WIDTH); + $legacySignatureHeight = $this->readLegacyFloat(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_HEIGHT); + $legacyBackgroundType = $this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_BACKGROUND_TYPE); + $legacyRenderMode = $this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_RENDER_MODE); + + $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, + SignatureTextPolicy::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, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY, + $encodedValue, + ); + } + + private function deleteLegacySignatureTextKeys(): void { + $legacyKeys = [ + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE_FONT_SIZE, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_WIDTH, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_HEIGHT, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_FONT_SIZE, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_RENDER_MODE, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_BACKGROUND_TYPE, + ]; + + 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_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; + } + + $prepared = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($decoded); + + return IdentifyMethodsPolicyValue::normalize($prepared); + } + + 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['factors']) && is_array($payload['factors'])) { + $payload['factors'] = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($payload['factors']); + } + + return $payload; + } + + /** + * @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 a56c78018d..74d8e889c8 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) { + // TRANSLATORS Notification message informing the signer that a pending document changed and should be reviewed again. $notification->setParsedMessage($l->t('Changes have been made in a file that you have 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( @@ -236,6 +244,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( @@ -251,6 +260,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 91fb39436a..6ff1364d32 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, @@ -117,7 +123,8 @@ * name: string, * friendly_name: string, * enabled: bool, - * mandatory: bool, + * requirement: LibresignIdentifyMethodRequirement, + * minimumTotalVerifiedFactors?: positive-int, * signatureMethods?: LibresignSignatureMethods, * } * @psalm-type LibresignIdentifyAccount = array{ @@ -126,7 +133,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 +148,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, @@ -285,7 +292,7 @@ * } * @psalm-type LibresignRootCertificateName = array{ * id: string, - * value: string, + * value: string|list|null, * } * @psalm-type LibresignRootCertificate = array{ * commonName: string, @@ -321,8 +328,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, @@ -334,19 +339,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, @@ -354,8 +346,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, @@ -371,11 +370,94 @@ * * Validation and progress contracts * + * @psalm-type LibresignEffectivePolicyValue = null|bool|int|float|string|array + * @psalm-type LibresignEffectivePolicyState = array{ + * policyKey: string, + * effectiveValue: LibresignEffectivePolicyValue, + * sourceScope: string, + * visible: bool, + * editableByCurrentActor: bool, + * allowedValues: list, + * canSaveAsUserDefault: bool, + * canUseAsRequestOverride: bool, + * preferenceWasCleared: bool, + * blockedBy: ?string, + * groupCount: non-negative-int, + * userCount: 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, + * allowChildOverride: bool, + * visibleToChild: bool, + * allowedValues: list, + * } + * @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 LibresignPolicySnapshotNumericEntry = array{ + * effectiveValue: int, + * sourceScope: string, + * } + * @psalm-type LibresignPolicySnapshotIdentifyMethodsEntry = array{ + * effectiveValue: list, + * sourceScope: string, + * } + * @psalm-type LibresignValidatePolicySnapshot = array{ + * docmdp?: LibresignPolicySnapshotNumericEntry, + * signature_flow?: LibresignPolicySnapshotEntry, + * add_footer?: LibresignPolicySnapshotEntry, + * 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, * } diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index 13f2974e55..579d4b6a6f 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, ) { } @@ -194,8 +199,7 @@ public function getCertificateEngineName(): string { * @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 +211,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 +277,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 +384,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 81d5edd033..f583b29808 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; @@ -34,7 +34,7 @@ class CrlRevocationChecker { public function __construct( private IConfig $config, - private IAppConfig $appConfig, + private PolicyService $policyService, private IURLGenerator $urlGenerator, private ITempManager $tempManager, private LoggerInterface $logger, @@ -62,7 +62,7 @@ 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); + $externalValidationEnabled = $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 39e0689d99..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 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 6248b24975..29e2f01ca9 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/EnvelopeAssembler.php b/lib/Service/File/EnvelopeAssembler.php index cb6c304da3..ad9dcd6583 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(); } diff --git a/lib/Service/File/FileListService.php b/lib/Service/File/FileListService.php index 94977366db..58baf68927 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/SettingsLoader.php b/lib/Service/File/SettingsLoader.php index 552bdb0cbc..bab43071fc 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)) { 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..8e36d2c214 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) { diff --git a/lib/Service/FolderService.php b/lib/Service/FolderService.php index 626193bdaf..a263fbe54d 100644 --- a/lib/Service/FolderService.php +++ b/lib/Service/FolderService.php @@ -69,14 +69,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; } /** @@ -108,17 +106,42 @@ 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; + 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'; 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..cee0cd0c01 100644 --- a/lib/Service/IdDocsPolicyService.php +++ b/lib/Service/IdDocsPolicyService.php @@ -8,27 +8,33 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\IdDocsMapper; 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, ) { } public function canApproverSignIdDoc(IUser $user, int $fileId, int $status): bool { - if (!$this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)) { + if (!$this->isIdentificationDocumentsEnabled($user)) { return false; } - if (!$this->validateHelper->userCanApproveValidationDocuments($user, false)) { + if (!$this->userCanApproveValidationDocuments($user, false)) { return false; } $readyStatuses = [FileStatus::ABLE_TO_SIGN->value, FileStatus::PARTIAL_SIGNED->value]; @@ -42,4 +48,55 @@ public function canApproverSignIdDoc(IUser $user, int $fileId, int $status): boo return false; } } + + public function isIdentificationDocumentsEnabled(?IUser $user = null): bool { + $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): array { + $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()); + } } diff --git a/lib/Service/IdDocsService.php b/lib/Service/IdDocsService.php index dd5be169c0..dbaff8a827 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/ResultEnricher.php b/lib/Service/Identify/ResultEnricher.php index 0b2fbfd1e0..815d49d9cf 100644 --- a/lib/Service/Identify/ResultEnricher.php +++ b/lib/Service/Identify/ResultEnricher.php @@ -10,6 +10,8 @@ use OCA\Libresign\Service\IdentifyMethod\Account; use OCA\Libresign\Service\IdentifyMethod\Email; +use OCP\Config\IUserConfig; +use OCP\IAppConfig; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; @@ -20,6 +22,8 @@ public function __construct( private IUserManager $userManager, private Email $identifyEmailMethod, private Account $identifyAccountMethod, + private IAppConfig $appConfig, + private IUserConfig $userConfig, ) { } @@ -128,21 +132,34 @@ private function userMatchesSearch(IUser $user, string $searchLower): bool { } private function isNotificationDisabledAtActivity(string $userId, string $type): bool { - if (!class_exists(\OCA\Activity\UserSettings::class)) { - return false; - } - $activityUserSettings = \OCP\Server::get(\OCA\Activity\UserSettings::class); + $key = sprintf('notify_email_%s', $type); - $adminSetting = $activityUserSettings->getAdminSetting('email', $type); - if (!$adminSetting) { + $adminSetting = $this->appConfig->getValueString('activity', $key, '1'); + if (!$this->isTruthySetting($adminSetting)) { return true; } - $userSetting = $activityUserSettings->getUserSetting($userId, 'email', $type); - if (!$userSetting) { + $userSetting = $this->userConfig->getValueString($userId, 'activity', $key, ''); + if (!$this->isTruthySetting($userSetting)) { return true; } return false; } + + private function isTruthySetting(mixed $value): bool { + if (is_bool($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return (int)$value === 1; + } + + if (!is_string($value)) { + return false; + } + + return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true); + } } 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 670eaab0d2..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 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 3bcd157399..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 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..66404abd9a 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; @@ -132,6 +133,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 +237,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 +268,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 +286,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 +339,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 +371,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 +379,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 3019a6df26..6d97ce2d5f 100644 --- a/lib/Service/IdentifyMethod/Email.php +++ b/lib/Service/IdentifyMethod/Email.php @@ -219,4 +219,13 @@ public function getSettings(): array { ); return $this->settings; } + + #[\Override] + public function getDefaultSettings(): array { + $settings = parent::getDefaultSettings(); + $settings['enabled'] = false; + $settings['can_create_account'] = true; + $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..e84f955574 --- /dev/null +++ b/lib/Service/IdentifyMethod/RuntimeRequirementValidator.php @@ -0,0 +1,233 @@ +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']) { + 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) { + throw new LibresignException( + $this->l10n->t('You need to complete at least %s 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/TwofactorGatewayToken.php b/lib/Service/IdentifyMethod/SignatureMethod/TwofactorGatewayToken.php index ad6f5756d3..8b4cff5e7d 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 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..29649ea813 100644 --- a/lib/Service/IdentifyMethodService.php +++ b/lib/Service/IdentifyMethodService.php @@ -11,8 +11,8 @@ 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; use OCA\Libresign\Service\IdentifyMethod\Email; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; @@ -20,12 +20,13 @@ 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 OCP\IL10N; use OCP\IUserManager; /** - * @psalm-import-type LibresignIdentifyMethodSetting from ResponseDefinitions + * @psalm-import-type LibresignIdentifyMethodSetting from \OCA\Libresign\ResponseDefinitions */ class IdentifyMethodService { public const IDENTIFY_ACCOUNT = 'account'; @@ -34,7 +35,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,13 +61,16 @@ 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 array> @@ -67,6 +87,7 @@ public function __construct( private Sms $sms, private Telegram $telegram, private Whatsapp $Whatsapp, + private Whatsappbusiness $whatsappbusiness, private Xmpp $xmpp, private SubjectAlternativeNameService $subjectAlternativeNameService, ) { @@ -82,7 +103,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 +118,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 +163,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')); + public function exists(string $name): bool { + $className = 'OCA\\Libresign\\Service\\IdentifyMethod\\' . ucfirst($name); + if (class_exists($className)) { + return true; } - $identifyMethod = $this->getInstanceOfIdentifyMethod($method, $identifyValue); + 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 +188,22 @@ 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; + $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; } /** @@ -332,7 +363,10 @@ public function save(SignRequest $signRequest, bool $notify = true): void { } } - /** @return list */ + /** + * @return array + * @psalm-return list + */ public function getIdentifyMethodsSettings(): array { if ($this->identifyMethodsSettings) { return $this->identifyMethodsSettings; @@ -353,12 +387,92 @@ 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; } + /** @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, + * signatureMethodEnabled: string + * }> + */ + public function getDefaultIdentifyMethodsPolicy(): array { + return [ + $this->buildDefaultPolicyFactorFromSettings($this->account->getDefaultSettings()), + $this->buildDefaultPolicyFactorFromSettings($this->email->getDefaultSettings()), + ]; + } + + /** + * @param LibresignIdentifyMethodSetting $settings + * @return array{ + * name: string, + * enabled: bool, + * requirement: 'required'|'optional', + * signatureMethods: array, + * signatureMethodEnabled: string + * } + */ + private function buildDefaultPolicyFactorFromSettings(array $settings): array { + $signatureMethods = []; + $signatureMethodEnabled = ''; + + foreach ($settings['signatureMethods'] ?? [] as $signatureMethodName => $signatureMethodConfig) { + $isEnabled = (bool)($signatureMethodConfig['enabled'] ?? false); + $signatureMethods[$signatureMethodName] = [ + 'enabled' => $isEnabled, + ]; + + if ($signatureMethodEnabled === '' && $isEnabled) { + $signatureMethodEnabled = $signatureMethodName; + } + } + + if ($signatureMethodEnabled === '' && !empty($signatureMethods)) { + $signatureMethodEnabled = (string)array_key_first($signatureMethods); + } + + $requirement = IdentifyMethodRequirement::tryFrom((string)($settings['requirement'] ?? '')); + + return [ + 'name' => $settings['name'], + 'enabled' => (bool)($settings['enabled'] ?? true), + 'requirement' => $requirement?->value ?? IdentifyMethodRequirement::REQUIRED->value, + 'signatureMethods' => $signatureMethods, + 'signatureMethodEnabled' => $signatureMethodEnabled, + ]; + } + /** * Resolve UID from certificate chain data * diff --git a/lib/Service/Install/SignSetupService.php b/lib/Service/Install/SignSetupService.php index 21c3e3178f..07568bb1c5 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/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..88c0dd0d51 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyDefinition.php @@ -0,0 +1,37 @@ + */ + public function allowedValues(PolicyContext $context): array; + + public function defaultSystemValue(): mixed; + + /** + * 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; +} 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..c5be9fd353 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicySource.php @@ -0,0 +1,80 @@ + */ + 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; + + public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void; + + public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride): void; + + public function clearGroupPolicy(string $policyKey, string $groupId): 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..0d9b075bcc --- /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 signature_flow + docmdp 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..19ff45fc8b --- /dev/null +++ b/lib/Service/Policy/IPolicyAuthorizationService.php @@ -0,0 +1,32 @@ + + */ + public function getManageablePolicyGroupIds(?IUser $user): array; +} diff --git a/lib/Service/Policy/Model/PolicyContext.php b/lib/Service/Policy/Model/PolicyContext.php new file mode 100644 index 0000000000..0e7d56f818 --- /dev/null +++ b/lib/Service/Policy/Model/PolicyContext.php @@ -0,0 +1,93 @@ + */ + private array $groups = []; + /** @var list */ + private array $circles = []; + /** @var array|null */ + private ?array $activeContext = null; + /** @var array */ + private array $requestOverrides = []; + /** @var array */ + private array $actorCapabilities = []; + + 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; + } + + /** @param array|null $activeContext */ + public function setActiveContext(?array $activeContext): self { + $this->activeContext = $activeContext; + return $this; + } + + /** @return array|null */ + public function getActiveContext(): ?array { + return $this->activeContext; + } + + /** @param array $requestOverrides */ + public function setRequestOverrides(array $requestOverrides): self { + $this->requestOverrides = $requestOverrides; + return $this; + } + + /** @return array */ + public function getRequestOverrides(): array { + return $this->requestOverrides; + } + + /** @param array $actorCapabilities */ + public function setActorCapabilities(array $actorCapabilities): self { + $this->actorCapabilities = $actorCapabilities; + return $this; + } + + /** @return array */ + public function getActorCapabilities(): array { + return $this->actorCapabilities; + } +} diff --git a/lib/Service/Policy/Model/PolicyLayer.php b/lib/Service/Policy/Model/PolicyLayer.php new file mode 100644 index 0000000000..16e8cdc17b --- /dev/null +++ b/lib/Service/Policy/Model/PolicyLayer.php @@ -0,0 +1,78 @@ + */ + private array $allowedValues = []; + /** @var array */ + private array $notes = []; + + 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; + } + + /** @param array $notes */ + public function setNotes(array $notes): self { + $this->notes = $notes; + return $this; + } + + /** @return array */ + public function getNotes(): array { + return $this->notes; + } +} diff --git a/lib/Service/Policy/Model/PolicySpec.php b/lib/Service/Policy/Model/PolicySpec.php new file mode 100644 index 0000000000..c748576c8f --- /dev/null +++ b/lib/Service/Policy/Model/PolicySpec.php @@ -0,0 +1,113 @@ +|Closure(PolicyContext): list */ + private array|Closure $allowedValuesResolver; + /** @var Closure(mixed): mixed|null */ + private ?Closure $normalizer; + /** @var Closure(mixed, PolicyContext): void|null */ + private ?Closure $validator; + + /** + * @param list|Closure(PolicyContext): list $allowedValues + * @param Closure(mixed): mixed|null $normalizer + * @param Closure(mixed, PolicyContext): void|null $validator + */ + public function __construct( + private string $key, + private mixed $defaultSystemValue, + array|Closure $allowedValues, + ?Closure $normalizer = null, + ?Closure $validator = null, + private ?string $appConfigKey = null, + private ?string $userPreferenceKey = null, + private string $resolutionMode = self::RESOLUTION_MODE_RESOLVED, + private bool $supportsUserPreference = true, + ) { + $this->allowedValuesResolver = $allowedValues; + $this->normalizer = $normalizer; + $this->validator = $validator; + } + + #[\Override] + public function key(): string { + return $this->key; + } + + #[\Override] + public function resolutionMode(): string { + return $this->resolutionMode; + } + + #[\Override] + public function getAppConfigKey(): string { + return $this->appConfigKey ?? $this->key; + } + + #[\Override] + public function getUserPreferenceKey(): string { + return $this->userPreferenceKey ?? 'policy.' . $this->key; + } + + #[\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; + } + + #[\Override] + public function supportsUserPreference(): bool { + return $this->supportsUserPreference; + } +} diff --git a/lib/Service/Policy/Model/ResolvedPolicy.php b/lib/Service/Policy/Model/ResolvedPolicy.php new file mode 100644 index 0000000000..1a6521b3a0 --- /dev/null +++ b/lib/Service/Policy/Model/ResolvedPolicy.php @@ -0,0 +1,167 @@ + */ + private array $allowedValues = []; + private bool $canSaveAsUserDefault = false; + private bool $canUseAsRequestOverride = false; + private bool $preferenceWasCleared = false; + private ?string $blockedBy = null; + + 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; + } + + /** @return array */ + public function toArray(): array { + return [ + '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(), + ]; + } +} diff --git a/lib/Service/Policy/PolicyAuthorizationService.php b/lib/Service/Policy/PolicyAuthorizationService.php new file mode 100644 index 0000000000..a561539ebf --- /dev/null +++ b/lib/Service/Policy/PolicyAuthorizationService.php @@ -0,0 +1,65 @@ +groupManager->isAdmin($user->getUID()) + || $this->subAdmin->isSubAdmin($user); + } + + /** + * Get list of group IDs manageable by the given user through subadmin scope. + * + * For instance admins: returns empty (they manage all groups at policy level). + * For subadmins: returns groups they are subadmin of. + * 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 []; + } + + // Only subadmins have a restricted manageable group scope + return array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + } +} diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php new file mode 100644 index 0000000000..5994018bcc --- /dev/null +++ b/lib/Service/Policy/PolicyService.php @@ -0,0 +1,208 @@ +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 { + $context = $this->contextFactory->forCurrentUser($requestOverrides, $activeContext); + $definitions = []; + foreach (array_keys(PolicyProviders::BY_KEY) as $policyKey) { + $definitions[] = $this->registry->get($policyKey); + } + + return $this->resolver->resolveMany($definitions, $context); + } + + 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); + return $this->source->loadUserPolicyConfig($definition->key(), $userId); + } + + /** + * @return list + */ + public function listUserPolicies(string|\BackedEnum $policyKey): array { + $definition = $this->registry->get($policyKey); + 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); + $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, $context); + } + + public function getGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + + /** + * @return list + */ + public function listGroupPolicies(string|\BackedEnum $policyKey): array { + $definition = $this->registry->get($policyKey); + return $this->source->listGroupPoliciesByKey($definition->key()); + } + + public function saveGroupPolicy(string|\BackedEnum $policyKey, string $groupId, mixed $value, bool $allowChildOverride): PolicyLayer { + $definition = $this->registry->get($policyKey); + $this->assertCurrentActorCanManageGroupOverride($definition->key()); + $context = $this->contextFactory->forCurrentUser(); + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $this->source->saveGroupPolicy($definition->key(), $groupId, $normalizedValue, $allowChildOverride); + + 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->assertCurrentActorCanManageGroupOverride($definition->key()); + $this->source->clearGroupPolicy($definition->key(), $groupId); + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + + private function assertCurrentActorCanManageGroupOverride(string $policyKey): void { + if ($this->contextFactory->isCurrentActorSystemAdmin()) { + return; + } + + $systemPolicy = $this->source->loadSystemPolicy($policyKey); + if ($systemPolicy !== null && !$systemPolicy->isAllowChildOverride()) { + throw new \DomainException($this->l10n->t('Lower-level overrides are not allowed for this policy')); + } + } + + public function saveUserPreference(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy { + $context = $this->contextFactory->forCurrentUser(); + $definition = $this->registry->get($policyKey); + $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(), + ])); + } + + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $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->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); + $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->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(); + } +} diff --git a/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php new file mode 100644 index 0000000000..dc628e0f15 --- /dev/null +++ b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..63794cccc3 --- /dev/null +++ b/lib/Service/Policy/Provider/CollectMetadata/CollectMetadataPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..bd9febd6c2 --- /dev/null +++ b/lib/Service/Policy/Provider/Confetti/ConfettiPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..e9d448e696 --- /dev/null +++ b/lib/Service/Policy/Provider/CrlValidation/CrlValidationPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..2fe24e7114 --- /dev/null +++ b/lib/Service/Policy/Provider/DefaultUserFolder/DefaultUserFolderPolicy.php @@ -0,0 +1,51 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..078a8f2932 --- /dev/null +++ b/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php @@ -0,0 +1,67 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..102e4906b1 --- /dev/null +++ b/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php @@ -0,0 +1,104 @@ +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; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private 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, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(DocMdpPolicy::KEY, $data['policyOverrides'])) { + return [DocMdpPolicy::KEY => $data['policyOverrides'][DocMdpPolicy::KEY]]; + } + + return []; + } + + private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); + } +} diff --git a/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php b/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php new file mode 100644 index 0000000000..f53a4779f6 --- /dev/null +++ b/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..32cc6baf7d --- /dev/null +++ b/lib/Service/Policy/Provider/ExpirationRules/ExpirationRulesPolicy.php @@ -0,0 +1,109 @@ +normalizePolicyKey($policyKey); + + return match ($normalizedKey) { + self::KEY_MAXIMUM_VALIDITY => new PolicySpec( + key: self::KEY_MAXIMUM_VALIDITY, + defaultSystemValue: self::DEFAULT_MAXIMUM_VALIDITY, + allowedValues: [], + normalizer: static fn (mixed $rawValue): int => self::normalizeNonNegativeInt($rawValue, self::DEFAULT_MAXIMUM_VALIDITY), + appConfigKey: self::KEY_MAXIMUM_VALIDITY, + ), + self::KEY_RENEWAL_INTERVAL => new PolicySpec( + key: self::KEY_RENEWAL_INTERVAL, + defaultSystemValue: self::DEFAULT_RENEWAL_INTERVAL, + allowedValues: [], + normalizer: static fn (mixed $rawValue): int => self::normalizeNonNegativeInt($rawValue, self::DEFAULT_RENEWAL_INTERVAL), + appConfigKey: self::KEY_RENEWAL_INTERVAL, + ), + self::KEY_EXPIRY_IN_DAYS => new PolicySpec( + key: self::KEY_EXPIRY_IN_DAYS, + defaultSystemValue: self::DEFAULT_EXPIRY_IN_DAYS, + allowedValues: [], + normalizer: static fn (mixed $rawValue): int => self::normalizePositiveInt($rawValue, self::DEFAULT_EXPIRY_IN_DAYS), + appConfigKey: self::KEY_EXPIRY_IN_DAYS, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } + + 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..400443c7c8 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php @@ -0,0 +1,113 @@ +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; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private 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, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(FooterPolicy::KEY, $data['policyOverrides'])) { + return [FooterPolicy::KEY => $data['policyOverrides'][FooterPolicy::KEY]]; + } + + return []; + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) { + return; + } + + $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope(); + throw new LibresignException($this->l10n->t('Footer template override is blocked by %s.', [$blockedBy]), 422); + } + + private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicy.php b/lib/Service/Policy/Provider/Footer/FooterPolicy.php new file mode 100644 index 0000000000..386b97397f --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicy.php @@ -0,0 +1,74 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: FooterPolicyValue::encode(FooterPolicyValue::defaults()), + 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } + + private static function canManageTechnicalFooterSettings(PolicyContext $context): bool { + $capabilities = $context->getActorCapabilities(); + + return ($capabilities['canManageSystemPolicies'] ?? false) === true + || ($capabilities['canManageGroupPolicies'] ?? false) === true; + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php new file mode 100644 index 0000000000..214c0610c1 --- /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); + } + + $defaults['enabled'] = self::toBool($trimmedValue); + return $defaults; + } + + return $defaults; + } + + public static function encode(array $value): string { + return (string)json_encode(self::normalize($value), 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/IdentificationDocuments/IdentificationDocumentsPolicy.php b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php new file mode 100644 index 0000000000..736817ddf3 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php @@ -0,0 +1,64 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..803d18e92c --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/FilePolicy/IdentifyMethodsFilePolicyApplier.php @@ -0,0 +1,62 @@ +policyService->resolveForUser(IdentifyMethodsPolicy::KEY, $user, []); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $resolvedPolicy = $this->policyService->resolveForUserId(IdentifyMethodsPolicy::KEY, $file->getUserId(), []); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return false; + } + + private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); + } +} diff --git a/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php new file mode 100644 index 0000000000..83f5be69c9 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php @@ -0,0 +1,54 @@ +identifyMethodService; + return match ($this->normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: [], + allowedValues: static fn (): array => [], + normalizer: fn (mixed $rawValue): array => IdentifyMethodsPolicyValue::normalize($rawValue, $identifyMethodService), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php new file mode 100644 index 0000000000..468e117fbf --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php @@ -0,0 +1,365 @@ +>, can_create_account?: bool} + */ + public static function normalize(mixed $rawValue, ?IdentifyMethodService $identifyMethodService = null): array { + $preparedInput = self::prepareInput($rawValue); + // 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, + ]; + } + } + } + return [ + 'factors' => $defaultFactors, + ]; + } + $normalization = self::normalizeFactors( + $preparedInput['factors'], + $preparedInput['sharedMinimumTotalVerifiedFactors'], + $preparedInput['globalCanCreateAccount'], + ); + $normalized = $normalization['factors']; + $legacyGlobalCanCreateAccount = $normalization['globalCanCreateAccount']; + + if ($identifyMethodService !== null) { + $normalized = self::enrichFriendlyNames($normalized, $identifyMethodService->getFriendlyNamesMap()); + } + + $payload = [ + 'factors' => $normalized, + ]; + if ($legacyGlobalCanCreateAccount !== null) { + $payload['can_create_account'] = $legacyGlobalCanCreateAccount; + } + + 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)) { + if (array_is_list($rawValue)) { + $factors = $rawValue; + } else { + 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' => $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>, globalCanCreateAccount: ?bool} + */ + private static function normalizeFactors( + array $rawFactors, + ?int $sharedMinimumTotalVerifiedFactors, + ?bool $globalCanCreateAccount, + ): array { + $normalized = []; + foreach ($rawFactors as $entry) { + if (is_string($entry)) { + $normalizedEntry = self::normalizeLegacyStringEntry($entry, $sharedMinimumTotalVerifiedFactors); + if ($normalizedEntry !== null) { + $normalized[] = $normalizedEntry; + } + continue; + } + + if (!is_array($entry)) { + continue; + } + + $normalizedEntry = self::normalizeFactorEntry($entry, $sharedMinimumTotalVerifiedFactors, $globalCanCreateAccount); + if ($normalizedEntry === null) { + continue; + } + + $normalized[] = $normalizedEntry['entry']; + if ($globalCanCreateAccount === null && $normalizedEntry['globalCanCreateAccount'] !== null) { + $globalCanCreateAccount = $normalizedEntry['globalCanCreateAccount']; + } + } + + return [ + 'factors' => $normalized, + 'globalCanCreateAccount' => $globalCanCreateAccount, + ]; + } + + /** + * @return ?array + */ + private static function normalizeLegacyStringEntry(string $entry, ?int $sharedMinimumTotalVerifiedFactors): ?array { + $name = trim($entry); + if ($name === '') { + return null; + } + + $normalizedEntry = [ + 'name' => $name, + 'enabled' => true, + 'signatureMethods' => [], + ]; + + if ($sharedMinimumTotalVerifiedFactors !== null) { + $normalizedEntry['minimumTotalVerifiedFactors'] = $sharedMinimumTotalVerifiedFactors; + } + + return $normalizedEntry; + } + + /** + * @param array $entry + * @return array{entry: array, globalCanCreateAccount: ?bool}|null + */ + private static function normalizeFactorEntry( + array $entry, + ?int $sharedMinimumTotalVerifiedFactors, + ?bool $globalCanCreateAccount, + ): ?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']; + } + + $entryCanCreateAccount = null; + if ($globalCanCreateAccount === null && array_key_exists('can_create_account', $entry)) { + $entryCanCreateAccount = (bool)$entry['can_create_account']; + } + + $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 [ + 'entry' => $normalizedEntry, + 'globalCanCreateAccount' => $entryCanCreateAccount, + ]; + } + + /** + * @param array $entry + * @return array> + */ + private static function normalizeSignatureMethods(array $entry): array { + $signatureMethods = []; + if (isset($entry['signatureMethods']) && is_array($entry['signatureMethods'])) { + if (array_is_list($entry['signatureMethods'])) { + foreach ($entry['signatureMethods'] as $signatureMethodName) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + $signatureMethods[$signatureMethodName] = ['enabled' => false]; + } + } else { + foreach ($entry['signatureMethods'] as $signatureMethodName => $signatureMethodConfig) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + $normalizedSignatureMethod = self::normalizeSignatureMethodConfig($signatureMethodConfig); + if ($normalizedSignatureMethod !== null) { + $signatureMethods[$signatureMethodName] = $normalizedSignatureMethod; + } + } + } + } + + if ($signatureMethods === [] && isset($entry['availableSignatureMethods']) && is_array($entry['availableSignatureMethods'])) { + foreach ($entry['availableSignatureMethods'] as $signatureMethodName) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + $signatureMethods[$signatureMethodName] = ['enabled' => false]; + } + } + + 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 = []; + if (array_key_exists('enabled', $signatureMethodConfig)) { + $normalizedSignatureMethod['enabled'] = (bool)$signatureMethodConfig['enabled']; + } + + if (isset($signatureMethodConfig['label']) && is_string($signatureMethodConfig['label'])) { + $normalizedSignatureMethod['label'] = $signatureMethodConfig['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; + } + + /** + * @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))); + } + + if (array_is_list($normalizedPayload)) { + return array_values(array_filter($normalizedPayload, 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']; + } + + $factors = self::extractFactors($normalizedPayload); + foreach ($factors as $entry) { + if (array_key_exists('can_create_account', $entry)) { + return (bool)$entry['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/LegalInformationPolicy.php b/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php new file mode 100644 index 0000000000..1275d2a4e4 --- /dev/null +++ b/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php @@ -0,0 +1,47 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: '', + allowedValues: [], + normalizer: static fn (mixed $rawValue): string => (string)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Provider/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php new file mode 100644 index 0000000000..47fdfdb3ca --- /dev/null +++ b/lib/Service/Policy/Provider/PolicyProviders.php @@ -0,0 +1,68 @@ + */ + 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, + SigningModePolicy::KEY_WORKER_TYPE => SigningModePolicy::class, + SigningModePolicy::KEY_PARALLEL_WORKERS => SigningModePolicy::class, + WorkerConfigPolicy::KEY => WorkerConfigPolicy::class, + IdentificationDocumentsPolicy::KEY => IdentificationDocumentsPolicy::class, + IdentifyMethodsPolicy::KEY => IdentifyMethodsPolicy::class, + SignatureTextPolicy::KEY => SignatureTextPolicy::class, + TsaPolicy::KEY => TsaPolicy::class, + SignatureTextPolicy::KEY_TEMPLATE => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_TEMPLATE_FONT_SIZE => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_SIGNATURE_WIDTH => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_SIGNATURE_HEIGHT => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_SIGNATURE_FONT_SIZE => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_RENDER_MODE => SignatureTextPolicy::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..7e733c440f --- /dev/null +++ b/lib/Service/Policy/Provider/Reminder/ReminderPolicy.php @@ -0,0 +1,47 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..580f0d4298 --- /dev/null +++ b/lib/Service/Policy/Provider/Reminder/ReminderPolicyValue.php @@ -0,0 +1,86 @@ + 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']); + + 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..c2e285747b --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: RequestSignGroupsPolicyValue::encode(RequestSignGroupsPolicyValue::DEFAULT_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::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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php new file mode 100644 index 0000000000..d2ad5520e7 --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php @@ -0,0 +1,83 @@ +l10n->t(self::USER_SCOPE_NOT_SUPPORTED_MESSAGE)); + } + + public function normalizeManagedValue(string $policyKey, mixed $value, bool $allowNullReset = false): 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')); + } + + $groupIds = RequestSignGroupsPolicyValue::decode($value); + if ($groupIds === []) { + throw new \InvalidArgumentException($this->l10n->t('At least one authorized group is required')); + } + + $allowedGroupIds = $this->resolveAllowedGroupIdsForActor($user); + $unknownGroupIds = array_values(array_diff($groupIds, $allowedGroupIds)); + if ($unknownGroupIds !== []) { + throw new \InvalidArgumentException($this->l10n->t('One or more selected groups are not allowed for your administration scope')); + } + + return RequestSignGroupsPolicyValue::encode($groupIds); + } + + /** @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 array_values(array_map( + static fn (IGroup $group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($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..8698853df9 --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php @@ -0,0 +1,65 @@ + */ + 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/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php new file mode 100644 index 0000000000..87f8efebb6 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php @@ -0,0 +1,117 @@ +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; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private 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, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(SignatureFlowPolicy::KEY, $data['policyOverrides'])) { + return [SignatureFlowPolicy::KEY => $data['policyOverrides'][SignatureFlowPolicy::KEY]]; + } + + return []; + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) { + return; + } + + $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope(); + throw new LibresignException($this->l10n->t('Signature flow override is blocked by %s.', [$blockedBy]), 422); + } + + private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); + } +} diff --git a/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php new file mode 100644 index 0000000000..9976c158b9 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + resolutionMode: PolicySpec::RESOLUTION_MODE_VALUE_CHOICE, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Provider/SignatureBackground/SignatureBackgroundPolicy.php b/lib/Service/Policy/Provider/SignatureBackground/SignatureBackgroundPolicy.php new file mode 100644 index 0000000000..b900cc4054 --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureBackground/SignatureBackgroundPolicy.php @@ -0,0 +1,64 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: 'default', + allowedValues: [ + 'default', + 'custom', + 'deleted', + ], + normalizer: static fn (mixed $rawValue): string => self::normalizeBackgroundType($rawValue), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private static 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'; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..00cddad1fb --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureHashAlgorithm/SignatureHashAlgorithmPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: 'SHA256', + allowedValues: self::ALGORITHMS, + normalizer: function (mixed $rawValue): string { + $candidate = strtoupper(trim((string)$rawValue)); + return in_array($candidate, self::ALGORITHMS, true) ? $candidate : 'SHA256'; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..c2eee4799a --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php @@ -0,0 +1,198 @@ +normalizePolicyKey($policyKey); + + return match ($normalizedKey) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: $this->encodeConsolidatedValue($this->defaultConsolidatedValue()), + allowedValues: [], + normalizer: fn (mixed $rawValue): string => $this->encodeConsolidatedValue( + $this->normalizeConsolidatedValue($rawValue), + ), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + self::KEY_TEMPLATE => new PolicySpec( + key: self::KEY_TEMPLATE, + defaultSystemValue: SignatureTextTemplate::translated($this->l10n, false), + allowedValues: [], + normalizer: fn (mixed $rawValue): string => (string)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_TEMPLATE, + ), + self::KEY_TEMPLATE_FONT_SIZE => new PolicySpec( + key: self::KEY_TEMPLATE_FONT_SIZE, + defaultSystemValue: SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_TEMPLATE_FONT_SIZE, + ), + self::KEY_SIGNATURE_WIDTH => new PolicySpec( + key: self::KEY_SIGNATURE_WIDTH, + defaultSystemValue: SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_SIGNATURE_WIDTH, + ), + self::KEY_SIGNATURE_HEIGHT => new PolicySpec( + key: self::KEY_SIGNATURE_HEIGHT, + defaultSystemValue: SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_SIGNATURE_HEIGHT, + ), + self::KEY_SIGNATURE_FONT_SIZE => new PolicySpec( + key: self::KEY_SIGNATURE_FONT_SIZE, + defaultSystemValue: SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_SIGNATURE_FONT_SIZE, + ), + self::KEY_RENDER_MODE => new PolicySpec( + key: self::KEY_RENDER_MODE, + defaultSystemValue: 'default', + allowedValues: ['default', 'graphic', 'text'], + normalizer: function (mixed $rawValue): string { + $value = (string)$rawValue; + $allowed = ['default', 'graphic', 'text']; + return in_array($value, $allowed, true) ? $value : 'default'; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_RENDER_MODE, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } + + /** + * @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'], 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..bce03d21cf --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicyValue.php @@ -0,0 +1,93 @@ + */ + public const DEFAULTS = [ + 'template' => '', + '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 + * @return array + */ + 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 $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); + } + + private static function normalizeRenderMode(mixed $value): string { + $mode = (string)($value ?? 'default'); + return match ($mode) { + 'default', 'graphic', 'text' => $mode, + default => 'default', + }; + } + + 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..4615318144 --- /dev/null +++ b/lib/Service/Policy/Provider/Tsa/TsaPolicy.php @@ -0,0 +1,54 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..83d71fef0d --- /dev/null +++ b/lib/Service/Policy/Provider/ValidationAccess/ValidationAccessPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => 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, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..49efd58d9c --- /dev/null +++ b/lib/Service/Policy/Provider/Worker/SigningModePolicy.php @@ -0,0 +1,81 @@ +normalizePolicyKey($policyKey)) { + self::KEY_SIGNING_MODE => 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, + ), + self::KEY_WORKER_TYPE => new PolicySpec( + key: self::KEY_WORKER_TYPE, + defaultSystemValue: 'local', + allowedValues: ['local', 'external'], + normalizer: static fn (mixed $rawValue): string => in_array((string)$rawValue, ['local', 'external'], true) + ? (string)$rawValue + : 'local', + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_WORKER_TYPE, + supportsUserPreference: false, + ), + self::KEY_PARALLEL_WORKERS => new PolicySpec( + key: self::KEY_PARALLEL_WORKERS, + defaultSystemValue: 4, + allowedValues: [], + normalizer: static function (mixed $rawValue): int { + $value = (int)$rawValue; + return max(self::MIN_PARALLEL_WORKERS, min(self::MAX_PARALLEL_WORKERS, $value)); + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_PARALLEL_WORKERS, + supportsUserPreference: false, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $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..bfb1a2bde8 --- /dev/null +++ b/lib/Service/Policy/Provider/Worker/WorkerConfigPolicy.php @@ -0,0 +1,95 @@ +encodeDefault(), + allowedValues: [], + normalizer: fn (mixed $rawValue): string => $this->encodeNormalized($rawValue), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + ); + } + + /** + * @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); + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/RequestSignAuthorizationService.php b/lib/Service/Policy/RequestSignAuthorizationService.php new file mode 100644 index 0000000000..e5eaed8d2e --- /dev/null +++ b/lib/Service/Policy/RequestSignAuthorizationService.php @@ -0,0 +1,37 @@ +policyService->resolveForUser(RequestSignGroupsPolicy::KEY, $user); + $authorizedGroups = RequestSignGroupsPolicyValue::decode($resolvedPolicy->getEffectiveValue()); + if ($authorizedGroups === []) { + return false; + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + return array_intersect($userGroups, $authorizedGroups) !== []; + } +} diff --git a/lib/Service/Policy/Runtime/DefaultPolicyResolver.php b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php new file mode 100644 index 0000000000..3c4c1c7e66 --- /dev/null +++ b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php @@ -0,0 +1,389 @@ +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; + + 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) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $userPolicy, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } + + $inheritedValue = $currentValue; + + if ($userPreference !== null) { + if ($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; + } + } + + $resolved + ->setEffectiveValue($currentValue) + ->setInheritedValue($inheritedValue) + ->setSourceScope($currentSourceScope) + ->setVisible($visible) + ->setEditableByCurrentActor($visible && $this->canManagePolicyAtCurrentScope($context)) + ->setCanSaveAsUserDefault($visible && $canOverrideBelow && $definition->supportsUserPreference()) + ->setCanUseAsRequestOverride($visible && $canOverrideBelow && $definition->supportsUserPreference()) + ->setBlockedBy($currentBlockedBy); + + return $resolved; + } + + /** + * @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) { + $currentValue = $definition->normalizeValue($layer->getValue()); + $definition->validateValue($currentValue, $context); + $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; + } + + private function canManagePolicyAtCurrentScope(PolicyContext $context): bool { + $actorCapabilities = $context->getActorCapabilities(); + + return ($actorCapabilities['canManageSystemPolicies'] ?? false) === true + || ($actorCapabilities['canManageGroupPolicies'] ?? false) === 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..5b7f5d7b7e --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyContextFactory.php @@ -0,0 +1,123 @@ + $requestOverrides */ + public function forCurrentUser(array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $user = $this->userSession->getUser(); + return $this->build($user?->getUID(), $user, $requestOverrides, $activeContext, $user); + } + + 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 { + return $this->build($user?->getUID(), $user, $requestOverrides, $activeContext, $this->userSession->getUser()); + } + + /** @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; + } + } + + return $this->build($userId, $user, $requestOverrides, $activeContext, $this->userSession->getUser()); + } + + /** @param array $requestOverrides */ + private function build(?string $userId, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null, ?IUser $currentActor = null): PolicyContext { + $validatedActiveContext = $this->validateActiveContext($activeContext, $currentActor); + + $context = (new PolicyContext()) + ->setRequestOverrides($requestOverrides) + ->setActiveContext($validatedActiveContext) + ->setActorCapabilities($this->resolveActorCapabilities($currentActor)); + + if ($userId !== null && $userId !== '') { + $context->setUserId($userId); + if ($user instanceof IUser) { + $context->setGroups($this->groupManager->getUserGroupIds($user)); + } + } + + return $context; + } + + /** @param array|null $activeContext + * @return array|null + */ + private function validateActiveContext(?array $activeContext, ?IUser $currentActor): ?array { + 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 || !in_array($groupId, $this->groupManager->getUserGroupIds($currentActor), true)) { + throw new LibresignException('You are not allowed to use this policy context.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return [ + 'type' => 'group', + 'id' => $groupId, + ]; + } + + /** @return array */ + private function resolveActorCapabilities(?IUser $currentActor): array { + if (!$currentActor instanceof IUser) { + return [ + 'canManageSystemPolicies' => false, + 'canManageGroupPolicies' => false, + ]; + } + + $userId = $currentActor->getUID(); + $canManageSystemPolicies = $this->groupManager->isAdmin($userId) === true; + + return [ + 'canManageSystemPolicies' => $canManageSystemPolicies, + 'canManageGroupPolicies' => $canManageSystemPolicies || $this->subAdmin->isSubAdmin($currentActor) === true, + ]; + } +} diff --git a/lib/Service/Policy/Runtime/PolicyRegistry.php b/lib/Service/Policy/Runtime/PolicyRegistry.php new file mode 100644 index 0000000000..3dff68ceed --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyRegistry.php @@ -0,0 +1,57 @@ + */ + private array $definitions = []; + + public function __construct( + private ContainerInterface $container, + ) { + } + + public function get(string|\BackedEnum $policyKey): IPolicyDefinition { + $policyKeyValue = $this->normalizePolicyKey($policyKey); + $definition = $this->definitions[$policyKeyValue] ?? null; + if ($definition instanceof IPolicyDefinition) { + return $definition; + } + + $providerClass = PolicyProviders::BY_KEY[$policyKeyValue] ?? null; + if (!is_string($providerClass) || $providerClass === '') { + throw new \InvalidArgumentException('Unknown policy key: ' . $policyKeyValue); + } + + $provider = $this->container->get($providerClass); + if (!$provider instanceof IPolicyDefinitionProvider) { + throw new \UnexpectedValueException('Invalid policy provider: ' . $providerClass); + } + + $definition = $provider->get($policyKeyValue); + if ($definition->key() !== $policyKeyValue) { + throw new \InvalidArgumentException('Policy provider returned mismatched key: ' . $definition->key()); + } + + return $this->definitions[$policyKeyValue] = $definition; + } + + 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..9afc63fe98 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -0,0 +1,948 @@ +registry->get($policyKey); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + $hasExplicitSystemValue = $this->appConfig->hasAppKey($definition->getAppConfigKey()); + $storedValue = $hasExplicitSystemValue + ? $this->readSystemValue($definition->getAppConfigKey(), $defaultValue) + : 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 = $permissionSet->getDecodedPolicyJson()[$policyKey] ?? null; + if (!is_array($policyConfig)) { + continue; + } + + $layers[] = (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'] : []); + } + + 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 = $policyJson[$policyKey] ?? null; + if (!is_array($policyConfig)) { + continue; + } + + $result[$policyKey][] = (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'] : []); + } + } + + 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 = $permissionSet->getDecodedPolicyJson()[$policyKey] ?? null; + if (!is_array($policyConfig)) { + return null; + } + + return $this->createGroupPolicyLayer($policyConfig); + } + + /** + * @return list + */ + #[\Override] + public function listGroupPoliciesByKey(string $policyKey): array { + $bindings = $this->bindingMapper->findByTargetType('group'); + 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 = $permissionSet->getDecodedPolicyJson()[$policyKey] ?? null; + if (!is_array($policyConfig) || !array_key_exists('defaultValue', $policyConfig) || $policyConfig['defaultValue'] === null) { + 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 = array_keys(PolicyProviders::BY_KEY); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = [ + 'groupCount' => 0, + 'userCount' => 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() ?? []; + foreach ($policyJson as $policyKey => $policyConfig) { + if (!isset($counts[$policyKey]) || !is_array($policyConfig)) { + continue; + } + + if (!array_key_exists('defaultValue', $policyConfig) || $policyConfig['defaultValue'] === null) { + continue; + } + + $counts[$policyKey]['groupCount']++; + } + } + } + + $userIds = array_values(array_unique(array_filter($userIds, static fn (string $userId): bool => $userId !== ''))); + if ($userIds === []) { + return $counts; + } + + $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(); + } + + return $counts; + } + + /** + * Count group/user rules for ALL known targets (no ID filter). Suitable for system admins. + * + * @return array + */ + public function loadAllRuleCounts(): array { + $policyKeys = array_keys(PolicyProviders::BY_KEY); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = ['groupCount' => 0, 'userCount' => 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() ?? []; + foreach ($policyJson as $policyKey => $policyConfig) { + if (!isset($counts[$policyKey]) || !is_array($policyConfig)) { + continue; + } + + if (!array_key_exists('defaultValue', $policyConfig) || $policyConfig['defaultValue'] === null) { + continue; + } + + $counts[$policyKey]['groupCount']++; + } + } + } + + $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(); + } + + return $counts; + } + + #[\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; + } + + if ($this->appConfig->hasAppKey($definition->getAppConfigKey())) { + $this->appConfig->deleteAppValue($definition->getAppConfigKey()); + } + if ($this->appConfig->hasAppKey($allowOverrideConfigKey)) { + $this->appConfig->deleteAppValue($allowOverrideConfigKey); + } + return; + } + + $this->writeSystemValue($definition->getAppConfigKey(), $normalizedValue); + $this->appConfig->setAppValueString($allowOverrideConfigKey, $allowChildOverride ? '1' : '0'); + } + + 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); + } + + 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; + } + } + + #[\Override] + public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride): 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(); + $policyJson[$policyKey] = [ + 'defaultValue' => $normalizedValue, + 'allowChildOverride' => $allowChildOverride, + 'visibleToChild' => true, + 'allowedValues' => $allowChildOverride ? [] : [$normalizedValue], + ]; + + $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 clearGroupPolicy(string $policyKey, string $groupId): void { + $binding = $this->findBindingByGroupId($groupId); + if (!$binding instanceof PermissionSetBinding) { + return; + } + + $permissionSet = $this->findPermissionSetByBinding($binding); + if (!$permissionSet instanceof PermissionSet) { + return; + } + + $policyJson = $permissionSet->getDecodedPolicyJson(); + 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 { + $activeContext = $context->getActiveContext(); + if (($activeContext['type'] ?? null) === 'group' && is_string($activeContext['id'] ?? null)) { + return [$activeContext['id']]; + } + + return $context->getGroups(); + } + + /** @param array $policyConfig */ + private function createGroupPolicyLayer(array $policyConfig): PolicyLayer { + return (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'] : []); + } + + 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/ReminderService.php b/lib/Service/ReminderService.php index 729e22d4b4..d32edcf384 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 78320822ec..f4ea362a06 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/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 3a37105c47..c34d72f259 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[] */ @@ -1339,6 +1373,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) => [ @@ -1357,7 +1392,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 { @@ -1366,6 +1406,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) { @@ -1457,7 +1524,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(); @@ -1465,9 +1533,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) { @@ -1566,7 +1637,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/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 2e4543c1e8..d0c3f3e651 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,18 @@ 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 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 +59,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 +111,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,10 +194,8 @@ 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 { @@ -217,7 +217,7 @@ 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) { $list['{{SignerIP}}'] = $this->l10n->t('IP address of the person who signed the document.'); $list['{{SignerUserAgent}}'] = $this->l10n->t('Browser and device information of the person who signed the document.'); @@ -444,62 +444,32 @@ 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 + */ + 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 +481,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 +495,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 +526,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 +538,75 @@ private function buildValidationUrl(string $uuid): string { 'uuid' => $uuid, ]); } + + /** + * @return array + */ + private function getSignatureStampPolicyConfig(): array { + $rawValue = $this->policyService->resolve(SignatureTextPolicyProvider::KEY)->getEffectiveValue(); + $normalized = SignatureTextPolicyValue::normalize($rawValue, $this->getDefaultSignatureStampConfig()); + + if ($this->hasConsolidatedStampPayload($rawValue)) { + return $normalized; + } + + $template = $this->policyService->resolve(SignatureTextPolicyProvider::KEY_TEMPLATE)->getEffectiveValue(); + $templateFontSize = $this->policyService->resolve(SignatureTextPolicyProvider::KEY_TEMPLATE_FONT_SIZE)->getEffectiveValue(); + $signatureFontSize = $this->policyService->resolve(SignatureTextPolicyProvider::KEY_SIGNATURE_FONT_SIZE)->getEffectiveValue(); + $signatureWidth = $this->policyService->resolve(SignatureTextPolicyProvider::KEY_SIGNATURE_WIDTH)->getEffectiveValue(); + $signatureHeight = $this->policyService->resolve(SignatureTextPolicyProvider::KEY_SIGNATURE_HEIGHT)->getEffectiveValue(); + $renderMode = $this->policyService->resolve(SignatureTextPolicyProvider::KEY_RENDER_MODE)->getEffectiveValue(); + + $normalized['template'] = is_string($template) + ? $template + : (string)($template ?? SignatureTextTemplate::translated($this->l10n, $this->isCollectMetadataEnabled())); + $normalized['template_font_size'] = max(0.1, (float)($templateFontSize ?? $this->getDefaultTemplateFontSize())); + $normalized['signature_font_size'] = max(0.1, (float)($signatureFontSize ?? SignatureTextPolicyValue::DEFAULTS['signature_font_size'])); + $normalized['signature_width'] = max(0.1, (float)($signatureWidth ?? SignatureTextPolicyValue::DEFAULTS['signature_width'])); + $normalized['signature_height'] = max(0.1, (float)($signatureHeight ?? SignatureTextPolicyValue::DEFAULTS['signature_height'])); + $normalized['render_mode'] = (string)($renderMode ?? SignatureTextPolicyValue::DEFAULTS['render_mode']); + + return $normalized; + } + + private function hasConsolidatedStampPayload(mixed $rawValue): bool { + if (is_array($rawValue)) { + return true; + } + + if (!is_string($rawValue) || trim($rawValue) === '') { + return false; + } + + $decoded = json_decode($rawValue, true); + return is_array($decoded); + } + + 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 15f671b210..408aa59322 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..c01c5664d9 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -9,95 +9,57 @@ 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)); + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $resolvedPolicies, + ]); $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 +78,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 +86,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 6168edf453..e985e3da9d 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -378,6 +378,105 @@ } } }, + "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" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "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 + } + } + }, + "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": [ @@ -425,24 +524,6 @@ } } }, - "ErrorStatusResponse": { - "type": "object", - "required": [ - "status", - "message" - ], - "properties": { - "status": { - "type": "string", - "enum": [ - "error" - ] - }, - "message": { - "type": "string" - } - } - }, "FailureStatusResponse": { "type": "object", "required": [ @@ -461,31 +542,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": [ @@ -497,13 +553,20 @@ } } }, + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, "IdentifyMethodSetting": { "type": "object", "required": [ "name", "friendly_name", "enabled", - "mandatory" + "requirement" ], "properties": { "name": { @@ -515,8 +578,13 @@ "enabled": { "type": "boolean" }, - "mandatory": { - "type": "boolean" + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 }, "signatureMethods": { "$ref": "#/components/schemas/SignatureMethods" @@ -581,38 +649,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": [ @@ -642,7 +678,18 @@ "type": "string" }, "value": { - "type": "string" + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, @@ -733,76 +780,131 @@ } } }, - "SignatureTemplateSettingsResponse": { + "SuccessStatusResponse": { "type": "object", "required": [ - "default_signature_text_template", - "signature_available_variables" + "status" ], "properties": { - "default_signature_text_template": { - "type": "string" - }, - "signature_available_variables": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + }, + "SystemPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/SystemPolicyState" } } }, - "SignatureTextSettingsResponse": { + "SystemPolicyState": { "type": "object", "required": [ - "template", - "parsed", - "templateFontSize", - "signatureFontSize", - "signatureWidth", - "signatureHeight", - "renderMode" + "policyKey", + "scope", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues" ], "properties": { - "template": { + "policyKey": { "type": "string" }, - "parsed": { - "type": "string" + "scope": { + "type": "string", + "enum": [ + "system", + "global" + ] }, - "templateFontSize": { - "type": "number", - "format": "double" + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true }, - "signatureFontSize": { - "type": "number", - "format": "double" + "allowChildOverride": { + "type": "boolean" }, - "signatureWidth": { - "type": "number", - "format": "double" + "visibleToChild": { + "type": "boolean" }, - "signatureHeight": { - "type": "number", - "format": "double" + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" }, - "renderMode": { - "type": "string" + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, + "UserPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/UserPolicyState" } } }, - "SuccessStatusResponse": { + "UserPolicyState": { "type": "object", "required": [ - "status" + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride" ], "properties": { - "status": { + "policyKey": { + "type": "string" + }, + "scope": { "type": "string", "enum": [ - "success" + "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" + } + ] } } }, @@ -1405,11 +1507,11 @@ } } }, - "/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", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { + "post": { + "operationId": "admin-signature-background-save", + "summary": "Add custom background image", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -1467,7 +1569,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/SuccessStatusResponse" + } + } + } + } + } + } + } + }, + "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/FailureStatusResponse" } } } @@ -1477,12 +1609,10 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { - "post": { - "operationId": "admin-signature-background-save", - "summary": "Add custom background image", + }, + "get": { + "operationId": "admin-signature-background-get", + "summary": "Get custom background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1521,70 +1651,21 @@ ], "responses": { "200": { - "description": "OK", + "description": "Image returned", "content": { - "application/json": { + "*/*": { "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" - } - } - } - } - } - } - } - }, - "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/FailureStatusResponse" - } - } - } - } + "type": "string", + "format": "binary" } } } } } }, - "get": { - "operationId": "admin-signature-background-get", - "summary": "Get custom background image", + "delete": { + "operationId": "admin-signature-background-delete", + "summary": "Delete background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1623,21 +1704,42 @@ ], "responses": { "200": { - "description": "Image returned", + "description": "Deleted with success", "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/SuccessStatusResponse" + } + } + } + } } } } } } - }, - "patch": { - "operationId": "admin-signature-background-reset", - "summary": "Reset the background image to be the default of LibreSign", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { + "post": { + "operationId": "admin-save-certificate-policy", + "summary": "Upload new certificate policy PDF for this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1676,7 +1778,7 @@ ], "responses": { "200": { - "description": "Image reseted to default", + "description": "OK", "content": { "application/json": { "schema": { @@ -1696,7 +1798,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/CertificatePolicyResponse" + } + } + } + } + } + } + } + }, + "422": { + "description": "Upload or 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" } } } @@ -1708,8 +1840,8 @@ } }, "delete": { - "operationId": "admin-signature-background-delete", - "summary": "Delete background image", + "operationId": "admin-delete-certificate-policy", + "summary": "Delete certificate policy of this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1748,7 +1880,7 @@ ], "responses": { "200": { - "description": "Deleted with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -1768,7 +1900,48 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + } + } + } + } + } + } + } + }, + "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/FailureStatusResponse" } } } @@ -1780,10 +1953,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { "post": { - "operationId": "admin-signature-text-save", - "summary": "Save signature text service", + "operationId": "admin-update-oid", + "summary": "Update OID", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1803,41 +1976,12 @@ "schema": { "type": "object", "required": [ - "template" + "oid" ], "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": { + "oid": { "type": "string", - "default": "GRAPHIC_AND_DESCRIPTION", - "description": "Signature render mode" + "description": "OID is a unique numeric identifier for certificate policies in digital certificates." } } } @@ -1890,7 +2034,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -1899,8 +2043,8 @@ } } }, - "400": { - "description": "Bad request", + "422": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -1920,7 +2064,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -1930,10 +2074,12 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": { "get": { - "operationId": "admin-signature-text-get", - "summary": "Get parsed signature text service", + "operationId": "admin-get-active-signings", + "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -1959,24 +2105,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", @@ -1990,7 +2118,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "List of active signings", "content": { "application/json": { "schema": { @@ -2010,7 +2138,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/ActiveSigningsResponse" } } } @@ -2019,8 +2147,8 @@ } } }, - "400": { - "description": "Bad request", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -2052,13 +2180,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { "get": { - "operationId": "admin-get-signature-settings", - "summary": "Get signature settings", + "operationId": "crl_api-list", + "summary": "List CRL entries with pagination and filters", "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl" ], "security": [ { @@ -2082,138 +2210,105 @@ } }, { - "name": "OCS-APIRequest", - "in": "header", - "description": "Required to be true for the API request to pass", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-based)", "schema": { - "type": "boolean", - "default": true + "type": "integer", + "format": "int64", + "nullable": 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" - } - } - } - } - } - } + }, + { + "name": "length", + "in": "query", + "description": "Number of items per page", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true } - } - } - } - }, - "/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": [] + "name": "status", + "in": "query", + "description": "Filter by status (issued, revoked, expired)", + "schema": { + "type": "string", + "nullable": true + } }, { - "basic_auth": [] - } - ], - "parameters": [ + "name": "engine", + "in": "query", + "description": "Filter by engine type", + "schema": { + "type": "string", + "nullable": true + } + }, { - "name": "apiVersion", - "in": "path", - "required": true, + "name": "instanceId", + "in": "query", + "description": "Filter by instance ID", "schema": { "type": "string", - "enum": [ - "v1" - ], - "default": "v1" + "nullable": true } }, { - "name": "width", + "name": "generation", "in": "query", - "description": "Image width,", - "required": true, + "description": "Filter by generation", "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true } }, { - "name": "height", + "name": "owner", "in": "query", - "description": "Image height", - "required": true, + "description": "Filter by owner", "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "nullable": true } }, { - "name": "text", + "name": "serialNumber", "in": "query", - "description": "Text to be added to image", - "required": true, + "description": "Filter by serial number (partial match)", "schema": { - "type": "string" + "type": "string", + "nullable": true } }, { - "name": "fontSize", + "name": "revokedBy", "in": "query", - "description": "Font size of text", - "required": true, + "description": "Filter by who revoked the certificate", "schema": { - "type": "number", - "format": "double" + "type": "string", + "nullable": true } }, { - "name": "isDarkTheme", + "name": "sortBy", "in": "query", - "description": "Color of text, white if is tark theme and black if not", - "required": true, + "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] + "type": "string", + "nullable": true } }, { - "name": "align", + "name": "sortOrder", "in": "query", - "description": "Align of text: left, center or right", - "required": true, + "description": "Sort order (ASC or DESC)", "schema": { - "type": "string" + "type": "string", + "nullable": true } }, { @@ -2229,28 +2324,7 @@ ], "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", + "description": "CRL entries retrieved successfully", "content": { "application/json": { "schema": { @@ -2270,7 +2344,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlListResponse" } } } @@ -2282,13 +2356,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { "post": { - "operationId": "admin-save-certificate-policy", - "summary": "Update certificate policy of this instance", + "operationId": "crl_api-revoke", + "summary": "Revoke a certificate by serial number", "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl" ], "security": [ { @@ -2298,6 +2372,36 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "serialNumber" + ], + "properties": { + "serialNumber": { + "type": "string", + "description": "Certificate serial number to revoke" + }, + "reasonCode": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Revocation reason code (0-10, see RFC 5280)" + }, + "reasonText": { + "type": "string", + "nullable": true, + "description": "Optional text describing the reason" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -2324,7 +2428,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Certificate revoked successfully", "content": { "application/json": { "schema": { @@ -2344,7 +2448,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificatePolicyResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -2353,8 +2457,8 @@ } } }, - "422": { - "description": "Not found", + "400": { + "description": "Invalid parameters", "content": { "application/json": { "schema": { @@ -2374,7 +2478,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "Certificate 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/CrlRevokeResponse" } } } @@ -2384,13 +2518,15 @@ } } } - }, - "delete": { - "operationId": "admin-delete-certificate-policy", - "summary": "Delete certificate policy of this instance", + } + }, + "/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": [ - "admin" + "policy" ], "security": [ { @@ -2413,6 +2549,16 @@ "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", @@ -2446,7 +2592,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/SystemPolicyResponse" } } } @@ -2456,15 +2602,13 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { + }, "post": { - "operationId": "admin-update-oid", - "summary": "Update OID", + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -2475,18 +2619,42 @@ } ], "requestBody": { - "required": true, + "required": false, "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." + "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." } } } @@ -2506,6 +2674,16 @@ "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", @@ -2539,7 +2717,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -2548,8 +2726,8 @@ } } }, - "422": { - "description": "Validation error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -2569,7 +2747,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -2581,13 +2759,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": { "get": { - "operationId": "admin-reminder-fetch", - "summary": "Get reminder settings", + "operationId": "policy-get-user-policy-for-user", + "summary": "Read an explicit user-level policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -2610,6 +2788,26 @@ "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", @@ -2643,7 +2841,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/UserPolicyResponse" } } } @@ -2651,88 +2849,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", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -2752,7 +2871,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -2762,15 +2881,13 @@ } } } - } - }, - "/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", + }, + "put": { + "operationId": "policy-set-user-policy-for-user", + "summary": "Save an explicit user policy for a target user (admin scope)", + "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -2787,30 +2904,36 @@ "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", + "value": { "nullable": true, - "description": "Username for basic authentication" + "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" + } + } + ] }, - "tsa_password": { - "type": "string", - "nullable": true, - "description": "Password for basic authentication (stored as sensitive data)" + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." } } } @@ -2830,6 +2953,26 @@ "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", @@ -2863,7 +3006,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -2873,7 +3016,37 @@ } }, "400": { - "description": "Validation error", + "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": { @@ -2893,7 +3066,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -2905,11 +3078,11 @@ } }, "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", + "operationId": "policy-clear-user-policy-for-user", + "summary": "Clear an explicit user policy for a target user (admin scope)", + "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -2932,6 +3105,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", @@ -2965,7 +3158,67 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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" } } } @@ -2977,13 +3230,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/by-policy/user/{policyKey}": { "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": "policy-list-user-policies", + "summary": "List all explicit user-level policy values for a policy key", + "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -3006,6 +3259,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", @@ -3039,1162 +3302,18 @@ "$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", - "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/docmdp/config": { - "post": { - "operationId": "admin-set-doc-mdp-config", - "summary": "Configure DocMDP signature restrictions", - "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 enable DocMDP restrictions" - }, - "defaultLevel": { - "type": "integer", - "format": "int64", - "default": 2, - "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" - } - } - } - } - } - }, - "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 DocMDP level 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", - "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/active-signings": { - "get": { - "operationId": "admin-get-active-signings", - "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", - "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": "List of active signings", - "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/ActiveSigningsResponse" - } - } - } - } - } - } - } - }, - "500": { - "description": "", - "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}/crl/list": { - "get": { - "operationId": "crl_api-list", - "summary": "List CRL entries with pagination and filters", - "description": "This endpoint requires admin access", - "tags": [ - "crl" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-based)", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "length", - "in": "query", - "description": "Number of items per page", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "status", - "in": "query", - "description": "Filter by status (issued, revoked, expired)", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "engine", - "in": "query", - "description": "Filter by engine type", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "instanceId", - "in": "query", - "description": "Filter by instance ID", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "generation", - "in": "query", - "description": "Filter by generation", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "owner", - "in": "query", - "description": "Filter by owner", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "serialNumber", - "in": "query", - "description": "Filter by serial number (partial match)", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "revokedBy", - "in": "query", - "description": "Filter by who revoked the certificate", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "sortBy", - "in": "query", - "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", - "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": "CRL entries retrieved 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/CrlListResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { - "post": { - "operationId": "crl_api-revoke", - "summary": "Revoke a certificate by serial number", - "description": "This endpoint requires admin access", - "tags": [ - "crl" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "serialNumber" - ], - "properties": { - "serialNumber": { - "type": "string", - "description": "Certificate serial number to revoke" - }, - "reasonCode": { - "type": "integer", - "format": "int64", - "nullable": true, - "description": "Revocation reason code (0-10, see RFC 5280)" - }, - "reasonText": { - "type": "string", - "nullable": true, - "description": "Optional text describing the reason" - } - } - } - } - } - }, - "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": "Certificate revoked 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/CrlRevokeResponse" - } - } - } - } - } - } - } - }, - "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/CrlRevokeResponse" - } - } - } - } - } - } - } - }, - "404": { - "description": "Certificate 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/CrlRevokeResponse" + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + } } } } diff --git a/openapi-full.json b/openapi-full.json index e8f9661a27..d5a67c36d9 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,20 +227,6 @@ "PhpNative" ] }, - "AdminSigningMode": { - "type": "string", - "enum": [ - "sync", - "async" - ] - }, - "AdminWorkerType": { - "type": "string", - "enum": [ - "local", - "external" - ] - }, "Capabilities": { "type": "object", "required": [ @@ -956,7 +961,7 @@ "DynamicMetadataRecord": { "type": "object", "additionalProperties": { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "object" } }, "DynamicMetadataScalar": { @@ -979,23 +984,117 @@ ] }, "DynamicMetadataValue": { - "anyOf": [ - { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "object" + }, + "EffectivePoliciesResponse": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + } + }, + "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" + ], + "properties": { + "policyKey": { + "type": "string" }, - { + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "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 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "integer", + "format": "int64" }, { - "type": "array", - "items": { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" } } ] @@ -1498,8 +1597,10 @@ "required": [ "template", "isDefault", + "template_variables", "preview_width", - "preview_height" + "preview_height", + "preview_zoom" ], "properties": { "template": { @@ -1508,6 +1609,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" @@ -1515,9 +1636,91 @@ "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" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "group" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "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": [ @@ -1648,6 +1851,7 @@ "sms", "telegram", "whatsapp", + "whatsappbusiness", "xmpp" ] }, @@ -1679,7 +1883,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1691,26 +1895,32 @@ "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" + ] + }, "IdentifyMethodSetting": { "type": "object", "required": [ "name", "friendly_name", "enabled", - "mandatory" + "requirement" ], "properties": { "name": { @@ -1722,8 +1932,13 @@ "enabled": { "type": "boolean" }, - "mandatory": { - "type": "boolean" + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 }, "signatureMethods": { "$ref": "#/components/schemas/SignatureMethods" @@ -1805,7 +2020,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1814,10 +2029,8 @@ "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } } @@ -1946,6 +2159,55 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotIdentifyMethodsEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdentifyMethodSetting" + } + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -2203,7 +2465,18 @@ "type": "string" }, "value": { - "type": "string" + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, @@ -2371,77 +2644,20 @@ } } }, - "SignatureTemplateSettingsResponse": { + "SignerCertificateInfo": { "type": "object", - "required": [ - "default_signature_text_template", - "signature_available_variables" - ], "properties": { - "default_signature_text_template": { + "serialNumber": { "type": "string" }, - "signature_available_variables": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "SignatureTextSettingsResponse": { - "type": "object", - "required": [ - "template", - "parsed", - "templateFontSize", - "signatureFontSize", - "signatureWidth", - "signatureHeight", - "renderMode" - ], - "properties": { - "template": { + "serialNumberHex": { "type": "string" }, - "parsed": { + "hash": { "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": { - "serialNumber": { - "type": "string" - }, - "serialNumberHex": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "subject": { - "$ref": "#/components/schemas/DynamicMetadataValue" + "subject": { + "$ref": "#/components/schemas/DynamicMetadataValue" } } }, @@ -2648,6 +2864,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": [ @@ -2725,6 +3012,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": [ @@ -2762,6 +3101,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2770,6 +3112,23 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "identify_methods": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodsEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -4236,119 +4595,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", @@ -6484,15 +6730,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": [] }, @@ -6500,28 +6746,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", @@ -6548,7 +6772,7 @@ ], "responses": { "200": { - "description": "Certificate saved with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -6567,7 +6791,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/FooterTemplateResponse" + } } } } @@ -6575,8 +6801,8 @@ } } }, - "401": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6596,7 +6822,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsUploadErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6607,14 +6833,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": [] }, @@ -6622,6 +6848,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", @@ -6635,45 +6890,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", @@ -6687,7 +6903,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": { @@ -6707,7 +6934,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsListResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6716,8 +6943,8 @@ } } }, - "404": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6737,7 +6964,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6749,15 +6976,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": [] }, @@ -6765,6 +6991,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", @@ -6778,25 +7038,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", @@ -6810,7 +7051,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": { @@ -6829,7 +7081,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } } } } @@ -6837,8 +7091,8 @@ } } }, - "401": { - "description": "Failure to delete file from account", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6858,7 +7112,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessagesResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6870,14 +7124,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": [] }, @@ -6885,6 +7140,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", @@ -6899,106 +7176,47 @@ } }, { - "name": "userId", - "in": "query", - "description": "User ID to filter by", + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, "schema": { - "type": "string", - "nullable": true + "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": {} + } + } + } + } + } } }, - { - "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", + "401": { + "description": "No file provided or other problem with provided file", "content": { "application/json": { "schema": { @@ -7018,7 +7236,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/IdDocsUploadErrorResponse" } } } @@ -7028,17 +7246,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": [] }, @@ -7060,41 +7276,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 } }, { @@ -7130,7 +7347,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" } } } @@ -7142,14 +7389,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": [] }, @@ -7157,42 +7405,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", @@ -7206,6 +7418,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", @@ -7219,7 +7450,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "File deleted with success", "content": { "application/json": { "schema": { @@ -7238,9 +7469,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "$ref": "#/components/schemas/MessageResponse" - } + "data": {} } } } @@ -7249,7 +7478,7 @@ } }, "401": { - "description": "Unauthorized", + "description": "Failure to delete file from account", "content": { "application/json": { "schema": { @@ -7269,7 +7498,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$ref": "#/components/schemas/MessagesResponse" } } } @@ -7281,12 +7510,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": [ { @@ -7296,32 +7525,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", @@ -7335,6 +7538,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", @@ -7368,7 +7628,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/IdDocsApprovalListResponse" } } } @@ -7377,8 +7637,8 @@ } } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Account not found", "content": { "application/json": { "schema": { @@ -7398,7 +7658,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -7410,12 +7670,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 sign the document. The return of this endpoint is related with Administration Settiongs > LibreSign > Identify method.", "tags": [ - "notify" + "identify" ], "security": [ { @@ -7439,41 +7700,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 } }, { @@ -7489,7 +7750,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Certificate saved with success", "content": { "application/json": { "schema": { @@ -7509,7 +7770,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/IdentifyAccountsResponse" } } } @@ -7521,13 +7782,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": [ { @@ -7538,59 +7798,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" } } } @@ -7643,7 +7879,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -7652,7 +7888,7 @@ } } }, - "422": { + "401": { "description": "Unauthorized", "content": { "application/json": { @@ -7673,14 +7909,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/DangerMessagesResponse" } } } @@ -7690,13 +7919,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": [ { @@ -7707,67 +7937,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." + "fileId": { + "type": "integer", + "format": "int64", + "description": "The identifier value of LibreSign file" }, - "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": { + "signRequestId": { "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" - }, - "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" - } + "description": "The sign request id" } } } @@ -7820,7 +8008,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -7829,7 +8017,7 @@ } } }, - "422": { + "401": { "description": "Unauthorized", "content": { "application/json": { @@ -7850,14 +8038,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/DangerMessagesResponse" } } } @@ -7869,13 +8050,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": [ { @@ -7899,9 +8079,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", @@ -7909,9 +8098,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", @@ -7951,7 +8149,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "type": "object" } } } @@ -7959,39 +8157,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": { @@ -8011,7 +8222,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/EffectivePoliciesResponse" } } } @@ -8023,13 +8234,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": [ { @@ -8053,13 +8263,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_]+$" } }, { @@ -8095,37 +8315,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" } } } @@ -8134,8 +8324,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8155,7 +8345,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8166,14 +8356,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": [] }, @@ -8182,41 +8371,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." } } } @@ -8237,13 +8427,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_]+$" } }, { @@ -8279,7 +8479,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -8288,8 +8488,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8309,7 +8509,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" } } } @@ -8319,17 +8549,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": [] }, @@ -8337,48 +8564,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", @@ -8393,12 +8578,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_]+$" } }, { @@ -8434,7 +8630,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -8443,8 +8639,8 @@ } } }, - "422": { - "description": "Error", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8464,7 +8660,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8476,15 +8672,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": [] }, @@ -8506,20 +8701,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_]+$" } }, { @@ -8555,7 +8743,18 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupPolicyState" + } + } + } } } } @@ -8567,15 +8766,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/{policyKey}": { + "put": { + "operationId": "policy-set-user-preference", + "summary": "Save a user policy preference", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8590,24 +8788,31 @@ "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", + "value": { "nullable": true, - "description": "Identify value, i.e. the signer email, account or phone number" + "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" + } + } + ] } } } @@ -8628,12 +8833,13 @@ } }, { - "name": "uuid", + "name": "policyKey", "in": "path", - "description": "UUID of LibreSign file", + "description": "Policy identifier to persist for the current user.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8669,7 +8875,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8678,8 +8884,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8699,7 +8905,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8709,17 +8915,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", + }, + "delete": { + "operationId": "policy-clear-user-preference", + "summary": "Clear a user policy preference", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8727,37 +8930,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", @@ -8772,13 +8944,13 @@ } }, { - "name": "fileId", + "name": "policyKey", "in": "path", - "description": "Id of LibreSign file", + "description": "Policy identifier to clear for the current user.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8814,7 +8986,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8823,8 +8995,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -8844,7 +9016,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8856,15 +9028,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { "post": { - "operationId": "signature_elements-create-signature-element", - "summary": "Create signature element", + "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": [] }, @@ -8873,18 +9045,59 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "elements" - ], "properties": { - "elements": { + "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." + }, + "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", - "description": "Element object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", "additionalProperties": { "type": "object" } @@ -8940,7 +9153,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -8950,7 +9163,7 @@ } }, "422": { - "description": "Invalid data", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -8970,7 +9183,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -8981,14 +9201,14 @@ } } }, - "get": { - "operationId": "signature_elements-get-signature-elements", - "summary": "Get signature elements", + "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": [] }, @@ -8996,6 +9216,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", @@ -9042,7 +9333,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -9051,8 +9342,8 @@ } } }, - "404": { - "description": "Invalid data", + "422": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9072,7 +9363,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -9084,15 +9382,15 @@ } } }, - "/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}/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": [ - "signature_elements" + "signing" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9114,9 +9412,19 @@ } }, { - "name": "nodeId", + "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "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", @@ -9138,16 +9446,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/MessageResponse" + } + } + } + } } } } }, - "404": { - "description": "Invalid data", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9167,7 +9494,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/MessageResponse" + } + } + } + } + } + } + } + }, + "422": { + "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/ActionErrorResponse" } } } @@ -9179,12 +9536,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { - "get": { - "operationId": "signature_elements-get-signature-element", - "summary": "Get signature element of signer", + "/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": [ - "signature_elements" + "signing" ], "security": [ { @@ -9208,9 +9566,9 @@ } }, { - "name": "nodeId", + "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "LibreSign file ID", "required": true, "schema": { "type": "integer", @@ -9250,7 +9608,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElement" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9259,8 +9617,8 @@ } } }, - "404": { - "description": "Invalid data", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9288,14 +9646,44 @@ } } } + }, + "422": { + "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/ActionErrorResponse" + } + } + } + } + } + } + } } } }, - "patch": { - "operationId": "signature_elements-patch-signature-element", - "summary": "Update signature element", + "post": { + "operationId": "sign_file-sign-by-file-id", + "summary": "Sign a file using file Id", "tags": [ - "signature_elements" + "signing" ], "security": [ {}, @@ -9307,24 +9695,41 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "method" + ], "properties": { - "type": { + "method": { "type": "string", - "default": "", - "description": "The type of signature element" + "description": "Signature method" }, - "file": { + "elements": { "type": "object", "default": {}, - "description": "Element object", + "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" } } } @@ -9345,9 +9750,9 @@ } }, { - "name": "nodeId", + "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "Id of LibreSign file", "required": true, "schema": { "type": "integer", @@ -9387,7 +9792,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -9417,7 +9822,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -9427,12 +9832,14 @@ } } } - }, - "delete": { - "operationId": "signature_elements-delete-signature-element", - "summary": "Delete signature element", + } + }, + "/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", "tags": [ - "signature_elements" + "signing" ], "security": [ {}, @@ -9443,6 +9850,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", @@ -9457,13 +9906,12 @@ } }, { - "name": "nodeId", + "name": "uuid", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "UUID of LibreSign file", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string" } }, { @@ -9499,7 +9947,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -9508,8 +9956,8 @@ } } }, - "404": { - "description": "Not found", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9529,7 +9977,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -9541,15 +9989,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": { "post": { - "operationId": "admin-generate-certificate-cfssl", - "summary": "Generate certificate using CFSSL engine", - "description": "This endpoint requires admin access", + "operationId": "sign_file-sign-renew", + "summary": "Renew the signature method", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -9557,68 +10005,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", @@ -9632,6 +10018,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", @@ -9665,7 +10068,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9673,107 +10076,51 @@ } } } - }, - "401": { - "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}/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": [] + } + } + } + }, + "/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": [ + "signing" + ], + "security": [ + {}, + { + "bearer_auth": [] }, { "basic_auth": [] } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "rootCert" - ], "properties": { - "rootCert": { - "type": "object", - "description": "fields of root certificate", - "required": [ - "commonName", - "names" + "identifyMethod": { + "type": "string", + "nullable": true, + "enum": [ + "account", + "email" ], - "properties": { - "commonName": { - "type": "string" - }, - "names": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - } - } - } + "description": "Identify signer method" }, - "configPath": { + "signMethod": { "type": "string", - "default": "", - "description": "Path of config files of CFSSL" + "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" } } } @@ -9793,6 +10140,15 @@ "default": "v1" } }, + { + "name": "uuid", + "in": "path", + "description": "UUID of LibreSign file", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9826,7 +10182,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9835,8 +10191,8 @@ } } }, - "401": { - "description": "Account not found", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9868,15 +10224,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": { "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", + "operationId": "sign_file-request-code-by-file-id", + "summary": "Get code to sign the document using FileID", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -9885,18 +10241,30 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "engine" - ], "properties": { - "engine": { + "identifyMethod": { "type": "string", - "description": "The certificate engine to use (openssl, cfssl, or none)" + "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" } } } @@ -9916,6 +10284,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "Id of LibreSign file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9949,7 +10327,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificateEngineConfigResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9958,8 +10336,8 @@ } } }, - "400": { - "description": "Invalid engine", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9991,15 +10369,15 @@ } } }, - "/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", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { + "post": { + "operationId": "signature_elements-create-signature-element", + "summary": "Create signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10007,6 +10385,28 @@ "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", @@ -10053,7 +10453,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificateDataGenerated" + "$ref": "#/components/schemas/UserElementsMessageResponse" + } + } + } + } + } + } + } + }, + "422": { + "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" } } } @@ -10063,17 +10493,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", + "operationId": "signature_elements-get-signature-elements", + "summary": "Get signature elements", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10127,7 +10555,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ConfigureChecksResponse" + "$ref": "#/components/schemas/UserElementsResponse" } } } @@ -10135,19 +10563,49 @@ } } } - } - } - } - }, - "/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": [ + }, + "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" + } + } + } + } + } + } + } + } + } + } + }, + "/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", + "tags": [ + "signature_elements" + ], + "security": [ + {}, { "bearer_auth": [] }, @@ -10168,6 +10626,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", @@ -10182,6 +10650,17 @@ "responses": { "200": { "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10213,13 +10692,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { - "post": { - "operationId": "admin-signature-background-save", - "summary": "Add custom background image", - "description": "This endpoint requires admin access", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { + "get": { + "operationId": "signature_elements-get-signature-element", + "summary": "Get signature element of signer", "tags": [ - "admin" + "signature_elements" ], "security": [ { @@ -10242,6 +10720,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", @@ -10275,7 +10763,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/UserElement" } } } @@ -10284,8 +10772,8 @@ } } }, - "422": { - "description": "Error", + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10305,7 +10793,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10316,14 +10804,14 @@ } } }, - "get": { - "operationId": "admin-signature-background-get", - "summary": "Get custom background image", - "description": "This endpoint requires admin access", + "patch": { + "operationId": "signature_elements-patch-signature-element", + "summary": "Update signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10331,6 +10819,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", @@ -10345,56 +10858,13 @@ } }, { - "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" - } - } - } - } - } - }, - "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", + "name": "nodeId", "in": "path", + "description": "Node id of a Nextcloud file", "required": true, "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" + "type": "integer", + "format": "int64" } }, { @@ -10410,7 +10880,7 @@ ], "responses": { "200": { - "description": "Image reseted to default", + "description": "OK", "content": { "application/json": { "schema": { @@ -10430,7 +10900,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/UserElementsMessageResponse" + } + } + } + } + } + } + } + }, + "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" } } } @@ -10442,13 +10942,13 @@ } }, "delete": { - "operationId": "admin-signature-background-delete", - "summary": "Delete background image", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-delete-signature-element", + "summary": "Delete signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10469,6 +10969,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", @@ -10482,7 +10992,7 @@ ], "responses": { "200": { - "description": "Deleted with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -10502,7 +11012,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10510,68 +11020,94 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { - "post": { - "operationId": "admin-signature-text-save", - "summary": "Save signature text service", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] }, - { - "basic_auth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "template" - ], + "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" + } + } + } + } + } + } + } + } + } + } + }, + "/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", - "description": "Template to signature text" + "default": "", + "description": "Signature text template (Twig syntax)" }, "templateFontSize": { "type": "number", "format": "double", - "default": 10, - "description": "Font size used when print the parsed text of this template at PDF file" + "description": "Font size for template text in pt" }, "signatureFontSize": { "type": "number", "format": "double", - "default": 20, - "description": "Font size used when the signature mode is SIGNAME_AND_DESCRIPTION" + "description": "Font size for signer name in pt" }, "signatureWidth": { "type": "number", "format": "double", - "default": 350, - "description": "Signature box width, minimum 1" + "description": "Stamp width in mm" }, "signatureHeight": { "type": "number", "format": "double", - "default": 100, - "description": "Signature box height, minimum 1" + "description": "Stamp height in mm" }, "renderMode": { "type": "string", - "default": "GRAPHIC_AND_DESCRIPTION", - "description": "Signature render mode" + "description": "Render mode: default, text, graphic, description_only" + }, + "backgroundType": { + "type": "string", + "description": "Background: default, custom, deleted" } } } @@ -10604,7 +11140,18 @@ ], "responses": { "200": { - "description": "OK", + "description": "Preview PDF", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -10624,7 +11171,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10633,8 +11180,8 @@ } } }, - "400": { - "description": "Bad request", + "422": { + "description": "Rendering error", "content": { "application/json": { "schema": { @@ -10664,10 +11211,12 @@ } } } - }, - "get": { - "operationId": "admin-signature-text-get", - "summary": "Get parsed signature text service", + } + }, + "/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" @@ -10680,6 +11229,68 @@ "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", @@ -10693,24 +11304,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", @@ -10744,7 +11337,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/EngineHandlerResponse" } } } @@ -10753,8 +11346,8 @@ } } }, - "400": { - "description": "Bad request", + "401": { + "description": "Account not found", "content": { "application/json": { "schema": { @@ -10774,7 +11367,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10786,10 +11379,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { - "get": { - "operationId": "admin-get-signature-settings", - "summary": "Get signature settings", + "/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" @@ -10802,6 +11395,63 @@ "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", @@ -10848,7 +11498,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTemplateSettingsResponse" + "$ref": "#/components/schemas/EngineHandlerResponse" + } + } + } + } + } + } + } + }, + "401": { + "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" } } } @@ -10860,12 +11540,12 @@ } } }, - "/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": [ + "/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" ], "security": [ @@ -10876,6 +11556,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", @@ -10889,67 +11588,6 @@ "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", @@ -10964,27 +11602,36 @@ "responses": { "200": { "description": "OK", - "headers": { - "Content-Disposition": { - "schema": { - "type": "string", - "enum": [ - "inline; filename=\"signer-name.png\"" - ] - } - } - }, "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/CertificateEngineConfigResponse" + } + } + } + } } } } }, "400": { - "description": "Bad request", + "description": "Invalid engine", "content": { "application/json": { "schema": { @@ -11004,7 +11651,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11016,11 +11663,11 @@ } } }, - "/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", + "/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" ], @@ -11078,37 +11725,7 @@ "$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" + "$ref": "#/components/schemas/CertificateDataGenerated" } } } @@ -11118,11 +11735,13 @@ } } } - }, - "delete": { - "operationId": "admin-delete-certificate-policy", - "summary": "Delete certificate policy of this instance", - "description": "This endpoint requires admin access", + } + }, + "/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" ], @@ -11180,7 +11799,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/ConfigureChecksResponse" } } } @@ -11192,10 +11811,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { "post": { - "operationId": "admin-update-oid", - "summary": "Update OID", + "operationId": "admin-signature-background-save", + "summary": "Add custom background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11208,25 +11827,6 @@ "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", @@ -11283,7 +11883,7 @@ } }, "422": { - "description": "Validation error", + "description": "Error", "content": { "application/json": { "schema": { @@ -11313,12 +11913,10 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { + }, "get": { - "operationId": "admin-reminder-fetch", - "summary": "Get reminder settings", + "operationId": "admin-signature-background-get", + "summary": "Get custom background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11357,40 +11955,21 @@ ], "responses": { "200": { - "description": "OK", + "description": "Image returned", "content": { - "application/json": { + "*/*": { "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "$ref": "#/components/schemas/ReminderSettings" - } - } - } - } + "type": "string", + "format": "binary" } } } } } }, - "post": { - "operationId": "admin-reminder-save", - "summary": "Save reminder", + "delete": { + "operationId": "admin-signature-background-delete", + "summary": "Delete background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11403,43 +11982,6 @@ "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", @@ -11466,7 +12008,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Deleted with success", "content": { "application/json": { "schema": { @@ -11486,7 +12028,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -11498,12 +12040,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { "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": [ + "operationId": "admin-save-certificate-policy", + "summary": "Upload new certificate policy PDF for this instance", + "description": "This endpoint requires admin access", + "tags": [ "admin" ], "security": [ @@ -11514,43 +12056,6 @@ "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", @@ -11597,7 +12102,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/CertificatePolicyResponse" } } } @@ -11606,8 +12111,8 @@ } } }, - "400": { - "description": "Validation error", + "422": { + "description": "Upload or validation error", "content": { "application/json": { "schema": { @@ -11627,7 +12132,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorStatusResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -11639,9 +12144,9 @@ } }, "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", + "operationId": "admin-delete-certificate-policy", + "summary": "Delete certificate policy of this instance", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -11699,7 +12204,18 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } } } } @@ -11707,53 +12223,9 @@ } } } - } - } - } - }, - "/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", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -11773,7 +12245,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FooterTemplateResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -11783,11 +12255,13 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { "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", + "operationId": "admin-update-oid", + "summary": "Update OID", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -11800,28 +12274,18 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "oid" + ], "properties": { - "template": { + "oid": { "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)" + "description": "OID is a unique numeric identifier for certificate policies in digital certificates." } } } @@ -11856,16 +12320,35 @@ "200": { "description": "OK", "content": { - "application/pdf": { + "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/SuccessStatusResponse" + } + } + } + } } } } }, - "400": { - "description": "Bad request", + "422": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -11885,7 +12368,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -11897,11 +12380,11 @@ } } }, - "/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", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": { + "get": { + "operationId": "admin-get-active-signings", + "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -11913,30 +12396,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", @@ -11963,37 +12422,7 @@ ], "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", + "description": "List of active signings", "content": { "application/json": { "schema": { @@ -12013,7 +12442,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ActiveSigningsResponse" } } } @@ -12023,7 +12452,7 @@ } }, "500": { - "description": "Internal server error", + "description": "", "content": { "application/json": { "schema": { @@ -12055,13 +12484,13 @@ } } }, - "/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", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { + "get": { + "operationId": "crl_api-list", + "summary": "List CRL entries with pagination and filters", "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl" ], "security": [ { @@ -12071,26 +12500,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", @@ -12105,49 +12514,121 @@ } }, { - "name": "OCS-APIRequest", - "in": "header", - "description": "Required to be true for the API request to pass", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-based)", "schema": { - "type": "boolean", - "default": true + "type": "integer", + "format": "int64", + "nullable": 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" - } - } - } - } - } - } + }, + { + "name": "length", + "in": "query", + "description": "Number of items per page", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true } }, - "500": { - "description": "Internal server error", + { + "name": "status", + "in": "query", + "description": "Filter by status (issued, revoked, expired)", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "engine", + "in": "query", + "description": "Filter by engine type", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "instanceId", + "in": "query", + "description": "Filter by instance ID", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "generation", + "in": "query", + "description": "Filter by generation", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "owner", + "in": "query", + "description": "Filter by owner", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "serialNumber", + "in": "query", + "description": "Filter by serial number (partial match)", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "revokedBy", + "in": "query", + "description": "Filter by who revoked the certificate", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", + "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": "CRL entries retrieved successfully", "content": { "application/json": { "schema": { @@ -12167,7 +12648,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlListResponse" } } } @@ -12179,13 +12660,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { "post": { - "operationId": "admin-set-signature-flow-config", - "summary": "Set signature flow configuration", + "operationId": "crl_api-revoke", + "summary": "Revoke a certificate by serial number", "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl" ], "security": [ { @@ -12202,17 +12683,23 @@ "schema": { "type": "object", "required": [ - "enabled" + "serialNumber" ], "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to force a signature flow for all documents" + "serialNumber": { + "type": "string", + "description": "Certificate serial number to revoke" }, - "mode": { + "reasonCode": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Revocation reason code (0-10, see RFC 5280)" + }, + "reasonText": { "type": "string", "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)" + "description": "Optional text describing the reason" } } } @@ -12245,7 +12732,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", + "description": "Certificate revoked successfully", "content": { "application/json": { "schema": { @@ -12265,7 +12752,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12275,7 +12762,7 @@ } }, "400": { - "description": "Invalid signature flow mode provided", + "description": "Invalid parameters", "content": { "application/json": { "schema": { @@ -12295,7 +12782,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12304,8 +12791,8 @@ } } }, - "500": { - "description": "Internal server error", + "404": { + "description": "Certificate not found", "content": { "application/json": { "schema": { @@ -12325,7 +12812,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12337,13 +12824,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { - "post": { - "operationId": "admin-set-doc-mdp-config", - "summary": "Configure DocMDP signature restrictions", + "/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": [ - "admin" + "policy" ], "security": [ { @@ -12353,31 +12840,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "enabled" - ], - "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)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12391,6 +12853,16 @@ "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", @@ -12404,7 +12876,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -12424,7 +12896,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SystemPolicyResponse" } } } @@ -12432,44 +12904,139 @@ } } } + } + } + }, + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] }, - "400": { - "description": "Invalid DocMDP level 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" + { + "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." } } } } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ + } + }, + "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": { @@ -12496,13 +13063,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": { "get": { - "operationId": "admin-get-active-signings", - "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", + "operationId": "policy-get-user-policy-for-user", + "summary": "Read an explicit user-level policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -12525,6 +13092,26 @@ "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", @@ -12538,7 +13125,7 @@ ], "responses": { "200": { - "description": "List of active signings", + "description": "OK", "content": { "application/json": { "schema": { @@ -12558,7 +13145,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActiveSigningsResponse" + "$ref": "#/components/schemas/UserPolicyResponse" } } } @@ -12567,8 +13154,8 @@ } } }, - "500": { - "description": "", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -12598,15 +13185,13 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { - "get": { - "operationId": "crl_api-list", - "summary": "List CRL entries with pagination and filters", + }, + "put": { + "operationId": "policy-set-user-policy-for-user", + "summary": "Save an explicit user policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "crl" + "policy" ], "security": [ { @@ -12616,6 +13201,49 @@ "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" + } + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -12630,105 +13258,23 @@ } }, { - "name": "page", - "in": "query", - "description": "Page number (1-based)", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "length", - "in": "query", - "description": "Number of items per page", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "status", - "in": "query", - "description": "Filter by status (issued, revoked, expired)", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "engine", - "in": "query", - "description": "Filter by engine type", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "instanceId", - "in": "query", - "description": "Filter by instance ID", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "generation", - "in": "query", - "description": "Filter by generation", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true - } - }, - { - "name": "owner", - "in": "query", - "description": "Filter by owner", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "serialNumber", - "in": "query", - "description": "Filter by serial number (partial match)", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "revokedBy", - "in": "query", - "description": "Filter by who revoked the certificate", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "sortBy", - "in": "query", - "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "required": true, "schema": { "type": "string", - "nullable": true + "pattern": "^[^/]+$" } }, { - "name": "sortOrder", - "in": "query", - "description": "Sort order (ASC or DESC)", + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the target user.", + "required": true, "schema": { "type": "string", - "nullable": true + "pattern": "^[a-z0-9_]+$" } }, { @@ -12744,7 +13290,7 @@ ], "responses": { "200": { - "description": "CRL entries retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -12764,7 +13310,67 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlListResponse" + "$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" } } } @@ -12774,15 +13380,13 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { - "post": { - "operationId": "crl_api-revoke", - "summary": "Revoke a certificate by serial number", + }, + "delete": { + "operationId": "policy-clear-user-policy-for-user", + "summary": "Clear an explicit user policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "crl" + "policy" ], "security": [ { @@ -12792,36 +13396,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "serialNumber" - ], - "properties": { - "serialNumber": { - "type": "string", - "description": "Certificate serial number to revoke" - }, - "reasonCode": { - "type": "integer", - "format": "int64", - "nullable": true, - "description": "Revocation reason code (0-10, see RFC 5280)" - }, - "reasonText": { - "type": "string", - "nullable": true, - "description": "Optional text describing the reason" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12835,6 +13409,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", @@ -12848,7 +13442,7 @@ ], "responses": { "200": { - "description": "Certificate revoked successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -12868,7 +13462,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -12878,7 +13472,7 @@ } }, "400": { - "description": "Invalid parameters", + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -12898,7 +13492,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12907,8 +13501,8 @@ } } }, - "404": { - "description": "Certificate not found", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -12928,7 +13522,102 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$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", + "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 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" + } + } + } } } } diff --git a/openapi.json b/openapi.json index c8588d072d..4844f1a49f 100644 --- a/openapi.json +++ b/openapi.json @@ -622,49 +622,118 @@ } ] }, - "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": [ - { + "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" + ], + "properties": { + "policyKey": { "type": "string" }, - { - "type": "integer", - "format": "int64" + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" }, - { - "type": "number", - "format": "double" + "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 } - ] + } }, - "DynamicMetadataValue": { + "EffectivePolicyValue": { + "nullable": true, "anyOf": [ { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "boolean" }, { - "type": "array", - "items": { - "$ref": "#/components/schemas/DynamicMetadataScalar" - } + "type": "integer", + "format": "int64" }, { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "number", + "format": "double" }, { - "type": "array", - "items": { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" } } ] @@ -1090,6 +1159,120 @@ } } }, + "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" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "group" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "GroupPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/GroupPolicyResponse" + } + ] + }, "IdDocs": { "type": "object", "required": [ @@ -1209,6 +1392,7 @@ "sms", "telegram", "whatsapp", + "whatsappbusiness", "xmpp" ] }, @@ -1240,7 +1424,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1252,16 +1436,53 @@ "sms", "telegram", "whatsapp", + "whatsappbusiness", "xmpp" ] }, "value": { "type": "string" }, - "mandatory": { + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + } + } + }, + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, + "IdentifyMethodSetting": { + "type": "object", + "required": [ + "name", + "friendly_name", + "enabled", + "requirement" + ], + "properties": { + "name": { + "type": "string" + }, + "friendly_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { "type": "integer", "format": "int64", - "minimum": 0 + "minimum": 1 + }, + "signatureMethods": { + "$ref": "#/components/schemas/SignatureMethods" } } }, @@ -1340,7 +1561,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1349,10 +1570,8 @@ "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } } @@ -1466,6 +1685,55 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotIdentifyMethodsEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdentifyMethodSetting" + } + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -2032,6 +2300,16 @@ } } }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -2146,6 +2424,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2154,6 +2435,23 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "identify_methods": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodsEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -3620,129 +3918,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": [] }, @@ -5868,15 +6053,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": [] }, @@ -5884,28 +6069,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", @@ -5932,7 +6095,7 @@ ], "responses": { "200": { - "description": "Certificate saved with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -5951,7 +6114,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/FooterTemplateResponse" + } } } } @@ -5959,8 +6124,8 @@ } } }, - "401": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -5980,7 +6145,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsUploadErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -5991,14 +6156,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": [] }, @@ -6006,6 +6171,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", @@ -6019,45 +6213,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", @@ -6071,7 +6226,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": { @@ -6091,7 +6257,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/IdDocsListResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6100,8 +6266,8 @@ } } }, - "404": { - "description": "No file provided or other problem with provided file", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6121,7 +6287,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6133,15 +6299,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": [] }, @@ -6149,36 +6314,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" - } - }, - { - "name": "uuid", - "in": "query", - "description": "Sign request UUID for unauthenticated access", - "schema": { + "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", + "in": "path", + "required": true, + "schema": { "type": "string", - "nullable": true + "enum": [ + "v1" + ], + "default": "v1" } }, { @@ -6194,7 +6374,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": { @@ -6213,7 +6404,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } } } } @@ -6221,8 +6414,8 @@ } } }, - "401": { - "description": "Failure to delete file from account", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6242,7 +6435,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessagesResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6254,14 +6447,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": [] }, @@ -6269,6 +6463,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", @@ -6282,63 +6498,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", @@ -6352,7 +6511,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Certificate saved with success", "content": { "application/json": { "schema": { @@ -6371,9 +6530,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "$ref": "#/components/schemas/IdDocsApprovalListResponse" - } + "data": {} } } } @@ -6381,8 +6538,8 @@ } } }, - "404": { - "description": "Account not found", + "401": { + "description": "No file provided or other problem with provided file", "content": { "application/json": { "schema": { @@ -6402,7 +6559,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/IdDocsUploadErrorResponse" } } } @@ -6412,17 +6569,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": [] }, @@ -6444,41 +6599,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 } }, { @@ -6514,7 +6670,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" } } } @@ -6526,14 +6712,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": [] }, @@ -6541,33 +6728,1141 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "fileId", - "signers" - ], - "properties": { + "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 sign the document. The return of this endpoint is related with Administration Settiongs > 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" }, - "signers": { - "type": "array", - "description": "Signers data", - "items": { + "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": [ - "email" + "meta", + "data" ], "properties": { - "email": { - "type": "string" + "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" } } } @@ -6576,7 +7871,22 @@ } } } - }, + } + }, + "delete": { + "operationId": "policy-clear-group", + "summary": "Clear a group-level policy value", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], "parameters": [ { "name": "apiVersion", @@ -6590,6 +7900,26 @@ "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", @@ -6623,7 +7953,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -6632,8 +7962,8 @@ } } }, - "401": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -6653,7 +7983,101 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$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" + } + } + } } } } @@ -6665,12 +8089,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": [ { @@ -6681,25 +8105,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" + } + } + ] } } } @@ -6719,6 +8155,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", @@ -6752,7 +8198,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -6761,8 +8207,8 @@ } } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -6782,7 +8228,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DangerMessagesResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6792,14 +8238,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": [ { @@ -6823,41 +8267,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_]+$" } }, { @@ -6893,7 +8309,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" } } } @@ -6971,10 +8417,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" + } } } } @@ -7130,10 +8579,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", @@ -8924,6 +10376,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 dec335b4e4..1a161cb6de 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", @@ -176,126 +177,1487 @@ "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "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-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "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.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "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/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/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/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@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-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "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-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "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/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-module-transforms": { + "node_modules/@babel/plugin-transform-spread": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "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-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@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" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-string-parser": { + "node_modules/@babel/plugin-transform-sticky-regex": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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/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/helper-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-validator-option": { + "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "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/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.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "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/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@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/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "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/types": "^7.29.0" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@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-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.0.0" + "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": { @@ -2053,6 +3415,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", @@ -5741,6 +7118,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", @@ -10545,6 +11967,14 @@ "dev": true, "license": "MIT" }, + "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", @@ -12944,6 +14374,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", @@ -12965,6 +14417,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", @@ -15806,6 +17299,54 @@ "dev": 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 7296299fdf..36225bb61e 100644 --- a/package.json +++ b/package.json @@ -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..993a500c1e --- /dev/null +++ b/playwright/e2e/confetti-after-signing-policy.spec.ts @@ -0,0 +1,125 @@ +/** + * 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').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.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') + const signButton = page.getByRole('button', { name: 'Sign the document.' }) + await expect(signButton).toBeVisible() + await signButton.click() + + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) + await page.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([ + { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, mandatory: false }, + ]), + ) +}) + +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..ad5fe6d554 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,9 +22,8 @@ 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 } } }, @@ -33,6 +32,7 @@ test('delete pending signature request', async ({ page }) => { ) 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 +58,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 +70,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 @@ -79,9 +80,13 @@ test('delete pending signature request', async ({ page }) => { // Confirm the deletion in the dialog await expect(page.getByRole('dialog', { name: 'Confirm' })).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()), + page.getByRole('button', { name: 'Ok' }).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..80824dc7f9 100644 --- a/playwright/e2e/envelope-validation-multi-file-bug.spec.ts +++ b/playwright/e2e/envelope-validation-multi-file-bug.spec.ts @@ -7,7 +7,7 @@ import { expect, test } from '@playwright/test' import type { APIRequestContext, Page } from '@playwright/test' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit' type EnvelopeSigningScenario = { @@ -78,10 +78,9 @@ 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 }, 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..90fc0753c5 100644 --- a/playwright/e2e/files-open-in-libresign-context-menu.spec.ts +++ b/playwright/e2e/files-open-in-libresign-context-menu.spec.ts @@ -39,8 +39,11 @@ 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 filesSearch = page.getByRole('searchbox', { name: /Search here/i }).first() + await expect(filesSearch).toBeVisible({ timeout: 15000 }) + await filesSearch.fill(fileName) const fileRow = filesTable.getByRole('row', { name: new RegExp(fileName) }) - await expect(fileRow).toBeVisible({ timeout: 15000 }) + await expect(fileRow).toBeVisible({ timeout: 30000 }) await fileRow.click({ button: 'right' }) 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..7b4fc8f6ad --- /dev/null +++ b/playwright/e2e/footer-policy-hierarchy-ui.spec.ts @@ -0,0 +1,385 @@ +/** + * 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, + setSystemPolicy, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, + 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_GROUPS = JSON.stringify(['admin', GROUP_ID]) +const DEFAULT_REQUEST_SIGN_GROUPS = JSON.stringify(['admin']) +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() : '' +} + +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, label: 'Target groups' | 'Target users', _placeholder: 'Search groups' | 'Search users', target: string): Promise { + const page = dialogScope.page() + const combobox = dialogScope.getByRole('combobox', { name: /Search for option/i }).first() + const labeledInput = dialogScope.getByLabel(label).first() + const targetInput = await combobox.count() ? combobox : labeledInput + const selectedTarget = dialogScope.locator('.vs__selected').filter({ hasText: new RegExp(target, 'i') }).first() + + await expect(targetInput).toBeVisible({ timeout: 8000 }) + if (await selectedTarget.isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(selectedTarget).toBeVisible() + return + } + + await targetInput.click() + + const searchInput = targetInput.locator('input').first() + if (await searchInput.count()) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await searchInput.fill(target) + + const matchingOption = page.getByRole('option', { name: new RegExp(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(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 selectedTarget.isVisible({ timeout: 2000 }).catch(() => false)) { + break + } + } + await expect(selectedTarget).toBeVisible({ timeout: 5000 }) + } else { + const fallbackTextbox = dialogScope.getByRole('textbox').first() + await fallbackTextbox.fill(target) + await fallbackTextbox.press('ArrowDown') + await fallbackTextbox.press('Enter') + await fallbackTextbox.press('Tab').catch(() => {}) + } + + 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, 'Target groups', 'Search groups', target) + } else { + await selectTarget(createRuleDialog, 'Target users', 'Search users', 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 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 resetFooterHierarchyState(adminRequestContext, endUserRequestContext) +}) + +test.afterEach(async ({ adminRequestContext, endUserRequestContext }) => { + await resetFooterHierarchyState(adminRequestContext, endUserRequestContext) + await setSystemPolicy(adminRequestContext, 'groups_request_sign', DEFAULT_REQUEST_SIGN_GROUPS) +}) + +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..35b39b1c02 --- /dev/null +++ b/playwright/e2e/footer-reset-persistence.spec.ts @@ -0,0 +1,73 @@ +/** + * 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 }) + +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..fcca197364 100644 --- a/playwright/e2e/multi-signer-parallel.spec.ts +++ b/playwright/e2e/multi-signer-parallel.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' import { createMailpitClient, waitForEmailTo } from '../support/mailpit' test('request signatures from two signers in parallel', async ({ page }) => { @@ -23,9 +23,8 @@ 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 }, diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 8cfbc241f5..1e38675482 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -3,11 +3,74 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Page } from '@playwright/test' -import { expect, test } from '@playwright/test' +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, test as base } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, getAppConfig, setAppConfig, setCertificateEngine, getSystemPolicyValue, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' +import { makeAdminContext } from '../support/system-policies' +import { setSystemPolicyEntry } from '../support/policy-api' + +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 ({}, 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 ?? '[]') + 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,33 +90,36 @@ 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', - ) - - await configureOpenSsl(page.request, 'LibreSign Test', { - C: 'BR', - OU: ['Organization Unit'], - ST: 'Rio de Janeiro', - O: 'LibreSign', - L: 'Rio de Janeiro', +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([ + { name: 'account', enabled: false, mandatory: false }, + { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, + ]), + ) + 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) }) - 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 mailpit = createMailpitClient() await mailpit.deleteMessages() @@ -69,10 +135,11 @@ test('request signatures from two signers in sequential order', async ({ page }) await addEmailSigner(page, 'signer02@libresign.coop', '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() @@ -95,8 +162,17 @@ test('request signatures from two signers in sequential order', async ({ page }) if (!signLink) throw new Error('Sign link not found in email') await page.goto(signLink) await page.getByRole('button', { name: 'Sign the document.' }).click() + const firstSignResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const firstSignResponse = await firstSignResponsePromise + const firstSignResponseBody = await firstSignResponse.text() + expect( + firstSignResponse.ok(), + `Signer 01 sign API failed with status ${firstSignResponse.status()}: ${firstSignResponseBody}`, + ).toBeTruthy() 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() @@ -104,7 +180,6 @@ test('request signatures from two signers in sequential order', async ({ page }) 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 expect(page.getByText('Signer 02')).toBeVisible() await expect(page.getByText('Not signed yet')).toBeVisible() @@ -121,8 +196,17 @@ test('request signatures from two signers in sequential order', async ({ page }) 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() + const secondSignResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const secondSignResponse = await secondSignResponsePromise + const secondSignResponseBody = await secondSignResponse.text() + expect( + secondSignResponse.ok(), + `Signer 02 sign API failed with status ${secondSignResponse.status()}: ${secondSignResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() // Both signers must appear as signed in the final validation view. @@ -130,3 +214,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..768e64bc2c --- /dev/null +++ b/playwright/e2e/policy-api-signature-flow-negative.spec.ts @@ -0,0 +1,50 @@ +/** + * 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' + +test.describe.configure({ mode: 'serial', retries: 0 }) + +test('rejects unsupported signature_flow value at system scope', async () => { + const ctx = await createAuthenticatedRequestContext(adminUser, adminPassword) + try { + 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) + } finally { + await ctx.dispose() + } +}) + +test('rejects unsupported signature_flow value at group scope', async () => { + const ctx = await createAuthenticatedRequestContext(adminUser, adminPassword) + try { + 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) + } finally { + await ctx.dispose() + } +}) 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..860922b278 --- /dev/null +++ b/playwright/e2e/policy-preferences-boolean-settings.spec.ts @@ -0,0 +1,345 @@ +/** + * 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, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, + setGroupPolicyEntry, + setSystemPolicyEntry, +} from '../support/policy-api' + +type SystemPolicySnapshot = { + exists: boolean + value: unknown + allowChildOverride: boolean +} + +const adminUser = 'admin' +const adminPass = process.env.ADMIN_PASSWORD || 'admin' + +test.describe('Policy preferences: boolean settings', () => { + test('user can save and clear collect_metadata/docmdp while signature_text follows group policy', async ({ page }) => { + test.setTimeout(180000) + const groupId = `pref-boolean-${Date.now()}` + const endUser = `prefboolean_${Date.now()}` + const endPass = 'user1234' + + const adminCtx = await createAuthenticatedRequestContext(adminUser, adminPass) + let endUserCtx: APIRequestContext | null = null + const originalGroupsRequestSign = await getSystemPolicySnapshot(adminCtx, 'groups_request_sign') + const originalCollectMetadata = await getSystemPolicySnapshot(adminCtx, 'collect_metadata') + const originalDocmdp = await getSystemPolicySnapshot(adminCtx, 'docmdp') + const 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', + }) + + try { + 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([groupId]), 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 collectMetadataSection = await sectionByTitle(page, 'Collect signer metadata') + const docMdpSection = await sectionByTitle(page, 'PDF certification') + const signatureTextSection = await sectionByTitle(page, /Signature stamp text|Signature text|Signature stamp/i) + + expect(await collectMetadataSection.isVisible()).toBe(true) + expect(await docMdpSection.isVisible()).toBe(true) + expect(await signatureTextSection.isVisible()).toBe(true) + + await savePreferenceAsDisabled(collectMetadataSection) + 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(collectMetadataSection) + await clearPreference(docMdpSection) + + await expectPolicyEffectiveValue(endUserCtx, 'collect_metadata', true, 'group') + await expectDocMdpEffectiveValue(endUserCtx, 2, 'group') + await expectSignatureTextEffectiveState(endUserCtx, 'group', { + templateContains: 'Group template', + renderMode: 'text', + }) + } finally { + 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() + } + + await restoreSystemPolicySnapshot(adminCtx, 'groups_request_sign', originalGroupsRequestSign) + await restoreSystemPolicySnapshot(adminCtx, 'collect_metadata', originalCollectMetadata) + await restoreSystemPolicySnapshot(adminCtx, 'docmdp', originalDocmdp) + await restoreSystemPolicySnapshot(adminCtx, 'signature_stamp', originalSignatureText) + + await policyRequest(adminCtx, 'DELETE', `/cloud/users/${endUser}`) + await policyRequest(adminCtx, 'DELETE', `/cloud/groups/${groupId}`) + await adminCtx.dispose() + } + }) +}) + +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 +} + +async function getSystemPolicySnapshot( + ctx: APIRequestContext, + policyKey: string, +): Promise { + const response = await policyRequest(ctx, 'GET', `/apps/libresign/api/v1/policies/system/${policyKey}`) + if (response.httpStatus === 404) { + return { + exists: false, + value: null, + allowChildOverride: true, + } + } + + expect(response.httpStatus, `getSystemPolicySnapshot(${policyKey}): expected 200 or 404 but got ${response.httpStatus}`).toBe(200) + + return { + exists: true, + value: response.data.value ?? null, + allowChildOverride: response.data.allowChildOverride === true, + } +} + +async function restoreSystemPolicySnapshot( + ctx: APIRequestContext, + policyKey: string, + snapshot: SystemPolicySnapshot, +): Promise { + if (!snapshot.exists) { + await setSystemPolicyEntry(ctx, policyKey, null, true) + return + } + + const response = await policyRequest(ctx, 'POST', `/apps/libresign/api/v1/policies/system/${policyKey}`, { + value: snapshot.value, + allowChildOverride: snapshot.allowChildOverride, + }) + expect(response.httpStatus, `restoreSystemPolicySnapshot(${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +async function savePreferenceAsDisabled(section: Locator): Promise { + const disabledOption = section.getByRole('radio', { name: /^(Disable metadata collection|Disabled)\b/i }).first() + await disabledOption.click({ force: true }) +} + +async function clearPreference(section: Locator): Promise { + const resetButton = section.getByRole('button').filter({ hasText: 'Reset to default' }).first() + await expect(resetButton).toBeVisible() + + await resetButton.click() +} + +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 }) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +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 }) + } +} + +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, + }) +} + +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, + }) +} + +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) +} + +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: '' } +} + +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) +} + +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..5ea10dc8c5 --- /dev/null +++ b/playwright/e2e/policy-preferences-visibility.spec.ts @@ -0,0 +1,142 @@ +/** + * 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, + setGroupPolicyEntry, + setSystemPolicyEntry, + waitForPolicyCanSaveAsUserDefault, +} 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({ 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(['admin', GROUP_ID]) +const DEFAULT_REQUEST_SIGN_GROUPS = JSON.stringify(['admin']) +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, +}) + +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) + 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) + await setSystemPolicy(adminRequestContext, 'groups_request_sign', DEFAULT_REQUEST_SIGN_GROUPS) +}) + +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-settings-menu-visibility.spec.ts b/playwright/e2e/policy-settings-menu-visibility.spec.ts new file mode 100644 index 0000000000..c9c044b58d --- /dev/null +++ b/playwright/e2e/policy-settings-menu-visibility.spec.ts @@ -0,0 +1,142 @@ +/** + * 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 open "Create rule" and 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 { randomBytes } from 'node:crypto' +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setSystemPolicy, + setUserLanguage, +} from '../support/nc-provisioning' +import { + createAuthenticatedRequestContext, + getEffectivePolicy, + setSystemPolicyEntry, +} 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 ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({}, use) => { + 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.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const GROUP_ADMIN_PASSWORD = '123456' + +const TEST_NAMESPACE = randomBytes(6).toString('hex') +const GROUP_ID = `policy-menu-visibility-group-${TEST_NAMESPACE}` +const GROUP_ADMIN = `policy-menu-visibility-admin-${TEST_NAMESPACE}` + +const POLICY_KEY = 'add_footer' +const REQUEST_SIGN_GROUPS = JSON.stringify(['admin', GROUP_ID]) +const DEFAULT_REQUEST_SIGN_GROUPS = JSON.stringify(['admin']) +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) + + +test.beforeEach(async ({ adminRequestContext }) => { + await ensureGroupExists(adminRequestContext, GROUP_ID) + await setSystemPolicy(adminRequestContext, 'groups_request_sign', REQUEST_SIGN_GROUPS) +}) + + +test.afterEach(async ({ adminRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, null, true) + await setSystemPolicy(adminRequestContext, 'groups_request_sign', DEFAULT_REQUEST_SIGN_GROUPS) +}) + +test('group admin can access policies and sees create-rule guard when higher-level rules block exceptions', async ({ page, adminRequestContext, groupAdminRequestContext }) => { + // ── 0. Provision users/groups (idempotent; safe to call on every run) ── + await ensureUserExists(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, GROUP_ADMIN, GROUP_ID) + await ensureSubadminOfGroup(page.request, GROUP_ADMIN, GROUP_ID) + await setUserLanguage(page.request, GROUP_ADMIN, 'en') + + // ── 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 visibility and guard message ─────────────── + 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).toBeDisabled() + await expect(createRuleButton).toHaveAttribute('title', /higher-level rule is blocking new exceptions in all scopes/i) +}) 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..61fb7d78dc --- /dev/null +++ b/playwright/e2e/policy-workbench-boolean-settings.spec.ts @@ -0,0 +1,111 @@ +/** + * 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, + policyRequest, + setSystemPolicyEntry, +} 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' }, +] + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +test('boolean settings stay consistent between effective policy and admin initial state', async ({ page }) => { + const adminContext = await createAuthenticatedRequestContext(adminUser, adminPassword) + + try { + await login(page.request, adminUser, adminPassword) + await page.goto('./settings/admin/libresign') + + for (const setting of booleanSettings) { + await clearAdminOverrides(adminContext, setting.policyKey) + await setSystemPolicyEntry(adminContext, setting.policyKey, JSON.stringify(false), true) + await page.reload() + + const effectiveDisabled = await getEffectivePolicy(adminContext, 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(adminContext, setting.policyKey, JSON.stringify(true), true) + await page.reload() + + const effectiveEnabled = await getEffectivePolicy(adminContext, 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) + } + } + } finally { + for (const setting of booleanSettings) { + await clearAdminOverrides(adminContext, setting.policyKey) + await setSystemPolicyEntry(adminContext, setting.policyKey, null, true) + } + await adminContext.dispose() + } +}) + +/** + * 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..6e0ae1c647 --- /dev/null +++ b/playwright/e2e/policy-workbench-identify-methods-rule-creation.spec.ts @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreSign 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 { + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, + setSystemPolicy, + setUserLanguage, +} from '../support/nc-provisioning' +import { clearPolicyWorkbenchRules } from '../support/policy-workbench-rules' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const GROUP_ID = 'libresign-identify-rule-group' +const USER_ID = 'identifyruleuser' + +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 }) + return dialog +} + +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 { + await activeDialog.getByRole('button', { name: /Create rule|Create policy rule/i }).first().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 = activeDialog.getByRole('button', { name: /Create rule|Create policy rule/i }).first() + const canOpenCreateScope = await createRuleButton.isVisible({ timeout: 2000 }).catch(() => false) + && await createRuleButton.isEnabled().catch(() => false) + + 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 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 +} + +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 }) +} + +test('identification factors rule editor shows available methods for everyone, group and user scopes', 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 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([ + { 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 everyoneRuleDialog.getByRole('button', { name: /Cancel/i }).click() + + const groupRuleDialog = await openScopeRuleEditor(page, dialog, 'group') + await assertIdentifyMethodsAreAvailable(groupRuleDialog) + await groupRuleDialog.getByRole('button', { name: /Cancel/i }).click() + + const userRuleDialog = await openScopeRuleEditor(page, dialog, 'user') + await assertIdentifyMethodsAreAvailable(userRuleDialog) +}) 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..2e12957a60 --- /dev/null +++ b/playwright/e2e/policy-workbench-personas-permissions.spec.ts @@ -0,0 +1,186 @@ +/** + * 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() +}) 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..42992373a4 --- /dev/null +++ b/playwright/e2e/policy-workbench-signature-hash-algorithm-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('signature_hash_algorithm 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, /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 }) + 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('SHA512', { 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/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..bfc0f40468 --- /dev/null +++ b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts @@ -0,0 +1,437 @@ +/** + * 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 { login } from '../support/nc-login' +import { ensureUserExists } from '../support/nc-provisioning' +import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' +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 instanceWideTargetLabel = 'Default (instance-wide)' +const ruleDialogName = /Create rule|Edit rule|What do you want to create\?/i + +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 combobox = root.getByRole('combobox', { name: ariaLabel }).first() + const labeledInput = root.getByLabel(ariaLabel).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').first() + if (await searchInput.count()) { + 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 { + const fallbackTextbox = root.getByRole('textbox').first() + await fallbackTextbox.fill(optionText) + await fallbackTextbox.press('ArrowDown') + await fallbackTextbox.press('Enter') + await fallbackTextbox.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 = `policy-system-default-user-${Date.now()}` + + 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: 'Target users' }).first() + const targetUsersLabel = stableDialog.page().getByLabel('Target users').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..c021ba6ee3 --- /dev/null +++ b/playwright/e2e/policy-workbench-validation-access-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('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..44c80863b2 100644 --- a/playwright/e2e/send-reminder.spec.ts +++ b/playwright/e2e/send-reminder.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' import { createMailpitClient, waitForEmailTo } from '../support/mailpit' /** @@ -29,9 +29,8 @@ 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 }, @@ -67,7 +66,9 @@ test('admin can send a reminder to a pending signer', async ({ page }) => { // 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') diff --git a/playwright/e2e/sign-email-token-authenticated.spec.ts b/playwright/e2e/sign-email-token-authenticated.spec.ts index ab5d2f4219..04a347e06e 100644 --- a/playwright/e2e/sign-email-token-authenticated.spec.ts +++ b/playwright/e2e/sign-email-token-authenticated.spec.ts @@ -5,8 +5,11 @@ 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 } from '../support/system-policies' + +useFooterPolicyGuard() /** * An authenticated Nextcloud user can sign a document via the email+token @@ -32,31 +35,29 @@ 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 }, ]), ) - + 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() @@ -72,11 +73,19 @@ 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 the 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') @@ -87,8 +96,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() + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + 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..e996e66031 100644 --- a/playwright/e2e/sign-email-token-unauthenticated.spec.ts +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -5,8 +5,11 @@ 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 { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() test('sign document with email token as unauthenticated signer', async ({ page }) => { await login( @@ -23,31 +26,33 @@ 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: 'account', enabled: false, mandatory: false }, { name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false }, ]), ) + 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('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('option', { name: 'signer01@libresign.coop' }).first().click(); + await page.getByRole('textbox', { name: 'Signer name' }).first().click(); + await page.getByRole('textbox', { name: 'Signer name' }).first().press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'Signer name' }).first().fill('Signer 01'); await page.getByRole('button', { name: 'Save' }).click(); const mailpit = createMailpitClient() @@ -64,37 +69,22 @@ test('sign document with email token as unauthenticated signer', async ({ page } const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') const signLink = extractSignLink(email.Text) if (!signLink) throw new Error('Sign link not found in email') - - // 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 - } - - 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'); + const openSignButton = page.getByRole('button', { name: 'Sign the 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(); + } + await expect(emailTextbox).toBeVisible() + await emailTextbox.click(); + await emailTextbox.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, 'signer01@libresign.coop', 'LibreSign: Code to sign file', { 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(); @@ -105,10 +95,18 @@ test('sign document with email token as unauthenticated signer', async ({ page } 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(); + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**'); + 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('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(); }); diff --git a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts index 65b98d19eb..ea30e3e01e 100644 --- a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts +++ b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts @@ -4,9 +4,12 @@ */ 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 { configureOpenSsl, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() /** * Issue #7344 in plain words: @@ -83,10 +86,12 @@ 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 }, @@ -167,6 +172,25 @@ async function openInvitationAsExternalSigner(page: Page, signLink: string) { await page.goto(signLink) } +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 deleteSignatureButton = page.getByRole('button', { name: 'Delete signature' }) await deleteSignatureButton.waitFor({ state: 'visible', timeout: 3_000 }).catch(() => null) @@ -179,23 +203,22 @@ async function defineVisibleSignature(page: Page) { 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.getByRole('button', { name: /Sign the document\.|Sign document/ }).first() + await expect(signDocumentCta).toBeVisible() } async function finishSigning(page: Page) { - await page.getByRole('button', { name: 'Sign the document.' }).click() + const openSignButton = page.getByRole('button', { name: /Sign the document\.|Sign document/ }).first() + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click() + } await page.getByRole('button', { name: 'Sign document' }).click() } 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..dcb6b9effb 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 @@ -5,7 +5,10 @@ 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 { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() async function sortByCreatedAtDescending(page: Page) { const createdAtTh = page.getByRole('columnheader', { name: 'Created at' }) @@ -33,10 +36,9 @@ 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 } } }, @@ -64,11 +66,19 @@ test('updates files list status after signing with native engine', async ({ page .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 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 expect(targetRow.locator('.status-chip__text')).toHaveText('Ready to sign') await targetRow.getByRole('button', { name: 'Actions' }).click() @@ -77,10 +87,23 @@ test('updates files list status after signing with native engine', async ({ page const signButton = page.getByRole('button', { name: 'Sign the document.' }) await expect(signButton).toBeVisible() await signButton.click() + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + 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.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..b51edcd2bf 100644 --- a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts +++ b/playwright/e2e/sign-herself-with-click-to-sign.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('sign herself with click to sign', async ({ page }) => { await login( @@ -22,9 +22,8 @@ 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 } } }, @@ -44,12 +43,20 @@ test('sign herself with click to sign', async ({ page }) => { await page.getByRole('button', { name: 'Send' }).click(); await page.getByRole('button', { name: 'Sign document' }).click(); await page.getByRole('button', { name: 'Sign the document.' }).click(); + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ); await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**'); + 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-herself-with-drawn-signature.spec.ts b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts index fd9317d7ef..6993120a6d 100644 --- a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts +++ b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts @@ -4,14 +4,33 @@ */ 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' function getVisiblePdfOverlay(dialog: Locator) { return dialog.locator('.overlay:visible').first() } +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,9 +46,8 @@ 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 } } }, @@ -86,51 +104,41 @@ test('sign herself with drawn signature', async ({ page }) => { ).toBeVisible() 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' }).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(); - // 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 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 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(); + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**') + 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..48c5df4a9d 100644 --- a/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts +++ b/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts @@ -5,7 +5,7 @@ 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' test('sign herself with pkcs12 certificate', async ({ page }) => { const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' @@ -21,9 +21,8 @@ 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 } } }, @@ -55,12 +54,20 @@ test('sign herself with pkcs12 certificate', async ({ page }) => { await page.getByText('Forgot password?').click() await expect(page.getByRole('button', { name: 'Read certificate' })).toBeVisible() await expect(page.getByRole('button', { name: 'Delete certificate' })).toBeVisible() + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + 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..ccca62642a 100644 --- a/playwright/e2e/sign-password-non-retriable-error.spec.ts +++ b/playwright/e2e/sign-password-non-retriable-error.spec.ts @@ -5,7 +5,7 @@ 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' async function prepareSignFlow(page: Parameters[1] extends (args: infer T) => any ? T['page'] : never, adminUser: string) { await page.goto('./apps/libresign') @@ -41,9 +41,8 @@ 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 } } }, @@ -51,9 +50,8 @@ async function bootstrapAdminCertificate(page: Parameters[1] extend ]), ) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'crl_external_validation_enabled', '1', ) @@ -102,9 +100,8 @@ test('switches from blocked (enabled) to normal (disabled) without extra scenari 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', ) diff --git a/playwright/e2e/sign-wrong-session.spec.ts b/playwright/e2e/sign-wrong-session.spec.ts index 4a34a410a6..f1a6fda790 100644 --- a/playwright/e2e/sign-wrong-session.spec.ts +++ b/playwright/e2e/sign-wrong-session.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' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' /** @@ -32,9 +32,8 @@ test('authenticated user sees error when accessing another signer\'s email link' L: 'Rio de Janeiro', }) - await setAppConfig( + await setSystemPolicy( page.request, - 'libresign', 'identify_methods', JSON.stringify([ { name: 'account', enabled: false, mandatory: false }, 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..bdd493ce92 --- /dev/null +++ b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts @@ -0,0 +1,205 @@ +/** + * 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' + +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(['admin'])) +}) + +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([ + { name: 'account', enabled: false, mandatory: false }, + { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, + ]), + ) + + 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([ + { name: 'account', enabled: false, mandatory: false }, + { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, + ]), + ) + + await setSystemPolicy( + adminRequestContext, + 'groups_request_sign', + JSON.stringify(['admin', GROUP_ADMIN_GROUP]), + ) + + 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..3984e169a4 --- /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(['admin'])) + 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(['admin'])) + 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..223ef046ef --- /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(['admin'])) + + 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..6e7e1dc6bc 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,9 +51,8 @@ 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 } } }, @@ -72,7 +82,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 +106,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 +134,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..3d84e3fe95 --- /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(['admin'])) + 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/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..445b14a181 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, @@ -124,6 +219,117 @@ export async function deleteUser( await ocsRequest(request, 'DELETE', `/cloud/users/${userId}`) } +/** + * 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}`) + } +} + +/** + * 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 { + 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}".`) + } +} + // --------------------------------------------------------------------------- // App config (equivalent to `occ config:app:set`) // --------------------------------------------------------------------------- @@ -151,6 +357,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 +389,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 +485,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 +520,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/policy-api.ts b/playwright/support/policy-api.ts new file mode 100644 index 0000000000..da5f24bc2a --- /dev/null +++ b/playwright/support/policy-api.ts @@ -0,0 +1,193 @@ +/** + * 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[] +} + +// --------------------------------------------------------------------------- +// 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 +} + +/** + * 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..dbca344510 --- /dev/null +++ b/playwright/support/policy-workbench-rules.ts @@ -0,0 +1,136 @@ +/** + * 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.click({ timeout: 1500 }) + return true +} + +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..0aaae6b607 --- /dev/null +++ b/playwright/support/system-policies.ts @@ -0,0 +1,119 @@ +/** + * 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, expect, request, type APIRequestContext } from '@playwright/test' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const FOOTER_POLICY_KEY = 'add_footer' + +export const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +// --------------------------------------------------------------------------- +// 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', + }, + }) +} + +/** + * Reads the current value of a system policy. Returns `null` when the policy + * has not been set (HTTP 404). + */ +export async function getSystemPolicy(ctx: APIRequestContext, key: string): Promise { + const response = await ctx.get(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${key}`, { + failOnStatusCode: false, + }) + if (response.status() === 404) { + return null + } + + const payload = await response.json() as { ocs?: { data?: { value?: string | null } } } + return payload.ocs?.data?.value ?? null +} + +/** + * Writes a system policy value. When `value` is `null` (meaning the policy + * was not set before) this is a no-op so the absent state is preserved on + * restore. + */ +export async function setSystemPolicy(ctx: APIRequestContext, key: string, value: string | null): Promise { + if (value === null) { + return + } + + const response = await ctx.post(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${key}`, { + data: { + value, + allowChildOverride: true, + }, + failOnStatusCode: false, + }) + + expect(response.status(), `setSystemPolicy(${key}): expected 200 but got ${response.status()}`).toBe(200) +} + +// --------------------------------------------------------------------------- +// 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 + + test.beforeEach(async () => { + adminContext = await makeAdminContext() + originalFooterPolicy = await getSystemPolicy(adminContext, FOOTER_POLICY_KEY) + await setSystemPolicy(adminContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE) + }) + + test.afterEach(async () => { + await setSystemPolicy(adminContext, FOOTER_POLICY_KEY, originalFooterPolicy) + 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 @@ --> @@ -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 @@