diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaf66e6..062ba020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ before starting to add changes. Use example [placed in the end of the page](#exa ## [Unreleased] +- [PR-215](https://github.com/OS2Forms/os2forms/pull/215) + Added condition to maestro notification submission handler +- [PR-101](https://github.com/OS2Forms/os2forms/pull/101) + - Added support for `os2web_key` in Digital post + - Switched from saving settings in key value store to config, i.e + the module needs to be reconfigured. + - Added support for `os2web_key` in Fasit handler. + - Switched from saving settings in key value store to config, i.e + the module needs to be reconfigured. +- [PR-179](https://github.com/OS2Forms/os2forms/pull/179) + Remove unused and abandoned package `webmozart/path-util`. +- [PR-167](https://github.com/OS2Forms/os2forms/pull/167) + Adding os2forms_digital_signature module + ## [4.1.0] 2025-06-03 - [PR-176](https://github.com/OS2Forms/os2forms/pull/176) diff --git a/composer.json b/composer.json index 5f20db3d..cb61499b 100644 --- a/composer.json +++ b/composer.json @@ -58,13 +58,13 @@ "itk-dev/serviceplatformen": "^1.5", "mglaman/composer-drupal-lenient": "^1.0", "os2web/os2web_audit": "^1.0", - "os2web/os2web_datalookup": "^2.0", + "os2web/os2web_datalookup": "^3.0", + "os2web/os2web_key": "^1.0", "os2web/os2web_nemlogin": "^1.0", "os2web/os2web_simplesaml": "dev-master", "php-http/guzzle7-adapter": "^1.0", "phpoffice/phpword": "^0.18.2", "symfony/options-resolver": "^5.4 || ^6.0", - "webmozart/path-util": "^2.3", "wsdltophp/packagebase": "^5.0", "zaporylie/composer-drupal-optimizations": "^1.2" }, diff --git a/modules/os2forms_attachment/os2forms_attachment.services.yml b/modules/os2forms_attachment/os2forms_attachment.services.yml index 34d2d676..e477d46f 100644 --- a/modules/os2forms_attachment/os2forms_attachment.services.yml +++ b/modules/os2forms_attachment/os2forms_attachment.services.yml @@ -1,4 +1,4 @@ services: os2forms_attachment.print_builder: class: Drupal\os2forms_attachment\Os2formsAttachmentPrintBuilder - arguments: ['@entity_print.renderer_factory', '@event_dispatcher', '@string_translation'] + arguments: ['@entity_print.renderer_factory', '@event_dispatcher', '@string_translation', '@file_system'] diff --git a/modules/os2forms_attachment/src/Element/AttachmentElement.php b/modules/os2forms_attachment/src/Element/AttachmentElement.php index 05d37e4c..cdf3ae44 100644 --- a/modules/os2forms_attachment/src/Element/AttachmentElement.php +++ b/modules/os2forms_attachment/src/Element/AttachmentElement.php @@ -20,6 +20,7 @@ public function getInfo() { return parent::getInfo() + [ '#view_mode' => 'html', '#export_type' => 'pdf', + '#digital_signature' => FALSE, '#template' => '', ]; } @@ -28,6 +29,8 @@ public function getInfo() { * {@inheritdoc} */ public static function getFileContent(array $element, WebformSubmissionInterface $webform_submission) { + $submissionUuid = $webform_submission->uuid(); + // Override webform settings. static::overrideWebformSettings($element, $webform_submission); @@ -51,18 +54,43 @@ public static function getFileContent(array $element, WebformSubmissionInterface \Drupal::request()->request->set('_webform_submissions_view_mode', $view_mode); if ($element['#export_type'] === 'pdf') { - // Get scheme. - $scheme = 'temporary'; - - // Get filename. - $file_name = 'webform-entity-print-attachment--' . $webform_submission->getWebform()->id() . '-' . $webform_submission->id() . '.pdf'; - - // Save printable document. - $print_engine = $print_engine_manager->createSelectedInstance($element['#export_type']); - $temporary_file_path = $print_builder->savePrintable([$webform_submission], $print_engine, $scheme, $file_name); - if ($temporary_file_path) { - $contents = file_get_contents($temporary_file_path); - \Drupal::service('file_system')->delete($temporary_file_path); + $file_path = NULL; + + // If attachment with digital signatur, check if we already have one. + if (isset($element['#digital_signature']) && $element['#digital_signature']) { + // Get scheme. + $scheme = 'private'; + + // Get filename. + $file_name = 'webform/' . $webform_submission->getWebform()->id() . '/digital_signature/' . $submissionUuid . '.pdf'; + $file_path = "$scheme://$file_name"; + } + + if (!$file_path || !file_exists($file_path)) { + // Get scheme. + $scheme = 'temporary'; + // Get filename. + $file_name = 'webform-entity-print-attachment--' . $webform_submission->getWebform()->id() . '-' . $webform_submission->id() . '.pdf'; + + // Save printable document. + $print_engine = $print_engine_manager->createSelectedInstance($element['#export_type']); + + // Adding digital signature. + if (isset($element['#digital_signature']) && $element['#digital_signature']) { + $file_path = $print_builder->savePrintableDigitalSignature([$webform_submission], $print_engine, $scheme, $file_name); + } + else { + $file_path = $print_builder->savePrintable([$webform_submission], $print_engine, $scheme, $file_name); + } + } + + if ($file_path) { + $contents = file_get_contents($file_path); + + // Deleting temporary file. + if ($scheme == 'temporary') { + \Drupal::service('file_system')->delete($file_path); + } } else { // Log error. diff --git a/modules/os2forms_attachment/src/Os2formsAttachmentPrintBuilder.php b/modules/os2forms_attachment/src/Os2formsAttachmentPrintBuilder.php index 67a0c99a..1f8a9f38 100644 --- a/modules/os2forms_attachment/src/Os2formsAttachmentPrintBuilder.php +++ b/modules/os2forms_attachment/src/Os2formsAttachmentPrintBuilder.php @@ -3,14 +3,28 @@ namespace Drupal\os2forms_attachment; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\File\FileExists; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\entity_print\Event\PreSendPrintEvent; +use Drupal\entity_print\Event\PrintEvents; use Drupal\entity_print\Plugin\PrintEngineInterface; use Drupal\entity_print\PrintBuilder; +use Drupal\entity_print\Renderer\RendererFactoryInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * The OS2Forms attachment print builder service. */ class Os2formsAttachmentPrintBuilder extends PrintBuilder { + /** + * {@inheritdoc} + */ + public function __construct(RendererFactoryInterface $renderer_factory, EventDispatcherInterface $event_dispatcher, TranslationInterface $string_translation, protected readonly FileSystemInterface $file_system) { + parent::__construct($renderer_factory, $event_dispatcher, $string_translation); + } + /** * {@inheritdoc} */ @@ -27,10 +41,56 @@ public function printHtml(EntityInterface $entity, $use_default_css = TRUE, $opt return $renderer->generateHtml([$entity], $render, $use_default_css, $optimize_css); } + /** + * Modified version of the original savePrintable() function. + * + * The only difference is modified call to prepareRenderer with digitalPost + * flag TRUE. + * + * @see PrintBuilder::savePrintable() + * + * @return string + * FALSE or the URI to the file. E.g. public://my-file.pdf. + */ + public function savePrintableDigitalSignature(array $entities, PrintEngineInterface $print_engine, $scheme = 'public', $filename = FALSE, $use_default_css = TRUE) { + $renderer = $this->prepareRenderer($entities, $print_engine, $use_default_css, TRUE); + + // Allow other modules to alter the generated Print object. + $this->dispatcher->dispatch(new PreSendPrintEvent($print_engine, $entities), PrintEvents::PRE_SEND); + + // If we didn't have a URI passed in the generate one. + if (!$filename) { + $filename = $renderer->getFilename($entities) . '.' . $print_engine->getExportType()->getFileExtension(); + } + + $uri = "$scheme://$filename"; + + // Save the file. + return $this->file_system->saveData($print_engine->getBlob(), $uri, FileExists::Replace); + } + /** * {@inheritdoc} */ - protected function prepareRenderer(array $entities, PrintEngineInterface $print_engine, $use_default_css) { + + /** + * Override prepareRenderer() the print engine with the passed entities. + * + * @param array $entities + * An array of entities. + * @param \Drupal\entity_print\Plugin\PrintEngineInterface $print_engine + * The print engine. + * @param bool $use_default_css + * TRUE if we want the default CSS included. + * @param bool $digitalSignature + * If the digital signature message needs to be added. + * + * @return \Drupal\entity_print\Renderer\RendererInterface + * A print renderer. + * + * @see PrintBuilder::prepareRenderer + */ + protected function prepareRenderer(array $entities, PrintEngineInterface $print_engine, $use_default_css, $digitalSignature = FALSE) { if (empty($entities)) { throw new \InvalidArgumentException('You must pass at least 1 entity'); } @@ -50,6 +110,9 @@ protected function prepareRenderer(array $entities, PrintEngineInterface $print_ // structure. That margin is automatically added in PDF and PDF only. $generatedHtml = (string) $renderer->generateHtml($entities, $render, $use_default_css, TRUE); $generatedHtml .= ""; + if ($digitalSignature) { + $generatedHtml .= $this->t('You can validate the signature on this PDF file via validering.nemlog-in.dk.'); + } $print_engine->addPage($generatedHtml); diff --git a/modules/os2forms_attachment/src/Plugin/WebformElement/AttachmentElement.php b/modules/os2forms_attachment/src/Plugin/WebformElement/AttachmentElement.php index 4f2215d6..7ec580bd 100644 --- a/modules/os2forms_attachment/src/Plugin/WebformElement/AttachmentElement.php +++ b/modules/os2forms_attachment/src/Plugin/WebformElement/AttachmentElement.php @@ -27,6 +27,7 @@ protected function defineDefaultProperties() { 'view_mode' => 'html', 'template' => '', 'export_type' => '', + 'digital_signature' => '', 'exclude_empty' => '', 'exclude_empty_checkbox' => '', 'excluded_elements' => '', @@ -88,6 +89,11 @@ public function form(array $form, FormStateInterface $form_state) { 'html' => $this->t('HTML'), ], ]; + $form['attachment']['digital_signature'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Digital signature'), + ]; + // Set #access so that help is always visible. WebformElementHelper::setPropertyRecursive($form['attachment']['help'], '#access', TRUE); diff --git a/modules/os2forms_dawa/src/Plugin/os2web/DataLookup/DatafordelerDataLookup.php b/modules/os2forms_dawa/src/Plugin/os2web/DataLookup/DatafordelerDataLookup.php index a9acd083..00df83a3 100644 --- a/modules/os2forms_dawa/src/Plugin/os2web/DataLookup/DatafordelerDataLookup.php +++ b/modules/os2forms_dawa/src/Plugin/os2web/DataLookup/DatafordelerDataLookup.php @@ -3,8 +3,10 @@ namespace Drupal\os2forms_dawa\Plugin\os2web\DataLookup; use Drupal\Component\Utility\NestedArray; +use Drupal\Core\File\FileSystem; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\key\KeyRepositoryInterface; use Drupal\os2forms_dawa\Entity\DatafordelerMatrikula; use Drupal\os2web_audit\Service\Logger; use Drupal\os2web_datalookup\Plugin\os2web\DataLookup\DataLookupBase; @@ -30,20 +32,31 @@ public function __construct( $plugin_definition, protected ClientInterface $httpClient, Logger $auditLogger, + KeyRepositoryInterface $keyRepository, + FileSystem $fileSystem, ) { - parent::__construct($configuration, $plugin_id, $plugin_definition, $auditLogger); + parent::__construct($configuration, $plugin_id, $plugin_definition, $auditLogger, $keyRepository, $fileSystem); } /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + /** @var \Drupal\os2web_audit\Service\Logger $auditLogger */ + $auditLogger = $container->get('os2web_audit.logger'); + /** @var \Drupal\key\KeyRepositoryInterface $keyRepository */ + $keyRepository = $container->get('key.repository'); + /** @var \Drupal\Core\File\FileSystem $fileSystem */ + $fileSystem = $container->get('file_system'); + return new static( $configuration, $plugin_id, $plugin_definition, $container->get('http_client'), - $container->get('os2web_audit.logger'), + $auditLogger, + $keyRepository, + $fileSystem, ); } diff --git a/modules/os2forms_digital_post/README.md b/modules/os2forms_digital_post/README.md index a487b13e..999d87c7 100644 --- a/modules/os2forms_digital_post/README.md +++ b/modules/os2forms_digital_post/README.md @@ -31,6 +31,11 @@ examples](modules/os2forms_digital_post_examples/README.md). Go to `/admin/os2forms_digital_post/settings` to set up global settings for digital post. +### Key + +We use [os2web_key](https://github.com/OS2web/os2web_key) to provide the certificate for sending digital post, and the +key must be of type "[Certificate](https://github.com/os2web/os2web_key?tab=readme-ov-file#certificate)". + ### Queue The actual sending of digital post is handled by jobs in an [Advanced diff --git a/modules/os2forms_digital_post/os2forms_digital_post.info.yml b/modules/os2forms_digital_post/os2forms_digital_post.info.yml index 71a17688..0e1408cc 100644 --- a/modules/os2forms_digital_post/os2forms_digital_post.info.yml +++ b/modules/os2forms_digital_post/os2forms_digital_post.info.yml @@ -7,6 +7,7 @@ dependencies: - 'beskedfordeler:beskedfordeler' - 'drupal:advancedqueue' - 'os2web_datalookup:os2web_datalookup' + - 'os2web_key:os2web_key' - 'webform:webform' - 'webform:webform_submission_log' - 'os2web:os2web_audit' diff --git a/modules/os2forms_digital_post/os2forms_digital_post.install b/modules/os2forms_digital_post/os2forms_digital_post.install index 760743cb..48768e21 100644 --- a/modules/os2forms_digital_post/os2forms_digital_post.install +++ b/modules/os2forms_digital_post/os2forms_digital_post.install @@ -17,3 +17,12 @@ use Drupal\os2forms_digital_post\Helper\BeskedfordelerHelper; function os2forms_digital_post_schema() { return Drupal::service(BeskedfordelerHelper::class)->schema(); } + +/** + * Install OS2Web key module. + */ +function os2forms_digital_post_update_9001(): void { + \Drupal::service('module_installer')->install([ + 'os2web_key', + ], TRUE); +} diff --git a/modules/os2forms_digital_post/os2forms_digital_post.services.yml b/modules/os2forms_digital_post/os2forms_digital_post.services.yml index c13fb96f..745b88d2 100644 --- a/modules/os2forms_digital_post/os2forms_digital_post.services.yml +++ b/modules/os2forms_digital_post/os2forms_digital_post.services.yml @@ -9,7 +9,8 @@ services: Drupal\os2forms_digital_post\Helper\Settings: arguments: - - "@keyvalue" + - "@config.factory" + - "@key.repository" Drupal\os2forms_digital_post\Helper\CertificateLocatorHelper: arguments: @@ -30,11 +31,12 @@ services: Drupal\os2forms_digital_post\Helper\DigitalPostHelper: arguments: - "@Drupal\\os2forms_digital_post\\Helper\\Settings" - - "@Drupal\\os2forms_digital_post\\Helper\\CertificateLocatorHelper" + - "@Drupal\\os2web_key\\KeyHelper" - "@plugin.manager.os2web_datalookup" - "@Drupal\\os2forms_digital_post\\Helper\\MeMoHelper" - "@Drupal\\os2forms_digital_post\\Helper\\ForsendelseHelper" - "@Drupal\\os2forms_digital_post\\Helper\\BeskedfordelerHelper" + - "@Drupal\\os2forms_digital_post\\Helper\\CertificateLocatorHelper" - "@logger.channel.os2forms_digital_post" - "@logger.channel.os2forms_digital_post_submission" - "@os2web_audit.logger" diff --git a/modules/os2forms_digital_post/src/Drush/Commands/DigitalPostTestCommands.php b/modules/os2forms_digital_post/src/Drush/Commands/DigitalPostTestCommands.php index 201581c5..490e0f5a 100644 --- a/modules/os2forms_digital_post/src/Drush/Commands/DigitalPostTestCommands.php +++ b/modules/os2forms_digital_post/src/Drush/Commands/DigitalPostTestCommands.php @@ -19,7 +19,7 @@ /** * Test commands for digital post. */ -class DigitalPostTestCommands extends DrushCommands { +final class DigitalPostTestCommands extends DrushCommands { use AutowireTrait; /** @@ -151,7 +151,9 @@ private function dumpDigitalPostSettings(SymfonyStyle $io): void { Yaml::encode([ 'testMode' => $this->digitalPostSettings->getTestMode(), 'sender' => $this->digitalPostSettings->getSender(), - 'certificate' => $this->digitalPostSettings->getCertificate(), + 'certificate' => [ + 'key' => $this->digitalPostSettings->getKey(), + ], 'processing' => $this->digitalPostSettings->getProcessing(), ]), '', diff --git a/modules/os2forms_digital_post/src/EventSubscriber/BeskedfordelerEventSubscriber.php b/modules/os2forms_digital_post/src/EventSubscriber/BeskedfordelerEventSubscriber.php index cbef5ad4..446dc8f3 100644 --- a/modules/os2forms_digital_post/src/EventSubscriber/BeskedfordelerEventSubscriber.php +++ b/modules/os2forms_digital_post/src/EventSubscriber/BeskedfordelerEventSubscriber.php @@ -8,6 +8,7 @@ use Drupal\os2forms_digital_post\Helper\BeskedfordelerHelper; use Drupal\os2forms_digital_post\Helper\WebformHelperSF1601; use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Event subscriber for PostStatusBeskedModtagEvent. @@ -23,6 +24,7 @@ public function __construct( private readonly BeskedfordelerHelper $beskedfordelerHelper, private readonly MessageHelper $messageHelper, private readonly WebformHelperSF1601 $webformHelper, + #[Autowire(service: 'logger.channel.os2forms_digital_post')] LoggerInterface $logger, ) { parent::__construct($logger); diff --git a/modules/os2forms_digital_post/src/Exception/InvalidSettingException.php b/modules/os2forms_digital_post/src/Exception/InvalidSettingException.php deleted file mode 100644 index c3d34af6..00000000 --- a/modules/os2forms_digital_post/src/Exception/InvalidSettingException.php +++ /dev/null @@ -1,10 +0,0 @@ -queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); } @@ -44,12 +47,23 @@ public function __construct( */ public static function create(ContainerInterface $container) { return new static( + $container->get('config.factory'), + $container->get('entity_type.manager'), $container->get(Settings::class), - $container->get(CertificateLocatorHelper::class), - $container->get('entity_type.manager') ); } + /** + * {@inheritdoc} + * + * @phpstan-return string[] + */ + protected function getEditableConfigNames() { + return [ + Settings::CONFIG_NAME, + ]; + } + /** * {@inheritdoc} */ @@ -63,15 +77,26 @@ public function getFormId() { * @phpstan-param array $form * @phpstan-return array */ - public function buildForm(array $form, FormStateInterface $form_state) { - $form['test_mode'] = [ + public function buildForm(array $form, FormStateInterface $form_state): array { + $form = parent::buildForm($form, $form_state); + + $form['message'] = [ + '#theme' => 'status_messages', + '#message_list' => [ + 'status' => [ + $this->t('Use drush os2forms-digital-post:test:send to test sending digital post.'), + ], + ], + ]; + + $form[Settings::TEST_MODE] = [ '#type' => 'checkbox', '#title' => $this->t('Test mode'), - '#default_value' => $this->settings->getTestMode(), + '#default_value' => $this->settings->getEditableValue(Settings::TEST_MODE), + '#description' => $this->createDescription(Settings::TEST_MODE), ]; - $sender = $this->settings->getSender(); - $form['sender'] = [ + $form[Settings::SENDER] = [ '#type' => 'fieldset', '#title' => $this->t('Sender'), '#tree' => TRUE, @@ -82,126 +107,184 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#options' => [ 'CVR' => $this->t('CVR'), ], - '#default_value' => $sender[Settings::SENDER_IDENTIFIER_TYPE] ?? 'CVR', + '#default_value' => $this->settings->getEditableValue([Settings::SENDER, Settings::SENDER_IDENTIFIER_TYPE]) ?? 'CVR', '#required' => TRUE, + '#description' => $this->createDescription([Settings::SENDER, Settings::SENDER_IDENTIFIER_TYPE]), ], Settings::SENDER_IDENTIFIER => [ '#type' => 'textfield', '#title' => $this->t('Identifier'), - '#default_value' => $sender[Settings::SENDER_IDENTIFIER] ?? NULL, + '#default_value' => $this->settings->getEditableValue([Settings::SENDER, Settings::SENDER_IDENTIFIER]), '#required' => TRUE, + '#description' => $this->createDescription([Settings::SENDER, Settings::SENDER_IDENTIFIER]), ], Settings::FORSENDELSES_TYPE_IDENTIFIKATOR => [ '#type' => 'textfield', '#title' => $this->t('Forsendelsestypeidentifikator'), - '#default_value' => $sender[Settings::FORSENDELSES_TYPE_IDENTIFIKATOR] ?? NULL, + '#default_value' => $this->settings->getEditableValue([ + Settings::SENDER, Settings::FORSENDELSES_TYPE_IDENTIFIKATOR, + ]), '#required' => TRUE, + '#description' => $this->createDescription([Settings::SENDER, Settings::FORSENDELSES_TYPE_IDENTIFIKATOR]), ], ]; - $certificate = $this->settings->getCertificate(); - $form['certificate'] = [ + $form[Settings::CERTIFICATE] = [ '#type' => 'fieldset', '#title' => $this->t('Certificate'), '#tree' => TRUE, + ]; - 'locator_type' => [ - '#type' => 'select', - '#title' => $this->t('Certificate locator type'), - '#options' => [ - 'azure_key_vault' => $this->t('Azure key vault'), - 'file_system' => $this->t('File system'), - ], - '#default_value' => $certificate['locator_type'] ?? NULL, + $form[Settings::CERTIFICATE][Settings::CERTIFICATE_PROVIDER] = [ + '#type' => 'select', + '#title' => $this->t('Provider'), + '#options' => [ + Settings::PROVIDER_TYPE_FORM => $this->t('Form'), + Settings::PROVIDER_TYPE_KEY => $this->t('Key'), ], + '#default_value' => $this->settings->getEditableValue([Settings::CERTIFICATE, Settings::CERTIFICATE_PROVIDER]) ?? Settings::PROVIDER_TYPE_FORM, + '#description' => $this->t('Specifies which provider to use'), ]; - $form['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT] = [ + $form[Settings::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE] = [ + '#type' => 'select', + '#title' => $this->t('Certificate locator type'), + '#options' => [ + CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT => $this->t('Azure key vault'), + CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM => $this->t('File system'), + ], + '#default_value' => $this->settings->getEditableValue([ + Settings::CERTIFICATE, + CertificateLocatorHelper::LOCATOR_TYPE, + ]) ?? NULL, + '#states' => [ + 'visible' => [':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_FORM]], + ], + '#description' => $this->t('Specifies which locator to use'), + ]; + + $form[Settings::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT] = [ '#type' => 'fieldset', '#title' => $this->t('Azure key vault'), '#states' => [ - 'visible' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT]], + 'visible' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT], + ], ], ]; $settings = [ - 'tenant_id' => ['title' => $this->t('Tenant id')], - 'application_id' => ['title' => $this->t('Application id')], - 'client_secret' => ['title' => $this->t('Client secret')], - 'name' => ['title' => $this->t('Name')], - 'secret' => ['title' => $this->t('Secret')], - 'version' => ['title' => $this->t('Version')], + CertificateLocatorHelper::LOCATOR_AZURE_KEY_VAULT_TENANT_ID => ['title' => $this->t('Tenant id')], + CertificateLocatorHelper::LOCATOR_AZURE_KEY_VAULT_APPLICATION_ID => ['title' => $this->t('Application id')], + CertificateLocatorHelper::LOCATOR_AZURE_KEY_VAULT_CLIENT_SECRET => ['title' => $this->t('Client secret')], + CertificateLocatorHelper::LOCATOR_AZURE_KEY_VAULT_NAME => ['title' => $this->t('Name')], + CertificateLocatorHelper::LOCATOR_AZURE_KEY_VAULT_SECRET => ['title' => $this->t('Secret')], + CertificateLocatorHelper::LOCATOR_AZURE_KEY_VAULT_VERSION => ['title' => $this->t('Version')], ]; foreach ($settings as $key => $info) { - $form['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] = [ + $form[Settings::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] = [ '#type' => 'textfield', '#title' => $info['title'], - '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] ?? NULL, + '#default_value' => $this->settings->getEditableValue([ + Settings::CERTIFICATE, + CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT, + $key, + ]) ?? NULL, '#states' => [ - 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT]], + 'required' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT], + ], ], ]; } - $form['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM] = [ + $form[Settings::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM] = [ '#type' => 'fieldset', '#title' => $this->t('File system'), '#states' => [ - 'visible' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]], + 'visible' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM], + ], ], - 'path' => [ + CertificateLocatorHelper::LOCATOR_FILE_SYSTEM_PATH => [ '#type' => 'textfield', '#title' => $this->t('Path'), - '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL, + '#default_value' => $this->settings->getEditableValue([ + Settings::CERTIFICATE, + CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM, + CertificateLocatorHelper::LOCATOR_FILE_SYSTEM_PATH, + ]) ?? NULL, '#states' => [ - 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]], + 'required' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM], + ], ], ], ]; - $form['certificate']['passphrase'] = [ + $form[Settings::CERTIFICATE][CertificateLocatorHelper::LOCATOR_PASSPHRASE] = [ '#type' => 'textfield', '#title' => $this->t('Passphrase'), - '#default_value' => $certificate['passphrase'] ?? NULL, + '#default_value' => $this->settings->getEditableValue([ + Settings::CERTIFICATE, + CertificateLocatorHelper::LOCATOR_PASSPHRASE, + ]) ?? '', + '#states' => [ + 'visible' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_FORM], + ], + ], ]; - $processing = $this->settings->getProcessing(); - $form['processing'] = [ + $form[Settings::CERTIFICATE][Settings::PROVIDER_TYPE_KEY] = [ + '#type' => 'key_select', + '#key_filters' => [ + 'type' => 'os2web_key_certificate', + ], + '#key_description' => FALSE, + '#title' => $this->t('Key'), + '#default_value' => $this->settings->getEditableValue([Settings::CERTIFICATE, Settings::PROVIDER_TYPE_KEY]), + '#description' => $this->createDescription([Settings::CERTIFICATE, Settings::PROVIDER_TYPE_KEY]), + '#states' => [ + 'visible' => [':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_KEY]], + 'required' => [':input[name="certificate[certificate_provider]"]' => ['value' => Settings::PROVIDER_TYPE_KEY]], + ], + ]; + + $form[Settings::PROCESSING] = [ '#type' => 'fieldset', '#title' => $this->t('Processing'), '#tree' => TRUE, ]; - $defaultValue = $processing['queue'] ?? 'os2forms_digital_post'; - $form['processing']['queue'] = [ + $queue = $this->settings->getEditableValue([Settings::PROCESSING, Settings::QUEUE]); + $form[Settings::PROCESSING][Settings::QUEUE] = [ '#type' => 'select', '#title' => $this->t('Queue'), '#options' => array_map( static fn(EntityInterface $queue) => $queue->label(), $this->queueStorage->loadMultiple() ), - '#default_value' => $defaultValue, - '#description' => $this->t("Queue for digital post jobs. The queue must be run via Drupal's cron or via drush advancedqueue:queue:process @queue(in a cron job).", [ - '@queue' => $defaultValue, - ':queue_url' => '/admin/config/system/queues/jobs/' . urlencode($defaultValue), - ]), - ]; - - $form['actions']['#type'] = 'actions'; - - $form['actions']['submit'] = [ - '#type' => 'submit', - '#value' => $this->t('Save settings'), - ]; - - $form['actions']['testCertificate'] = [ - '#type' => 'submit', - '#name' => 'testCertificate', - '#value' => $this->t('Test certificate'), + '#required' => TRUE, + '#default_value' => $queue, + '#description' => $this->createDescription([Settings::PROCESSING, Settings::QUEUE], + $queue + ? $this->t("Queue for digital post jobs. The queue must be run via Drupal's cron or via drush advancedqueue:queue:process @queue (in a cron job).", [ + '@queue' => $queue, + ':queue_url' => Url::fromRoute('view.advancedqueue_jobs.page_1', [ + 'arg_0' => $queue, + ])->toString(TRUE)->getGeneratedUrl(), + ]) + : $this->t("Queue for digital post jobs. The queue must be processed via Drupal's cron or drush advancedqueue:queue:process (in a cron job)."), + ), ]; return $form; @@ -212,19 +295,19 @@ public function buildForm(array $form, FormStateInterface $form_state) { * * @phpstan-param array $form */ - public function validateForm(array &$form, FormStateInterface $formState): void { - $triggeringElement = $formState->getTriggeringElement(); - if ('testCertificate' === ($triggeringElement['#name'] ?? NULL)) { - return; - } - - $values = $formState->getValues(); - if (CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM === $values['certificate']['locator_type']) { - $path = $values['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL; - if (!file_exists($path)) { - $formState->setErrorByName('certificate][file_system][path', $this->t('Invalid certificate path: %path', ['%path' => $path])); + public function validateForm(array &$form, FormStateInterface $form_state): void { + $values = $form_state->getValues(); + + if (Settings::PROVIDER_TYPE_FORM === $values[Settings::CERTIFICATE][Settings::CERTIFICATE_PROVIDER]) { + if (CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM === $values[Settings::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE]) { + $path = $values[Settings::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM][CertificateLocatorHelper::LOCATOR_FILE_SYSTEM_PATH] ?? NULL; + if (!file_exists($path)) { + $form_state->setErrorByName('certificate][file_system][path', $this->t('Invalid certificate path: %path', ['%path' => $path])); + } } } + + parent::validateForm($form, $form_state); } /** @@ -232,39 +315,43 @@ public function validateForm(array &$form, FormStateInterface $formState): void * * @phpstan-param array $form */ - public function submitForm(array &$form, FormStateInterface $formState): void { - $triggeringElement = $formState->getTriggeringElement(); - if ('testCertificate' === ($triggeringElement['#name'] ?? NULL)) { - $this->testCertificate(); - return; + public function submitForm(array &$form, FormStateInterface $form_state): void { + $config = $this->config(Settings::CONFIG_NAME); + foreach ([ + Settings::TEST_MODE, + Settings::SENDER, + Settings::CERTIFICATE, + Settings::PROCESSING, + ] as $key) { + $config->set($key, $form_state->getValue($key)); } + $config->save(); - try { - $settings['test_mode'] = (bool) $formState->getValue('test_mode'); - $settings['sender'] = $formState->getValue('sender'); - $settings['certificate'] = $formState->getValue('certificate'); - $settings['processing'] = $formState->getValue('processing'); - $this->settings->setSettings($settings); - $this->messenger()->addStatus($this->t('Settings saved')); - } - catch (OptionsResolverException $exception) { - $this->messenger()->addError($this->t('Settings not saved (@message)', ['@message' => $exception->getMessage()])); - } + parent::submitForm($form, $form_state); } /** - * Test certificate. + * Create form field description with information on any runtime override. + * + * @param string|array $key + * The key. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $description + * The actual field description. + * + * @return string + * The full description. + * + * @phpstan-param string|string[] $key */ - private function testCertificate(): void { - try { - $certificateLocator = $this->certificateLocatorHelper->getCertificateLocator(); - $certificateLocator->getCertificates(); - $this->messenger()->addStatus($this->t('Certificate succesfully tested')); - } - catch (\Throwable $throwable) { - $message = $this->t('Error testing certificate: %message', ['%message' => $throwable->getMessage()]); - $this->messenger()->addError($message); + private function createDescription(string|array $key, ?TranslatableMarkup $description = NULL): string { + if ($value = $this->settings->getOverride($key)) { + if (!empty($description)) { + $description .= '
'; + } + $description .= $this->t('Note: overridden on runtime with the value @value.', ['@value' => var_export($value['runtime'], TRUE)]); } + + return (string) $description; } } diff --git a/modules/os2forms_digital_post/src/Helper/AbstractMessageHelper.php b/modules/os2forms_digital_post/src/Helper/AbstractMessageHelper.php index d9fb96a3..c2f2b990 100644 --- a/modules/os2forms_digital_post/src/Helper/AbstractMessageHelper.php +++ b/modules/os2forms_digital_post/src/Helper/AbstractMessageHelper.php @@ -12,6 +12,7 @@ use Drupal\webform_attachment\Element\WebformAttachmentBase; use ItkDev\Serviceplatformen\Service\SF1601\Serializer; use Oio\Fjernprint\ForsendelseI; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Abstract message helper. @@ -23,7 +24,9 @@ abstract class AbstractMessageHelper { */ public function __construct( readonly protected Settings $settings, + #[Autowire(service: 'plugin.manager.element_info')] readonly protected ElementInfoManager $elementInfoManager, + #[Autowire(service: 'webform.token_manager')] readonly protected WebformTokenManagerInterface $webformTokenManager, ) { } diff --git a/modules/os2forms_digital_post/src/Helper/BeskedfordelerHelper.php b/modules/os2forms_digital_post/src/Helper/BeskedfordelerHelper.php index 5b93da5e..c256dc17 100644 --- a/modules/os2forms_digital_post/src/Helper/BeskedfordelerHelper.php +++ b/modules/os2forms_digital_post/src/Helper/BeskedfordelerHelper.php @@ -9,6 +9,7 @@ use Drupal\webform\WebformSubmissionInterface; use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Beskedfordeler helper. @@ -24,6 +25,7 @@ class BeskedfordelerHelper { public function __construct( private readonly Connection $database, private readonly MeMoHelper $meMoHelper, + #[Autowire(service: 'logger.channel.os2forms_digital_post')] LoggerInterface $logger, ) { $this->setLogger($logger); diff --git a/modules/os2forms_digital_post/src/Helper/CertificateLocatorHelper.php b/modules/os2forms_digital_post/src/Helper/CertificateLocatorHelper.php index 10e6ac57..01fde400 100644 --- a/modules/os2forms_digital_post/src/Helper/CertificateLocatorHelper.php +++ b/modules/os2forms_digital_post/src/Helper/CertificateLocatorHelper.php @@ -16,8 +16,17 @@ * Certificate locator helper. */ class CertificateLocatorHelper { + public const LOCATOR_TYPE = 'locator_type'; public const LOCATOR_TYPE_AZURE_KEY_VAULT = 'azure_key_vault'; public const LOCATOR_TYPE_FILE_SYSTEM = 'file_system'; + public const LOCATOR_PASSPHRASE = 'passphrase'; + public const LOCATOR_AZURE_KEY_VAULT_TENANT_ID = 'tenant_id'; + public const LOCATOR_AZURE_KEY_VAULT_APPLICATION_ID = 'application_id'; + public const LOCATOR_AZURE_KEY_VAULT_CLIENT_SECRET = 'client_secret'; + public const LOCATOR_AZURE_KEY_VAULT_NAME = 'name'; + public const LOCATOR_AZURE_KEY_VAULT_SECRET = 'secret'; + public const LOCATOR_AZURE_KEY_VAULT_VERSION = 'version'; + public const LOCATOR_FILE_SYSTEM_PATH = 'path'; /** * {@inheritdoc} @@ -31,12 +40,12 @@ public function __construct( * Get certificate locator. */ public function getCertificateLocator(): CertificateLocatorInterface { - $certificateSettings = $this->settings->getCertificate(); + $certificateSettings = $this->settings->getEditableValue(Settings::CERTIFICATE); $locatorType = $certificateSettings['locator_type']; $options = $certificateSettings[$locatorType]; $options += [ - 'passphrase' => $certificateSettings['passphrase'] ?: '', + 'passphrase' => $certificateSettings['passphrase'], ]; if (self::LOCATOR_TYPE_AZURE_KEY_VAULT === $locatorType) { diff --git a/modules/os2forms_digital_post/src/Helper/DigitalPostHelper.php b/modules/os2forms_digital_post/src/Helper/DigitalPostHelper.php index 8681cf35..44198074 100644 --- a/modules/os2forms_digital_post/src/Helper/DigitalPostHelper.php +++ b/modules/os2forms_digital_post/src/Helper/DigitalPostHelper.php @@ -11,12 +11,14 @@ use Drupal\os2web_datalookup\Plugin\DataLookupManager; use Drupal\os2web_datalookup\Plugin\os2web\DataLookup\DataLookupCompanyInterface; use Drupal\os2web_datalookup\Plugin\os2web\DataLookup\DataLookupCprInterface; +use Drupal\os2web_key\KeyHelper; use Drupal\webform\WebformSubmissionInterface; use ItkDev\Serviceplatformen\Service\SF1601\SF1601; use ItkDev\Serviceplatformen\Service\SF1601\Serializer; use Oio\Fjernprint\ForsendelseI; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Webform helper. @@ -29,13 +31,18 @@ final class DigitalPostHelper implements LoggerInterface { */ public function __construct( private readonly Settings $settings, - private readonly CertificateLocatorHelper $certificateLocatorHelper, + private readonly KeyHelper $keyHelper, + #[Autowire(service: 'plugin.manager.os2web_datalookup')] private readonly DataLookupManager $dataLookupManager, private readonly MeMoHelper $meMoHelper, private readonly ForsendelseHelper $forsendelseHelper, private readonly BeskedfordelerHelper $beskedfordelerHelper, + private readonly CertificateLocatorHelper $certificateLocatorHelper, + #[Autowire(service: 'logger.channel.os2forms_digital_post')] private readonly LoggerChannelInterface $logger, + #[Autowire(service: 'logger.channel.os2forms_digital_post_submission')] private readonly LoggerChannelInterface $submissionLogger, + #[Autowire(service: 'os2web_audit.logger')] private readonly Logger $auditLogger, ) { } @@ -59,11 +66,23 @@ public function __construct( */ public function sendDigitalPost(string $type, Message $message, ?ForsendelseI $forsendelse, ?WebformSubmissionInterface $submission = NULL): array { $senderSettings = $this->settings->getSender(); + + if (Settings::PROVIDER_TYPE_FORM === $this->settings->getCertificateProvider()) { + $certificateLocator = $this->certificateLocatorHelper->getCertificateLocator(); + } + else { + $certificateLocator = new KeyCertificateLocator( + $this->settings->getCertificateKey(), + $this->keyHelper + ); + } + $options = [ 'test_mode' => (bool) $this->settings->getTestMode(), 'authority_cvr' => $senderSettings[Settings::SENDER_IDENTIFIER], - 'certificate_locator' => $this->certificateLocatorHelper->getCertificateLocator(), + 'certificate_locator' => $certificateLocator, ]; + $service = new SF1601($options); $transactionId = Serializer::createUuid(); diff --git a/modules/os2forms_digital_post/src/Helper/KeyCertificateLocator.php b/modules/os2forms_digital_post/src/Helper/KeyCertificateLocator.php new file mode 100644 index 00000000..8d8f0a41 --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/KeyCertificateLocator.php @@ -0,0 +1,59 @@ + + */ + private array $certificates; + + /** + * Constructor. + */ + public function __construct( + private readonly KeyInterface $key, + private readonly KeyHelper $keyHelper, + ) { + parent::__construct(); + } + + /** + * {@inheritdoc} + * + * @phpstan-return array + */ + public function getCertificates(): array { + if (!isset($this->certificates)) { + $this->certificates = $this->keyHelper->getCertificates($this->key); + } + + return $this->certificates; + } + + /** + * {@inheritdoc} + */ + public function getCertificate(): string { + return $this->key->getKeyValue(); + } + + /** + * {@inheritdoc} + */ + public function getAbsolutePathToCertificate(): string { + throw new CertificateLocatorException(__METHOD__ . ' should not be used.'); + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/Settings.php b/modules/os2forms_digital_post/src/Helper/Settings.php index e64be738..fc7ab385 100644 --- a/modules/os2forms_digital_post/src/Helper/Settings.php +++ b/modules/os2forms_digital_post/src/Helper/Settings.php @@ -2,45 +2,66 @@ namespace Drupal\os2forms_digital_post\Helper; -use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; -use Drupal\Core\KeyValueStore\KeyValueStoreInterface; -use Drupal\os2forms_digital_post\Exception\InvalidSettingException; -use Symfony\Component\OptionsResolver\OptionsResolver; +use Drupal\Core\Config\Config; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\ImmutableConfig; +use Drupal\key\KeyInterface; +use Drupal\key\KeyRepositoryInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * General settings for os2forms_digital_post. */ final class Settings { + public const CONFIG_NAME = 'os2forms_digital_post.settings'; + + public const TEST_MODE = 'test_mode'; + + public const SENDER = 'sender'; public const SENDER_IDENTIFIER_TYPE = 'sender_identifier_type'; public const SENDER_IDENTIFIER = 'sender_identifier'; public const FORSENDELSES_TYPE_IDENTIFIKATOR = 'forsendelses_type_identifikator'; + public const CERTIFICATE = 'certificate'; + public const KEY = 'key'; + public const CERTIFICATE_PROVIDER = 'certificate_provider'; + public const PROVIDER_TYPE_FORM = 'form'; + public const PROVIDER_TYPE_KEY = 'key'; + + public const PROCESSING = 'processing'; + public const QUEUE = 'queue'; + /** - * The store. + * The runtime (immutable) config. * - * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface + * @var \Drupal\Core\Config\ImmutableConfig */ - private KeyValueStoreInterface $store; + private ImmutableConfig $runtimeConfig; /** - * The key prefix. + * The (mutable) config. * - * @var string + * @var \Drupal\Core\Config\Config */ - private $collection = 'os2forms_digital_post.'; + private Config $editableConfig; /** - * Constructor. + * The constructor. */ - public function __construct(KeyValueFactoryInterface $keyValueFactory) { - $this->store = $keyValueFactory->get($this->collection); + public function __construct( + ConfigFactoryInterface $configFactory, + #[Autowire(service: 'key.repository')] + private readonly KeyRepositoryInterface $keyRepository, + ) { + $this->runtimeConfig = $configFactory->get(self::CONFIG_NAME); + $this->editableConfig = $configFactory->getEditable(self::CONFIG_NAME); } /** * Get test mode. */ public function getTestMode(): bool { - return (bool) $this->get('test_mode', TRUE); + return (bool) $this->get(self::TEST_MODE, TRUE); } /** @@ -49,18 +70,32 @@ public function getTestMode(): bool { * @phpstan-return array */ public function getSender(): array { - $value = $this->get('sender'); + $value = $this->get(self::SENDER); + return is_array($value) ? $value : []; } + /** + * Get certificate provider. + */ + public function getCertificateProvider(): ?string { + return $this->get([self::CERTIFICATE, self::CERTIFICATE_PROVIDER]); + } + + /** + * Get key. + */ + public function getKey(): ?string { + return $this->get([self::CERTIFICATE, self::KEY]); + } + /** * Get certificate. - * - * @phpstan-return array */ - public function getCertificate(): array { - $value = $this->get('certificate'); - return is_array($value) ? $value : []; + public function getCertificateKey(): ?KeyInterface { + return $this->keyRepository->getKey( + $this->getKey(), + ); } /** @@ -69,57 +104,82 @@ public function getCertificate(): array { * @phpstan-return array */ public function getProcessing(): array { - $value = $this->get('processing'); + $value = $this->get(self::PROCESSING); + return is_array($value) ? $value : []; } /** - * Get a setting value. + * Get editable value. * - * @param string $key + * @param string|array $key * The key. - * @param mixed|null $default - * The default value. * * @return mixed - * The setting value. + * The editable value. */ - private function get(string $key, $default = NULL) { - $resolver = $this->getSettingsResolver(); - if (!$resolver->isDefined($key)) { - throw new InvalidSettingException(sprintf('Setting %s is not defined', $key)); + public function getEditableValue(string|array $key): mixed { + if (is_array($key)) { + $key = implode('.', $key); } - - return $this->store->get($key, $default); + return $this->editableConfig->get($key); } /** - * Set settings. + * Get runtime value override if any. * - * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface + * @param string|array $key + * The key. * - * @phpstan-param array $settings + * @return array|null + * - 'runtime': the runtime value + * - 'editable': the editable (raw) value */ - public function setSettings(array $settings): self { - $settings = $this->getSettingsResolver()->resolve($settings); - foreach ($settings as $key => $value) { - $this->store->set($key, $value); + public function getOverride(string|array $key): ?array { + $runtimeValue = $this->getRuntimeValue($key); + $editableValue = $this->getEditableValue($key); + + // Note: We deliberately use "Equal" (==) rather than "Identical" (===) + // to compare values (cf. https://www.php.net/manual/en/language.operators.comparison.php#language.operators.comparison). + if ($runtimeValue == $editableValue) { + return NULL; } - return $this; + return [ + 'runtime' => $runtimeValue, + 'editable' => $editableValue, + ]; } /** - * Get settings resolver. + * Get a setting value. + * + * @param string|array $key + * The key. + * @param mixed $default + * The default value. + * + * @return mixed + * The setting value. */ - private function getSettingsResolver(): OptionsResolver { - return (new OptionsResolver()) - ->setDefaults([ - 'test_mode' => TRUE, - 'sender' => [], - 'certificate' => [], - 'processing' => [], - ]); + private function get(string|array $key, mixed $default = NULL) { + return $this->getRuntimeValue($key) ?? $default; + } + + /** + * Get runtime value with any overrides applied. + * + * @param string|array $key + * The key. + * + * @return mixed + * The runtime value. + */ + public function getRuntimeValue(string|array $key): mixed { + if (is_array($key)) { + $key = implode('.', $key); + } + return $this->runtimeConfig->get($key); } } diff --git a/modules/os2forms_digital_post/src/Helper/WebformHelperSF1601.php b/modules/os2forms_digital_post/src/Helper/WebformHelperSF1601.php index 3c2c724e..60fae6a2 100644 --- a/modules/os2forms_digital_post/src/Helper/WebformHelperSF1601.php +++ b/modules/os2forms_digital_post/src/Helper/WebformHelperSF1601.php @@ -21,6 +21,7 @@ use ItkDev\Serviceplatformen\Service\SF1601\SF1601; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Webform helper. @@ -51,11 +52,14 @@ final class WebformHelperSF1601 implements LoggerInterface { public function __construct( private readonly Settings $settings, EntityTypeManagerInterface $entityTypeManager, + #[Autowire(service: 'plugin.manager.os2web_datalookup')] private readonly DataLookupManager $dataLookupManager, private readonly MeMoHelper $meMoHelper, private readonly ForsendelseHelper $forsendelseHelper, private readonly BeskedfordelerHelper $beskedfordelerHelper, + #[Autowire(service: 'logger.channel.os2forms_digital_post')] private readonly LoggerChannelInterface $logger, + #[Autowire(service: 'logger.channel.os2forms_digital_post_submission')] private readonly LoggerChannelInterface $submissionLogger, private readonly DigitalPostHelper $digitalPostHelper, ) { diff --git a/modules/os2forms_digital_signature/README.md b/modules/os2forms_digital_signature/README.md new file mode 100644 index 00000000..242fa541 --- /dev/null +++ b/modules/os2forms_digital_signature/README.md @@ -0,0 +1,40 @@ +# OS2Forms Digital Signature module + +## Module purpose + +This module provides functionality for adding digital signature to the webform PDF submissions. + +## How does it work + +### Activating Digital Signature + +1. Add the OS2forms attachment element to the form. +2. Indicate that the OS2Forms attachment requires a digital signature. +3. Add the Digital Signature Handler to the webform. +4. If the form requires an email handler, ensure the trigger is set to **...when submission is locked** in the handler’s +*Additional settings*. + +### Flow Explained + +1. Upon form submission, a PDF is generated, saved in the private directory, and sent to the signature service via URL. +2. The user is redirected to the signature service to provide their signature. +3. After signing, the user is redirected back to the webform solution. +4. The signed PDF is downloaded and stored in Drupal’s private directory. +5. When a submission PDF is requested (e.g., via download link or email), the signed PDF is served instead of generating +a new one on the fly. + +## Settings page + +URL: `admin/os2forms_digital_signature/settings` + +- **Signature server URL** + + The URL of the service providing digital signature. This is the example of a known service [https://signering.bellcom.dk/sign.php?](https://signering.bellcom.dk/sign.php?) + +- **Hash Salt used for signature** + + Must match hash salt on the signature server + +- **List IPs which can download unsigned PDF submissions** + + Only requests from this IP will be able to download PDF which are to be signed. diff --git a/modules/os2forms_digital_signature/os2forms_digital_signature.info.yml b/modules/os2forms_digital_signature/os2forms_digital_signature.info.yml new file mode 100644 index 00000000..29547e43 --- /dev/null +++ b/modules/os2forms_digital_signature/os2forms_digital_signature.info.yml @@ -0,0 +1,9 @@ +name: 'OS2Forms Digital Signature' +type: module +description: 'Provides digital signature functionality' +package: 'OS2Forms' +core_version_requirement: ^9 || ^10 +dependencies: + - 'webform:webform' + +configure: os2forms_digital_signature.settings diff --git a/modules/os2forms_digital_signature/os2forms_digital_signature.links.menu.yml b/modules/os2forms_digital_signature/os2forms_digital_signature.links.menu.yml new file mode 100644 index 00000000..2fc07d22 --- /dev/null +++ b/modules/os2forms_digital_signature/os2forms_digital_signature.links.menu.yml @@ -0,0 +1,5 @@ +os2forms_digital_signature.admin.settings: + title: OS2Forms digital signature + description: Configure the OS2Forms digital signature module + parent: system.admin_config_system + route_name: os2forms_digital_signature.settings diff --git a/modules/os2forms_digital_signature/os2forms_digital_signature.module b/modules/os2forms_digital_signature/os2forms_digital_signature.module new file mode 100644 index 00000000..6e12a210 --- /dev/null +++ b/modules/os2forms_digital_signature/os2forms_digital_signature.module @@ -0,0 +1,77 @@ +deleteStalledSubmissions(); +} + +/** + * Implements hook_webform_submission_form_alter(). + * + * Replaces submit button title, if digital signature present. + */ +function os2forms_digital_signature_webform_submission_form_alter(array &$form, FormStateInterface $form_state, $form_id) { + /** @var \Drupal\webform\WebformSubmissionInterface Interface $webformSubmission */ + $webformSubmission = $form_state->getFormObject()->getEntity(); + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $webformSubmission->getWebform(); + + // Checking for os2forms_digital_signature handler presence. + foreach ($webform->getHandlers()->getConfiguration() as $handlerConf) { + if ($handlerConf['id'] == 'os2forms_digital_signature') { + $config = \Drupal::config('webform.settings'); + $settings = $config->get('settings'); + + // Checking if the title has not been overridden. + if ($settings['default_submit_button_label'] == $form['actions']['submit']['#value']) { + $form['actions']['submit']['#value'] = t('Sign and submit'); + } + } + } +} + +/** + * Implements hook_file_download(). + * + * Custom access control for private files. + */ +function os2forms_digital_signature_file_download($uri) { + // Only operate on files in the private directory. + if (StreamWrapperManager::getScheme($uri) === 'private' && str_starts_with(StreamWrapperManager::getTarget($uri), 'signing/')) { + // Get allowed IPs settings. + $config = \Drupal::config(SettingsForm::$configName); + $allowedIps = $config->get('os2forms_digital_signature_submission_allowed_ips'); + + $allowedIpsArr = explode(',', $allowedIps); + $remoteIp = Drupal::request()->getClientIp(); + + // IP list is empty, or request IP is allowed. + if (empty($allowedIpsArr) || in_array($remoteIp, $allowedIpsArr)) { + $basename = basename($uri); + return [ + 'Content-disposition' => 'attachment; filename="' . $basename . '"', + ]; + } + + // Otherwise - Deny access. + return -1; + } + + // Not submission file, allow normal access. + return NULL; +} diff --git a/modules/os2forms_digital_signature/os2forms_digital_signature.routing.yml b/modules/os2forms_digital_signature/os2forms_digital_signature.routing.yml new file mode 100644 index 00000000..41dbc321 --- /dev/null +++ b/modules/os2forms_digital_signature/os2forms_digital_signature.routing.yml @@ -0,0 +1,15 @@ +# Webform os2forms_attachment_component routes. +os2forms_digital_signature.sign_callback: + path: '/os2forms_digital_signature/{uuid}/{hash}/sign_callback/{fid}' + defaults: + _controller: '\Drupal\os2forms_digital_signature\Controller\DigitalSignatureController::signCallback' + fid: '' + requirements: + _permission: 'access content' +os2forms_digital_signature.settings: + path: '/admin/os2forms_digital_signature/settings' + defaults: + _form: '\Drupal\os2forms_digital_signature\Form\SettingsForm' + _title: 'Digital signature settings' + requirements: + _permission: 'administer site configuration' diff --git a/modules/os2forms_digital_signature/os2forms_digital_signature.services.yml b/modules/os2forms_digital_signature/os2forms_digital_signature.services.yml new file mode 100644 index 00000000..33848830 --- /dev/null +++ b/modules/os2forms_digital_signature/os2forms_digital_signature.services.yml @@ -0,0 +1,13 @@ +services: + logger.channel.os2forms_digital_signature: + parent: logger.channel_base + arguments: [ 'os2forms_digital_signature' ] + + os2forms_digital_signature.signing_service: + class: Drupal\os2forms_digital_signature\Service\SigningService + arguments: + - '@http_client' + - '@datetime.time' + - '@config.factory' + - '@entity_type.manager' + - '@logger.channel.os2forms_digital_signature' diff --git a/modules/os2forms_digital_signature/src/Controller/DigitalSignatureController.php b/modules/os2forms_digital_signature/src/Controller/DigitalSignatureController.php new file mode 100644 index 00000000..656bf7d4 --- /dev/null +++ b/modules/os2forms_digital_signature/src/Controller/DigitalSignatureController.php @@ -0,0 +1,166 @@ +fileStorage = $this->entityTypeManager()->getStorage('file'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('logger.channel.os2forms_digital_signature'), + $container->get('settings'), + $container->get('os2forms_digital_signature.signing_service'), + $container->get('file_system'), + $container->get('request_stack'), + ); + } + + /** + * Callback for the file being signed. + * + * Expecting the file name to be coming as GET parameter. + * + * @param string $uuid + * Webform submission UUID. + * @param string $hash + * Hash to check if the request is authentic. + * @param int|null $fid + * File to replace (optional). + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * Redirect response to form submission confirmation. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function signCallback($uuid, $hash, $fid = NULL) { + // Load the webform submission entity by UUID. + $submissions = $this->entityTypeManager() + ->getStorage('webform_submission') + ->loadByProperties(['uuid' => $uuid]); + + // Since loadByProperties returns an array, we need to fetch the first item. + /** @var \Drupal\webform\WebformSubmissionInterface $webformSubmission */ + $webformSubmission = $submissions ? reset($submissions) : NULL; + if (!$webformSubmission) { + // Submission does not exist. + throw new NotFoundHttpException(); + } + + $webformId = $webformSubmission->getWebform()->id(); + + // Checking the action. + $request = $this->requestStack->getCurrentRequest(); + + $action = $request->query->get('action'); + if ($action == 'cancel') { + $cancelUrl = $webformSubmission->getWebform()->toUrl()->toString(); + + // Redirect to the webform confirmation page. + $response = new RedirectResponse($cancelUrl); + return $response; + } + + // Checking hash. + $salt = $this->settings->get('hash_salt'); + $tmpHash = Crypt::hashBase64($uuid . $webformId . $salt); + if ($hash !== $tmpHash) { + // Submission exist, but the provided hash is incorrect. + throw new NotFoundHttpException(); + } + + $signedFilename = $request->get('file'); + $signedFileContent = $this->signingService->download($signedFilename); + if (!$signedFileContent) { + $this->logger->warning('Missing file on remote server %file.', ['%file' => $signedFilename]); + throw new NotFoundHttpException(); + } + + // If $fid is present - we are replacing uploaded/managed file, otherwise + // creating a new one. + if ($fid) { + $file = $this->fileStorage->load($fid); + $expectedFileUri = $file->getFileUri(); + } + else { + // Prepare the directory to ensure it exists and is writable. + $expectedFileUri = "private://webform/$webformId/digital_signature/$uuid.pdf"; + $directory = dirname($expectedFileUri); + + if (!$this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY)) { + $this->logger->error('Failed to prepare directory %directory.', ['%directory' => $directory]); + } + } + + // Write the data to the file using Drupal's file system service. + try { + $this->fileSystem->saveData($signedFileContent, $expectedFileUri, FileExists::Replace); + + // Updating webform submission. + $webformSubmission->setLocked(TRUE); + $webformSubmission->save(); + + // If file existing, resave the file to update the size and etc. + if ($fid) { + $this->fileStorage->load($fid)?->save(); + } + } + catch (\Exception $e) { + $this->logger->error('Failed to write to file %uri: @message', + [ + '%uri' => $expectedFileUri, + '@message' => $e->getMessage(), + ]); + } + + // Build the URL for the webform submission confirmation page. + $confirmation_url = Url::fromRoute('entity.webform.confirmation', [ + 'webform' => $webformId, + 'webform_submission' => $webformSubmission->id(), + ])->toString(); + + // Redirect to the webform confirmation page. + $response = new RedirectResponse($confirmation_url); + return $response; + } + +} diff --git a/modules/os2forms_digital_signature/src/Element/DigitalSignatureDocument.php b/modules/os2forms_digital_signature/src/Element/DigitalSignatureDocument.php new file mode 100644 index 00000000..7ac75c97 --- /dev/null +++ b/modules/os2forms_digital_signature/src/Element/DigitalSignatureDocument.php @@ -0,0 +1,19 @@ + 'textfield', + '#title' => $this->t('Signature server URL'), + '#default_value' => $this->config(self::$configName)->get('os2forms_digital_signature_remote_service_url'), + '#description' => $this->t('E.g. https://signering.bellcom.dk/sign.php?'), + ]; + $form['os2forms_digital_signature_sign_hash_salt'] = [ + '#type' => 'textfield', + '#title' => $this->t('Hash Salt used for signature'), + '#default_value' => $this->config(self::$configName)->get('os2forms_digital_signature_sign_hash_salt'), + '#description' => $this->t('Must match hash salt on the signature server'), + ]; + $form['os2forms_digital_signature_submission_allowed_ips'] = [ + '#type' => 'textfield', + '#title' => $this->t('List IPs which can download unsigned PDF submissions'), + '#default_value' => $this->config(self::$configName)->get('os2forms_digital_signature_submission_allowed_ips'), + '#description' => $this->t('Comma separated. e.g. 192.168.1.1,192.168.2.1'), + ]; + $form['os2forms_digital_signature_submission_retention_period'] = [ + '#type' => 'textfield', + '#title' => $this->t('Unsigned submission timespan (s)'), + '#default_value' => ($this->config(self::$configName)->get('os2forms_digital_signature_submission_retention_period')) ?? 300, + '#description' => $this->t('How many seconds can unsigned submission exist before being automatically deleted'), + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $values = $form_state->getValues(); + + $config = $this->config(self::$configName); + foreach ($values as $key => $value) { + $config->set($key, $value); + } + $config->save(); + + parent::submitForm($form, $form_state); + } + +} diff --git a/modules/os2forms_digital_signature/src/Plugin/WebformElement/DigitalSignatureDocument.php b/modules/os2forms_digital_signature/src/Plugin/WebformElement/DigitalSignatureDocument.php new file mode 100644 index 00000000..dcf727d8 --- /dev/null +++ b/modules/os2forms_digital_signature/src/Plugin/WebformElement/DigitalSignatureDocument.php @@ -0,0 +1,82 @@ +t('PDF file for signature'); + return $formats; + } + + /** + * {@inheritdoc} + */ + protected function getFileExtensions(?array $element = NULL) { + return 'pdf'; + } + + /** + * {@inheritdoc} + */ + protected function formatHtmlItem(array $element, WebformSubmissionInterface $webform_submission, array $options = []) { + $value = $this->getValue($element, $webform_submission, $options); + $file = $this->getFile($element, $value, $options); + + if (empty($file)) { + return ''; + } + + $format = $this->getItemFormat($element); + switch ($format) { + case 'basename': + case 'extension': + case 'data': + case 'id': + case 'mime': + case 'name': + case 'raw': + case 'size': + case 'url': + case 'value': + return $this->formatTextItem($element, $webform_submission, $options); + + case 'link': + return [ + '#theme' => 'file_link', + '#file' => $file, + ]; + + default: + return [ + '#theme' => 'webform_element_document_file', + '#element' => $element, + '#value' => $value, + '#options' => $options, + '#file' => $file, + ]; + } + } + +} diff --git a/modules/os2forms_digital_signature/src/Plugin/WebformHandler/DigitalSignatureWebformHandler.php b/modules/os2forms_digital_signature/src/Plugin/WebformHandler/DigitalSignatureWebformHandler.php new file mode 100644 index 00000000..9a616bfc --- /dev/null +++ b/modules/os2forms_digital_signature/src/Plugin/WebformHandler/DigitalSignatureWebformHandler.php @@ -0,0 +1,247 @@ +moduleHandler = $container->get('module_handler'); + $instance->elementManager = $container->get('plugin.manager.webform.element'); + $instance->logger = $container->get('logger.channel.os2forms_digital_signature'); + $instance->fileSystem = $container->get('file_system'); + $instance->fileRepository = $container->get('file.repository'); + $instance->fileUrlGenerator = $container->get('file_url_generator'); + $instance->signingService = $container->get('os2forms_digital_signature.signing_service'); + $instance->settings = $container->get('settings'); + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function preSave(WebformSubmissionInterface $webform_submission) { + $webform = $webform_submission->getWebform(); + + if ($webform_submission->isLocked()) { + return; + } + + $attachment = $this->getSubmissionAttachment($webform_submission); + if (!$attachment) { + $this->logger->error('Attachment cannot be created webform: %webform, webform_submission: %webform_submission', + [ + '%webform' => $webform->id(), + '%webform_submission' => $webform_submission->uuid(), + ] + ); + return; + } + + $destinationDir = 'private://signing'; + if (!$this->fileSystem->prepareDirectory($destinationDir, FileSystemInterface::CREATE_DIRECTORY)) { + $this->logger->error('File directory cannot be created: %filedirectory', ['%filedirectory' => $destinationDir]); + return; + } + + $fileUri = $destinationDir . '/' . $webform_submission->uuid() . '.pdf'; + + // Save the file data. + try { + $fileToSign = $this->fileRepository->writeData($attachment['filecontent'], $fileUri, FileExists::Replace); + } + catch (\Exception $e) { + $this->logger->error('File cannot be saved: %fileUri, error: %error', + [ + '%fileUri' => $fileUri, + '%error' => $e->getMessage(), + ]); + return; + } + + $fileToSign->save(); + $fileToSignPublicUrl = $this->fileUrlGenerator->generateAbsoluteString($fileToSign->getFileUri()); + + $cid = $this->signingService->getCid(); + if (empty($cid)) { + $this->logger->error('Failed to obtain cid. Is server running?'); + return; + } + + // Creating hash. + $salt = $this->settings->get('hash_salt'); + $hash = Crypt::hashBase64($webform_submission->uuid() . $webform->id() . $salt); + + $attachmentFid = $attachment['fid'] ?? NULL; + $signatureCallbackUrl = Url::fromRoute('os2forms_digital_signature.sign_callback', + [ + 'uuid' => $webform_submission->uuid(), + 'hash' => $hash, + 'fid' => $attachmentFid, + ] + ); + + // Starting signing, if everything is correct - this funcition will start + // redirect. + $this->signingService->sign($fileToSignPublicUrl, $cid, $signatureCallbackUrl->setAbsolute()->toString()); + } + + /** + * Get OS2forms file attachment. + * + * @param \Drupal\webform\WebformSubmissionInterface $webform_submission + * A webform submission. + * + * @return array|null + * Array of attachment data. + * + * @throws \Exception + */ + protected function getSubmissionAttachment(WebformSubmissionInterface $webform_submission) { + $attachments = NULL; + $attachment = NULL; + + // Getting all element types that are added to the webform. + // + // Priority is the following: check for os2forms_digital_signature_document, + // is not found try serving os2forms_attachment. + $elementTypes = array_column($this->getWebform()->getElementsDecodedAndFlattened(), '#type'); + $attachmentType = ''; + if (in_array('os2forms_digital_signature_document', $elementTypes)) { + $attachmentType = 'os2forms_digital_signature_document'; + } + elseif (in_array('os2forms_attachment', $elementTypes)) { + $attachmentType = 'os2forms_attachment'; + } + + $elements = $this->getWebform()->getElementsInitializedAndFlattened(); + $element_attachments = $this->getWebform()->getElementsAttachments(); + foreach ($element_attachments as $element_attachment) { + // Check if the element attachment key is excluded and should not attach + // any files. + if (isset($this->configuration['excluded_elements'][$element_attachment])) { + continue; + } + + $element = $elements[$element_attachment]; + + if ($element['#type'] == $attachmentType) { + /** @var \Drupal\webform\Plugin\WebformElementAttachmentInterface $element_plugin */ + $element_plugin = $this->elementManager->getElementInstance($element); + $attachments = $element_plugin->getEmailAttachments($element, $webform_submission); + + // If we are dealing with an uploaded file, attach the FID. + if ($fid = $webform_submission->getElementData($element_attachment)) { + $attachments[0]['fid'] = $fid; + } + break; + } + } + + if (!empty($attachments)) { + $attachment = reset($attachments); + } + + // For SwiftMailer && Mime Mail use filecontent and not the filepath. + // @see \Drupal\swiftmailer\Plugin\Mail\SwiftMailer::attachAsMimeMail + // @see \Drupal\mimemail\Utility\MimeMailFormatHelper::mimeMailFile + // @see https://www.drupal.org/project/webform/issues/3232756 + if ($this->moduleHandler->moduleExists('swiftmailer') + || $this->moduleHandler->moduleExists('mimemail')) { + if (isset($attachment['filecontent']) && isset($attachment['filepath'])) { + unset($attachment['filepath']); + } + } + + return $attachment; + } + +} diff --git a/modules/os2forms_digital_signature/src/Service/SigningService.php b/modules/os2forms_digital_signature/src/Service/SigningService.php new file mode 100644 index 00000000..47505243 --- /dev/null +++ b/modules/os2forms_digital_signature/src/Service/SigningService.php @@ -0,0 +1,259 @@ +config = $configFactory->get(SettingsForm::$configName); + $this->webformStorage = $entityTypeManager->getStorage('webform'); + $this->webformSubmissionStorage = $entityTypeManager->getStorage('webform_submission'); + } + + /** + * Fetch a new cid. + * + * @return string|null + * The correlation id. + */ + public function getCid() : ?string { + $url = $this->getServiceUrl() . http_build_query(['action' => 'getcid']); + $response = $this->httpClient->request('GET', $url); + $result = $response->getBody()->getContents(); + + $reply = json_decode($result, JSON_OBJECT_AS_ARRAY); + + return $reply['cid'] ?? NULL; + } + + /** + * Sign the document. + * + * Signing is done by redirecting the user's browser to a url on the signing + * server that takes the user through the signing flow. + * + * This function will never return. + * + * @param string $document_uri + * A uri to a file on the local server that we want to sign or the file name + * on the signing server in the SIGN_PDF_UPLOAD_DIR. + * In case of a local file, it must be prefixed by 'http://' or 'https://' + * and be readable from the signing server. + * @param string $cid + * The cid made available by the getCid() function. + * @param string $forward_url + * The url on the local server to forward user to afterwards. + */ + public function sign(string $document_uri, string $cid, string $forward_url):void { + if (empty($document_uri) || empty($cid) || empty($forward_url)) { + $this->logger->error('Cannot initiate signing process, check params: document_uri: %document_uri, cid: %cid, forward_url: %forward_url', + [ + '%document_uri' => $document_uri, + '%cid' => $cid, + '%forward_url' => $forward_url, + ] + ); + return; + } + + $hash = $this->getHash($forward_url); + $params = [ + 'action' => 'sign', + 'cid' => $cid, + 'hash' => $hash, + 'uri' => base64_encode($document_uri), + 'forward_url' => base64_encode($forward_url), + ]; + $url = $this->getServiceUrl() . http_build_query($params); + + $response = new RedirectResponse($url); + $response->send(); + } + + /** + * Download the pdf file and return it as a binary string. + * + * @param string $filename + * The filename as given by the signing server. + * @param bool $leave + * If TRUE, leave the file on the remote server, default is to remove the + * file after download. + * @param bool $annotate + * If TRUE, download a pdf with an annotation page. + * @param array $attributes + * An array of pairs of prompts and values that will be added to the + * annotation box, e.g. + * [ + * 'IP' => $_SERVER['REMOTE_ADDR'], + * 'Region' => 'Capital Region Copenhagen' + * ]. + * + * @return mixed|bool + * The binary data of the pdf or FALSE if an error occurred. + */ + public function download(string $filename, $leave = FALSE, $annotate = FALSE, $attributes = []) { + if (empty($filename)) { + return FALSE; + } + if (!preg_match('/^[a-f0-9]{32}\.pdf$/', $filename)) { + return FALSE; + } + $params = [ + 'action' => 'download', + 'file' => $filename, + 'leave' => $leave, + 'annotate' => $annotate, + 'attributes' => $attributes, + ]; + $url = $this->getServiceUrl() . http_build_query($params); + + $response = $this->httpClient->request('GET', $url); + $return = $response->getBody()->getContents(); + + if (empty($return)) { + return FALSE; + } + elseif (substr($return, 0, 5) !== '%PDF-') { + return FALSE; + } + + return $return; + } + + /** + * Calculate the hash value. + * + * @param string $value + * The value to hash including salt. + * + * @return string + * The hash value (sha1). + */ + private function getHash(string $value) : string { + $hashSalt = $this->config->get('os2forms_digital_signature_sign_hash_salt'); + return sha1($hashSalt . $value); + } + + /** + * Deletes stalled webform submissions that were left unsigned. + * + * Only checked the webforms that have digital_signature handler enabled and + * the submission is older that a specified period. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function deleteStalledSubmissions() : void { + $digitalSignatureWebforms = []; + + // Finding webforms that have any handler. + $query = $this->webformStorage->getQuery() + ->exists('handlers'); + $handler_webform_ids = $query->execute(); + + // No webforms with handlers, aborting. + if (empty($handler_webform_ids)) { + return; + } + + // Find all with os2forms_digital_signature handlers enabled. + foreach ($handler_webform_ids as $webform_id) { + $webform = $this->webformStorage->load($webform_id); + if (!$webform) { + continue; + } + + $handlers = $webform->getHandlers(); + foreach ($handlers as $handler) { + // Check if the handler is of type 'os2forms_digital_signature'. + if ($handler->getPluginId() === 'os2forms_digital_signature' && $handler->isEnabled()) { + $digitalSignatureWebforms[] = $webform->id(); + break; + } + } + } + + // No webforms, aborting. + if (empty($digitalSignatureWebforms)) { + return; + } + + // Find all stalled webform submissions of digital signature forms. + $retention_period = ($this->config->get('os2forms_digital_signature_submission_retention_period')) ?? 300; + $timestamp_threshold = $this->time->getRequestTime() - $retention_period; + $query = $this->webformSubmissionStorage->getQuery() + ->accessCheck(FALSE) + ->condition('webform_id', $digitalSignatureWebforms, 'IN') + ->condition('locked', 0) + ->condition('created', $timestamp_threshold, '<'); + $submission_ids = $query->execute(); + + // No submissions, aborting. + if (empty($submission_ids)) { + return; + } + + // Deleting all stalled webform submissions. + foreach ($submission_ids as $submission_id) { + $submission = $this->webformSubmissionStorage->load($submission_id); + $submission->delete(); + } + } + + /** + * Returns Remote signature service URL. + * + * @return string + * Remote Service URL, if missing '?' or '&', '?' will be added + * automatically. + */ + public function getServiceUrl() : string { + $url = $this->config->get('os2forms_digital_signature_remote_service_url'); + // Handling URL, if it does not end with '?' or '&'. + if (!str_ends_with($url, '?') && !str_ends_with($url, '&')) { + return $url . '?'; + } + + return $url; + } + +} diff --git a/modules/os2forms_fasit/os2forms_fasit.info.yml b/modules/os2forms_fasit/os2forms_fasit.info.yml index 43309b4d..fe55c94a 100644 --- a/modules/os2forms_fasit/os2forms_fasit.info.yml +++ b/modules/os2forms_fasit/os2forms_fasit.info.yml @@ -6,6 +6,7 @@ core_version_requirement: ^9 || ^10 dependencies: - drupal:webform - drupal:advancedqueue + - key:key - os2forms:os2forms_attachment - os2web:os2web_audit configure: os2forms_fasit.admin.settings diff --git a/modules/os2forms_fasit/os2forms_fasit.install b/modules/os2forms_fasit/os2forms_fasit.install new file mode 100644 index 00000000..f862bafb --- /dev/null +++ b/modules/os2forms_fasit/os2forms_fasit.install @@ -0,0 +1,15 @@ +install([ + 'key', + ], TRUE); +} diff --git a/modules/os2forms_fasit/os2forms_fasit.services.yml b/modules/os2forms_fasit/os2forms_fasit.services.yml index 4397c83f..e70008fb 100644 --- a/modules/os2forms_fasit/os2forms_fasit.services.yml +++ b/modules/os2forms_fasit/os2forms_fasit.services.yml @@ -1,16 +1,13 @@ services: Drupal\os2forms_fasit\Helper\Settings: + autowire: true arguments: - - "@keyvalue" + $keyRepository: "@key.repository" Drupal\os2forms_fasit\Helper\CertificateLocatorHelper: - arguments: - - "@Drupal\\os2forms_fasit\\Helper\\Settings" + autowire: true Drupal\os2forms_fasit\Helper\FasitHelper: + autowire: true arguments: - - '@http_client' - - '@entity_type.manager' - - "@Drupal\\os2forms_fasit\\Helper\\Settings" - - "@Drupal\\os2forms_fasit\\Helper\\CertificateLocatorHelper" - - "@os2web_audit.logger" + $auditLogger: "@os2web_audit.logger" diff --git a/modules/os2forms_fasit/src/Drush/Commands/FasitTestCommand.php b/modules/os2forms_fasit/src/Drush/Commands/FasitTestCommand.php new file mode 100644 index 00000000..9d1fb281 --- /dev/null +++ b/modules/os2forms_fasit/src/Drush/Commands/FasitTestCommand.php @@ -0,0 +1,41 @@ +helper->pingApi(); + $this->io()->success('Successfully connected to Fasit API'); + } + catch (\Throwable $t) { + $this->io()->error($t->getMessage()); + } + + } + +} diff --git a/modules/os2forms_fasit/src/Form/SettingsForm.php b/modules/os2forms_fasit/src/Form/SettingsForm.php index d0fe119e..2cfed651 100644 --- a/modules/os2forms_fasit/src/Form/SettingsForm.php +++ b/modules/os2forms_fasit/src/Form/SettingsForm.php @@ -2,41 +2,65 @@ namespace Drupal\os2forms_fasit\Form; -use Drupal\Core\Form\FormBase; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\os2forms_fasit\Helper\CertificateLocatorHelper; -use Drupal\os2forms_fasit\Helper\Settings; +use Drupal\os2forms_fasit\Helper\FasitHelper; use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\OptionsResolver\Exception\ExceptionInterface as OptionsResolverException; /** - * Organisation settings form. + * Fasit settings form. */ -final class SettingsForm extends FormBase { +final class SettingsForm extends ConfigFormBase { use StringTranslationTrait; + public const CONFIG_NAME = 'os2forms_fasit.settings'; public const FASIT_API_BASE_URL = 'fasit_api_base_url'; public const FASIT_API_TENANT = 'fasit_api_tenant'; public const FASIT_API_VERSION = 'fasit_api_version'; public const CERTIFICATE = 'certificate'; + public const KEY = 'key'; + public const CERTIFICATE_PROVIDER = 'certificate_provider'; + public const PROVIDER_TYPE_FORM = 'form'; + public const PROVIDER_TYPE_KEY = 'key'; + + public const ACTION_PING_API = 'action_ping_api'; /** - * Constructor. + * {@inheritdoc} */ - public function __construct(private readonly Settings $settings, private readonly CertificateLocatorHelper $certificateLocatorHelper) { + public function __construct( + ConfigFactoryInterface $config_factory, + private readonly FasitHelper $helper, + ) { + parent::__construct($config_factory); } /** * {@inheritdoc} + * + * @phpstan-return self */ - public static function create(ContainerInterface $container): SettingsForm { + public static function create(ContainerInterface $container): self { return new static( - $container->get(Settings::class), - $container->get(CertificateLocatorHelper::class) + $container->get('config.factory'), + $container->get(FasitHelper::class) ); } + /** + * {@inheritdoc} + * + * @phpstan-return array + */ + protected function getEditableConfigNames() { + return [ + self::CONFIG_NAME, + ]; + } + /** * {@inheritdoc} */ @@ -51,57 +75,74 @@ public function getFormId() { * @phpstan-return array */ public function buildForm(array $form, FormStateInterface $form_state): array { + $form = parent::buildForm($form, $form_state); + $config = $this->config(self::CONFIG_NAME); - $fasitApiBaseUrl = $this->settings->getFasitApiBaseUrl(); $form[self::FASIT_API_BASE_URL] = [ '#type' => 'textfield', '#title' => $this->t('Fasit API base url'), '#required' => TRUE, - '#default_value' => !empty($fasitApiBaseUrl) ? $fasitApiBaseUrl : NULL, + '#default_value' => $config->get(self::FASIT_API_BASE_URL), '#description' => $this->t('Specifies which base url to use. This is disclosed by Schultz'), ]; - $fasitApiTenant = $this->settings->getFasitApiTenant(); $form[self::FASIT_API_TENANT] = [ '#type' => 'textfield', '#title' => $this->t('Fasit API tenant'), '#required' => TRUE, - '#default_value' => !empty($fasitApiTenant) ? $fasitApiTenant : NULL, + '#default_value' => $config->get(self::FASIT_API_TENANT), '#description' => $this->t('Specifies which tenant to use. This is disclosed by Schultz'), ]; - $fasitApiVersion = $this->settings->getFasitApiVersion(); $form[self::FASIT_API_VERSION] = [ '#type' => 'textfield', '#title' => $this->t('Fasit API version'), '#required' => TRUE, - '#default_value' => !empty($fasitApiVersion) ? $fasitApiVersion : NULL, + '#default_value' => $config->get(self::FASIT_API_VERSION), '#description' => $this->t('Specifies which api version to use. Should probably be v2'), ]; - $certificate = $this->settings->getCertificate(); + $certificateConfig = $config->get(self::CERTIFICATE) ?? []; $form[self::CERTIFICATE] = [ '#type' => 'fieldset', '#title' => $this->t('Certificate'), '#tree' => TRUE, - CertificateLocatorHelper::LOCATOR_TYPE => [ + self::CERTIFICATE_PROVIDER => [ '#type' => 'select', - '#title' => $this->t('Certificate locator type'), + '#title' => $this->t('Provider'), '#options' => [ - CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT => $this->t('Azure key vault'), - CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM => $this->t('File system'), + self::PROVIDER_TYPE_FORM => $this->t('Form'), + self::PROVIDER_TYPE_KEY => $this->t('Key'), ], - '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE] ?? NULL, + '#default_value' => $certificateConfig[self::CERTIFICATE_PROVIDER] ?? self::PROVIDER_TYPE_FORM, + '#description' => $this->t('Specifies which provider to use'), ], ]; + $form[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE] = [ + '#type' => 'select', + '#title' => $this->t('Certificate locator type'), + '#options' => [ + CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT => $this->t('Azure key vault'), + CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM => $this->t('File system'), + ], + '#default_value' => $certificateConfig[CertificateLocatorHelper::LOCATOR_TYPE] ?? NULL, + '#states' => [ + 'visible' => [':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_FORM]], + ], + '#description' => $this->t('Specifies which locator to use'), + ]; + $form[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT] = [ '#type' => 'fieldset', '#title' => $this->t('Azure key vault'), '#states' => [ - 'visible' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT]], + 'visible' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT], + ], ], ]; @@ -118,9 +159,12 @@ public function buildForm(array $form, FormStateInterface $form_state): array { $form[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] = [ '#type' => 'textfield', '#title' => $info['title'], - '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] ?? NULL, + '#default_value' => $certificateConfig[CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] ?? NULL, '#states' => [ - 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT]], + 'required' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT], + ], ], ]; } @@ -129,15 +173,21 @@ public function buildForm(array $form, FormStateInterface $form_state): array { '#type' => 'fieldset', '#title' => $this->t('File system'), '#states' => [ - 'visible' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]], + 'visible' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM], + ], ], 'path' => [ '#type' => 'textfield', '#title' => $this->t('Path'), - '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL, + '#default_value' => $certificateConfig[CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL, '#states' => [ - 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]], + 'required' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_FORM], + ':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM], + ], ], ], ]; @@ -145,20 +195,36 @@ public function buildForm(array $form, FormStateInterface $form_state): array { $form[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_PASSPHRASE] = [ '#type' => 'textfield', '#title' => $this->t('Passphrase'), - '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_PASSPHRASE] ?? NULL, + '#default_value' => $certificateConfig[CertificateLocatorHelper::LOCATOR_PASSPHRASE] ?? NULL, + '#states' => [ + 'visible' => [ + ':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_FORM], + ], + ], ]; - $form['actions']['#type'] = 'actions'; - - $form['actions']['submit'] = [ - '#type' => 'submit', - '#value' => $this->t('Save settings'), + $form[self::CERTIFICATE][self::PROVIDER_TYPE_KEY] = [ + '#type' => 'key_select', + '#title' => $this->t('Key'), + '#default_value' => $certificateConfig[self::PROVIDER_TYPE_KEY] ?? NULL, + '#states' => [ + 'visible' => [':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_KEY]], + 'required' => [':input[name="certificate[certificate_provider]"]' => ['value' => self::PROVIDER_TYPE_KEY]], + ], ]; - $form['actions']['testCertificate'] = [ - '#type' => 'submit', - '#name' => 'testCertificate', - '#value' => $this->t('Test certificate'), + $form['actions']['ping_api'] = [ + '#type' => 'container', + + self::ACTION_PING_API => [ + '#type' => 'submit', + '#name' => self::ACTION_PING_API, + '#value' => $this->t('Ping API'), + ], + + 'message' => [ + '#markup' => $this->t('Note: Pinging the API will use saved config.'), + ], ]; return $form; @@ -169,20 +235,23 @@ public function buildForm(array $form, FormStateInterface $form_state): array { * * @phpstan-param array $form */ - public function validateForm(array &$form, FormStateInterface $formState): void { - $triggeringElement = $formState->getTriggeringElement(); - if ('testCertificate' === ($triggeringElement['#name'] ?? NULL)) { + public function validateForm(array &$form, FormStateInterface $form_state): void { + if (self::ACTION_PING_API === ($form_state->getTriggeringElement()['#name'] ?? NULL)) { return; } - $values = $formState->getValues(); + $values = $form_state->getValues(); - if (CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM === $values[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE]) { - $path = $values[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL; - if (!file_exists($path)) { - $formState->setErrorByName('certificate][file_system][path', $this->t('Invalid certificate path: %path', ['%path' => $path])); + if (self::PROVIDER_TYPE_FORM === $values[self::CERTIFICATE][self::CERTIFICATE_PROVIDER]) { + if (CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM === $values[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE]) { + $path = $values[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL; + if (!file_exists($path)) { + $form_state->setErrorByName('certificate][file_system][path', $this->t('Invalid certificate path: %path', ['%path' => $path])); + } } } + + parent::validateForm($form, $form_state); } /** @@ -190,44 +259,30 @@ public function validateForm(array &$form, FormStateInterface $formState): void * * @phpstan-param array $form */ - public function submitForm(array &$form, FormStateInterface $formState): void { - $triggeringElement = $formState->getTriggeringElement(); - if ('testCertificate' === ($triggeringElement['#name'] ?? NULL)) { - $this->testCertificate(); + public function submitForm(array &$form, FormStateInterface $form_state): void { + if (self::ACTION_PING_API === ($form_state->getTriggeringElement()['#name'] ?? NULL)) { + try { + $this->helper->pingApi(); + $this->messenger()->addStatus($this->t('Pinged API successfully.')); + } + catch (\Throwable $t) { + $this->messenger()->addError($this->t('Pinging API failed: @message', ['@message' => $t->getMessage()])); + } return; } - try { - $settings[self::CERTIFICATE] = $formState->getValue(self::CERTIFICATE); - $settings[self::FASIT_API_BASE_URL] = $formState->getValue(self::FASIT_API_BASE_URL); - $settings[self::FASIT_API_TENANT] = $formState->getValue(self::FASIT_API_TENANT); - $settings[self::FASIT_API_VERSION] = $formState->getValue(self::FASIT_API_VERSION); - - $this->settings->setSettings($settings); - $this->messenger()->addStatus($this->t('Settings saved')); + $config = $this->config(self::CONFIG_NAME); + foreach ([ + self::FASIT_API_BASE_URL, + self::FASIT_API_TENANT, + self::FASIT_API_VERSION, + self::CERTIFICATE, + ] as $key) { + $config->set($key, $form_state->getValue($key)); } - catch (OptionsResolverException $exception) { - $this->messenger()->addError($this->t('Settings not saved (@message)', ['@message' => $exception->getMessage()])); - - return; - } - - $this->messenger()->addStatus($this->t('Settings saved')); - } + $config->save(); - /** - * Test certificate. - */ - private function testCertificate(): void { - try { - $certificateLocator = $this->certificateLocatorHelper->getCertificateLocator(); - $certificateLocator->getCertificates(); - $this->messenger()->addStatus($this->t('Certificate successfully tested')); - } - catch (\Throwable $throwable) { - $message = $this->t('Error testing certificate: %message', ['%message' => $throwable->getMessage()]); - $this->messenger()->addError($message); - } + parent::submitForm($form, $form_state); } } diff --git a/modules/os2forms_fasit/src/Helper/CertificateLocatorHelper.php b/modules/os2forms_fasit/src/Helper/CertificateLocatorHelper.php index 3f244d1a..4d56a580 100644 --- a/modules/os2forms_fasit/src/Helper/CertificateLocatorHelper.php +++ b/modules/os2forms_fasit/src/Helper/CertificateLocatorHelper.php @@ -38,12 +38,12 @@ public function __construct(private readonly Settings $settings) { * Get certificate locator. */ public function getCertificateLocator(): CertificateLocatorInterface { - $certificateSettings = $this->settings->getCertificate(); + $config = $this->settings->getFasitCertificateConfig(); - $locatorType = $certificateSettings[self::LOCATOR_TYPE]; - $options = $certificateSettings[$locatorType]; + $locatorType = $config[self::LOCATOR_TYPE]; + $options = $config[$locatorType]; $options += [ - self::LOCATOR_PASSPHRASE => $certificateSettings[self::LOCATOR_PASSPHRASE] ?: '', + self::LOCATOR_PASSPHRASE => $config[self::LOCATOR_PASSPHRASE] ?: '', ]; if (self::LOCATOR_TYPE_AZURE_KEY_VAULT === $locatorType) { diff --git a/modules/os2forms_fasit/src/Helper/FasitHelper.php b/modules/os2forms_fasit/src/Helper/FasitHelper.php index 2bd84d65..12a5c51f 100644 --- a/modules/os2forms_fasit/src/Helper/FasitHelper.php +++ b/modules/os2forms_fasit/src/Helper/FasitHelper.php @@ -4,18 +4,21 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\File\FileSystemInterface; use Drupal\os2forms_attachment\Element\AttachmentElement; use Drupal\os2forms_fasit\Exception\FasitResponseException; use Drupal\os2forms_fasit\Exception\FasitXMLGenerationException; use Drupal\os2forms_fasit\Exception\FileTypeException; use Drupal\os2forms_fasit\Exception\InvalidSettingException; use Drupal\os2forms_fasit\Exception\InvalidSubmissionException; +use Drupal\os2forms_fasit\Form\SettingsForm; use Drupal\os2forms_fasit\Plugin\WebformHandler\FasitWebformHandler; use Drupal\os2web_audit\Service\Logger; use Drupal\webform\Entity\WebformSubmission; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\Utils; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpFoundation\Response; /** @@ -38,6 +41,7 @@ public function __construct( private readonly ClientInterface $client, private readonly EntityTypeManagerInterface $entityTypeManager, private readonly Settings $settings, + private readonly FileSystemInterface $fileSystem, private readonly CertificateLocatorHelper $certificateLocator, private readonly Logger $auditLogger, ) { @@ -206,8 +210,6 @@ private function uploadDocument(array $uploads, string $submissionId, array $han } } - [$certificateOptions, $tempCertFilename] = $this->getCertificateOptionsAndTempCertFilename(); - $body = $doc->saveXML(); if (!$body) { @@ -219,20 +221,14 @@ private function uploadDocument(array $uploads, string $submissionId, array $han 'Content-Type' => 'application/xml', ], 'body' => $body, - 'cert' => $certificateOptions, ]; // Attempt upload. try { - $response = $this->client->request('POST', $endpoint, $options); + $response = $this->post($endpoint, $options); } catch (GuzzleException $e) { throw new FasitResponseException($e->getMessage(), $e->getCode()); - } finally { - // Remove the certificate from disk. - if (file_exists($tempCertFilename)) { - unlink($tempCertFilename); - } } if (Response::HTTP_OK !== $response->getStatusCode()) { @@ -262,26 +258,6 @@ private function checkHandlerConfiguration(array $handlerConfiguration, string $ } } - /** - * Gets certificate options and temp certificate filename. - * - * @throws \Drupal\os2forms_fasit\Exception\CertificateLocatorException - * Certificate locator exception. - * - * @phpstan-return array - */ - private function getCertificateOptionsAndTempCertFilename(): array { - $certificateLocator = $this->certificateLocator->getCertificateLocator(); - $localCertFilename = tempnam(sys_get_temp_dir(), 'cert'); - file_put_contents($localCertFilename, $certificateLocator->getCertificate()); - $certificateOptions = - $certificateLocator->hasPassphrase() ? - [$localCertFilename, $certificateLocator->getPassphrase()] - : $localCertFilename; - - return [$certificateOptions, $localCertFilename]; - } - /** * Uploads attachment to Fasit. * @@ -345,8 +321,6 @@ private function uploadFile(string $originalFilename, string $tempFilename, stri self::FASIT_API_METHOD_UPLOAD ); - [$certificateOptions, $tempCertFilename] = $this->getCertificateOptionsAndTempCertFilename(); - // Attempt upload. try { $options = [ @@ -356,18 +330,13 @@ private function uploadFile(string $originalFilename, string $tempFilename, stri 'X-Title' => pathinfo($originalFilename, PATHINFO_FILENAME), ], 'body' => Utils::tryFopen($tempFilename, 'r'), - 'cert' => $certificateOptions, ]; - $response = $this->client->request('POST', $endpoint, $options); + $response = $this->post($endpoint, $options); } catch (GuzzleException $e) { throw new FasitResponseException($e->getMessage(), $e->getCode()); } finally { - // Remove the certificate from disk. - if (file_exists($tempCertFilename)) { - unlink($tempCertFilename); - } // Remove the attachment from disk. if (file_exists($tempFilename)) { unlink($tempFilename); @@ -510,4 +479,96 @@ private function getSubmission(string $submissionId): EntityInterface { return $storage->load($submissionId); } + /** + * Send POST request to Fasit API. + * + * @param string $endpoint + * The API endpoint. + * @param array $options + * The request options. + * + * @return \Psr\Http\Message\ResponseInterface + * The response. + * + * @throws \GuzzleHttp\Exception\GuzzleException + * A Guzzle exception. + */ + private function post(string $endpoint, array $options): ResponseInterface { + try { + $config = $this->settings->getFasitCertificateConfig(); + + // Key => string + // Azure => file without passphrase + // Filesystem => file with potential passphrase. + $provider = $config['certificate_provider']; + + if (SettingsForm::PROVIDER_TYPE_KEY === $provider) { + $certificate = $this->settings->getKeyValue(); + $certPath = $this->fileSystem->tempnam($this->fileSystem->getTempDirectory(), 'os2forms_fasit_cert'); + // `tempnam` has created a file, so we must replace when saving. + $this->fileSystem->saveData($certificate, $certPath, FileSystemInterface::EXISTS_REPLACE); + $options['cert'] = $certPath; + } + elseif (SettingsForm::PROVIDER_TYPE_FORM === $provider) { + [$certificateOptions] = $this->getCertificateOptionsAndTempCertFilename(); + $options['cert'] = $certificateOptions; + } + else { + throw new InvalidSettingException('Invalid certificate configuration'); + } + + return $this->client->request('POST', $endpoint, $options); + } finally { + // Remove the certificate from disk. + if (isset($certPath) && file_exists($certPath)) { + unlink($certPath); + } + } + } + + /** + * Ping the Fasit API and expect a 400 Bad Request response. + * + * @throws \Throwable + */ + public function pingApi(): void { + $endpoint = sprintf('%s/%s/%s/documents/%s', + $this->settings->getFasitApiBaseUrl(), + $this->settings->getFasitApiTenant(), + $this->settings->getFasitApiVersion(), + self::FASIT_API_METHOD_UPLOAD + ); + + try { + $this->post($endpoint, []); + } + catch (\Throwable $t) { + // Throw if it's not a 400 Bad Request exception. + if (!($t instanceof GuzzleException) + || Response::HTTP_BAD_REQUEST !== $t->getCode()) { + throw $t; + } + } + } + + /** + * Gets certificate options and temp certificate filename. + * + * @throws \Drupal\os2forms_fasit\Exception\CertificateLocatorException + * Certificate locator exception. + * + * @phpstan-return array + */ + private function getCertificateOptionsAndTempCertFilename(): array { + $certificateLocator = $this->certificateLocator->getCertificateLocator(); + $localCertFilename = tempnam(sys_get_temp_dir(), 'cert'); + file_put_contents($localCertFilename, $certificateLocator->getCertificate()); + $certificateOptions = + $certificateLocator->hasPassphrase() ? + [$localCertFilename, $certificateLocator->getPassphrase()] + : $localCertFilename; + + return [$certificateOptions, $localCertFilename]; + } + } diff --git a/modules/os2forms_fasit/src/Helper/Settings.php b/modules/os2forms_fasit/src/Helper/Settings.php index de065fc6..09410299 100644 --- a/modules/os2forms_fasit/src/Helper/Settings.php +++ b/modules/os2forms_fasit/src/Helper/Settings.php @@ -2,115 +2,111 @@ namespace Drupal\os2forms_fasit\Helper; -use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; -use Drupal\Core\KeyValueStore\KeyValueStoreInterface; -use Drupal\os2forms_fasit\Exception\InvalidSettingException; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\ImmutableConfig; +use Drupal\key\KeyRepositoryInterface; use Drupal\os2forms_fasit\Form\SettingsForm; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * General settings for os2forms_fasit. */ final class Settings { /** - * The store. + * The config. * - * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface + * @var \Drupal\Core\Config\ImmutableConfig */ - private KeyValueStoreInterface $store; + private ImmutableConfig $config; /** - * The key value collection name. - * - * @var string + * The constructor. */ - private $collection = 'os2forms_fasit'; + public function __construct( + ConfigFactoryInterface $configFactory, + private readonly KeyRepositoryInterface $keyRepository, + ) { + $this->config = $configFactory->get(SettingsForm::CONFIG_NAME); + } /** - * The constructor. + * Get fasit api base url. */ - public function __construct(KeyValueFactoryInterface $keyValueFactory) { - $this->store = $keyValueFactory->get($this->collection); + public function getFasitApiBaseUrl(): ?string { + return $this->get(SettingsForm::FASIT_API_BASE_URL); } /** - * Get fasit api base url. + * Get fasit api tenant. */ - public function getFasitApiBaseUrl(): string { - return $this->get(SettingsForm::FASIT_API_BASE_URL, ''); + public function getFasitApiTenant(): ?string { + return $this->get(SettingsForm::FASIT_API_TENANT); } /** - * Get fasit api base url. + * Get fasit api version. */ - public function getFasitApiTenant(): string { - return $this->get(SettingsForm::FASIT_API_TENANT, ''); + public function getFasitApiVersion(): ?string { + return $this->get(SettingsForm::FASIT_API_VERSION); } /** - * Get fasit api base url. + * Get Fasit configuration selector. */ - public function getFasitApiVersion(): string { - return $this->get(SettingsForm::FASIT_API_VERSION, ''); + public function getFasitCertificateConfig(): ?array { + return $this->get(SettingsForm::CERTIFICATE); } /** - * Get certificate. - * - * @phpstan-return array + * Get Fasit certificate provider. */ - public function getCertificate(): array { - $value = $this->get(SettingsForm::CERTIFICATE); - return is_array($value) ? $value : []; + public function getFasitCertificateProvider(): string { + $config = $this->getFasitCertificateConfig(); + + return $config[SettingsForm::CERTIFICATE_PROVIDER] ?? SettingsForm::PROVIDER_TYPE_FORM; } /** - * Get a setting value. - * - * @param string $key - * The key. - * @param mixed|null $default - * The default value. - * - * @return mixed - * The setting value. + * Get Fasit certificate locator. */ - private function get(string $key, $default = NULL) { - $resolver = $this->getSettingsResolver(); - if (!$resolver->isDefined($key)) { - throw new InvalidSettingException(sprintf('Setting %s is not defined', $key)); - } + public function getFasitCertificateLocator(): string { + $config = $this->getFasitCertificateConfig(); - return $this->store->get($key, $default); + return $config[CertificateLocatorHelper::LOCATOR_TYPE] ?? CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM; } /** - * Set settings. - * - * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface - * - * @phpstan-param array $settings + * Get Fasit key certificate configuration. + */ + public function getFasitCertificateKey(): ?string { + $config = $this->getFasitCertificateConfig(); + + return $config[SettingsForm::PROVIDER_TYPE_KEY] ?? NULL; + } + + /** + * Get certificate. */ - public function setSettings(array $settings): self { - $settings = $this->getSettingsResolver()->resolve($settings); - foreach ($settings as $key => $value) { - $this->store->set($key, $value); - } + public function getKeyValue(): ?string { + $key = $this->keyRepository->getKey( + $this->getFasitCertificateKey(), + ); - return $this; + return $key?->getKeyValue(); } /** - * Get settings resolver. + * Get a setting value. + * + * @param string $key + * The key. + * @param mixed|null $default + * The default value. + * + * @return mixed + * The setting value. */ - private function getSettingsResolver(): OptionsResolver { - return (new OptionsResolver()) - ->setDefaults([ - SettingsForm::FASIT_API_BASE_URL => '', - SettingsForm::FASIT_API_TENANT => '', - SettingsForm::FASIT_API_VERSION => '', - SettingsForm::CERTIFICATE => [], - ]); + private function get(string $key, $default = NULL): mixed { + return $this->config->get($key) ?? $default; } } diff --git a/modules/os2forms_forloeb/src/MaestroHelper.php b/modules/os2forms_forloeb/src/MaestroHelper.php index d2dd0c52..0beea889 100644 --- a/modules/os2forms_forloeb/src/MaestroHelper.php +++ b/modules/os2forms_forloeb/src/MaestroHelper.php @@ -239,6 +239,7 @@ private function sendNotification( || $handler->isDisabled() || $handler->isExcluded() || !$handler->isNotificationEnabled($notificationType) + || !$handler->checkConditions($submission) ) { continue; } diff --git a/os2forms.install b/os2forms.install index 2079b530..b6085142 100644 --- a/os2forms.install +++ b/os2forms.install @@ -228,3 +228,12 @@ function _os2form_install_init_area_terms() { function os2forms_update_103001() { \Drupal::service('module_installer')->install(['os2web_audit']); } + +/** + * Implements hook_update_N(). + * + * Enable os2web_key module. + */ +function os2forms_update_103002() { + \Drupal::service('module_installer')->install(['os2web_key']); +} diff --git a/scripts/code-analysis b/scripts/code-analysis index 9fec0f46..ace9e282 100755 --- a/scripts/code-analysis +++ b/scripts/code-analysis @@ -16,9 +16,21 @@ if [ ! -f "$drupal_dir/composer.json" ]; then composer --no-interaction create-project drupal/recommended-project:^10 "$drupal_dir" fi # Copy our code into the modules folder -mkdir -p "$drupal_dir/$module_path" + +# Clean up +rm -fr "${drupal_dir:?}/$module_path" + # https://stackoverflow.com/a/15373763 -rsync --archive --compress . --filter=':- .gitignore' --exclude "$drupal_dir" --exclude .git "$drupal_dir/$module_path" +# rsync --archive --compress . --filter=':- .gitignore' --exclude "$drupal_dir" --exclude .git "$drupal_dir/$module_path" + +# The rsync command in not available in itkdev/php8.1-fpm + +git config --global --add safe.directory /app +# Copy module files into module path +for f in $(git ls-files); do + mkdir -p "$drupal_dir/$module_path/$(dirname "$f")" + cp "$f" "$drupal_dir/$module_path/$f" +done drupal_composer config minimum-stability dev @@ -37,4 +49,4 @@ drupal_composer config extra.merge-plugin.include "$module_path/composer.json" drupal_composer require --dev symfony/phpunit-bridge # Run PHPStan -(cd "$drupal_dir" && vendor/bin/phpstan --configuration="$module_path/phpstan.neon") +(cd "$drupal_dir/$module_path" && ../../../../vendor/bin/phpstan) diff --git a/templates/opgavebeskrivelse.md b/templates/opgavebeskrivelse.md new file mode 100644 index 00000000..c58c5d06 --- /dev/null +++ b/templates/opgavebeskrivelse.md @@ -0,0 +1,35 @@ +--- +name: Opgavebeskrivelse +about: Standard skabelon for definition af opgaver +title: '' +labels: '' +assignees: '' +--- +# {Kort titel, der repræsenterer opgaven, krævet} + +**Opgavebeskrivelse**: {venligst henvis til eventuelle danske ord, der bruges til at definere aktiviteten i omfanget.} + +**Møde eller møderække**: {en eventuel henvisning til mødet, hvor opgaven blev aftalt, inklusive dato og tidspunkt.} + +**Opdragsgiver**: {henvis til beslutningsorganet, der er enige om opgaven.} + +## Overordnet opgavebeskrivelse +{Beskriv konteksten og problemstillingen, fx i fri form med to til tre sætninger eller i form af en illustrativ historie.} + +## Kontekst og problem at løse +{Beskriv konteksten og problemstillingen, fx i fri form med to til tre sætninger eller i form af en illustrativ historie.} + +## Vigtige milepæle +* {titel på milepæl 1} +* {titel på milepæl 2} + +## Aktiviteter eller underopgaver (sub-issues) +- [ ] {titel på opgave 1} +- [ ] {titel på opgave 2} +- [ ] {titel på opgave 3} + +## Accept kriterier +{Beskriv med enkle ord, hvornår opgaven betragtes som fuldført, og angiv eventuelle formelle godkendelseskrav.} + +## Risici & konsekvenser, hvis opgaven ikke udføres. +{Beskriv med enkle ord, hvilke risice og konsekvenser det har, hvis opgaven ikke udføres.}