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.}