diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaf66e6..17b484cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ before starting to add changes. Use example [placed in the end of the page](#exa - Fix digital post commands - Updated versions in GitHub Actions `uses` steps - Updating the display of os2forms package on the status page +- Adding os2forms_digital_signature module ## [4.0.0] 2025-03-06 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_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; + } + +}