diff --git a/CHANGELOG.md b/CHANGELOG.md index fa29fb6..e31fbff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ before starting to add changes. Use example [placed in the end of the page](#exa - Adds audit logging to `os2forms_digital_post` - Adds audit logging to `os2forms_nemid` +## [3.18.0] 2024-12-05 + +- Added `os2forms_fasit` module. + ## [3.17.0] 2024-11-21 - Updated `os2web/os2web_audit` version @@ -302,7 +306,8 @@ before starting to add changes. Use example [placed in the end of the page](#exa - Security in case of vulnerabilities. ``` -[Unreleased]: https://github.com/OS2Forms/os2forms/compare/3.17.0...HEAD +[Unreleased]: https://github.com/OS2Forms/os2forms/compare/3.18.0...HEAD +[3.18.0]: https://github.com/OS2Forms/os2forms/compare/3.17.0...3.18.0 [3.17.0]: https://github.com/OS2Forms/os2forms/compare/3.16.2...3.17.0 [3.16.2]: https://github.com/OS2Forms/os2forms/compare/3.16.1...3.16.2 [3.16.1]: https://github.com/OS2Forms/os2forms/compare/3.16.0-beta1...3.16.1 diff --git a/modules/os2forms_fasit/README.md b/modules/os2forms_fasit/README.md new file mode 100644 index 0000000..6a697c2 --- /dev/null +++ b/modules/os2forms_fasit/README.md @@ -0,0 +1,64 @@ +# OS2Forms Fasit + +Adds [Fasit Schultz](https://schultz.dk/loesninger/schultz-fasit/) +handler for archiving purposes. + +For usage in danish, see [BENYTTELSE](docs/BENYTTELSE.md). + +## Installation + +```sh +drush pm:enable os2forms_fasit +``` + +## Settings + +Configure Fasit API `base url` and a way of getting +certificate on `/admin/os2forms_fasit/settings`. + +### Certificate + +The certificate must be in `pem` or `cer` format and +must be whitelisted by Fasit Schultz. +For this the certificate thumbprint, +in lowercase and without colons, is needed. +To get the thumbprint in the correct format from the command line run + +```sh +openssl x509 -in SOME_CERTIFICATE.pem -noout -fingerprint | cut -d= -f2 | sed 's/://g' | tr '[:upper:]' '[:lower:]' +``` + +Example output + +```sh +6acb261f393172d87fa3997cec86569759a8528a +``` + +## Queue + +Archiving is done via an +[Advanced Queue](https://www.drupal.org/project/advancedqueue) +called `fasit_queue`. + +The queue should be processed with `drush`: + +```sh +drush advancedqueue:queue:process fasit_queue +``` + +List the queue (and all other queues) with + +```sh +drush advancedqueue:queue:list +``` + +or go to `/admin/config/system/queues/jobs/fasit_queue` +for a graphical overview of jobs in the queue. + +### Cronjob + +Consider running the queue via a cronjob. + +```cron +*/5 * * * * /path/to/drush advancedqueue:queue:process fasit_queue +``` diff --git a/modules/os2forms_fasit/config/install/advancedqueue.advancedqueue_queue.fasit_queue.yml b/modules/os2forms_fasit/config/install/advancedqueue.advancedqueue_queue.fasit_queue.yml new file mode 100644 index 0000000..461da73 --- /dev/null +++ b/modules/os2forms_fasit/config/install/advancedqueue.advancedqueue_queue.fasit_queue.yml @@ -0,0 +1,15 @@ +langcode: da +status: true +dependencies: { } +id: fasit_queue +label: 'Fasit Queue' +backend: database +backend_configuration: + lease_time: 300 +processor: daemon +processing_time: 280 +locked: false +threshold: + type: 0 + limit: 0 + state: all diff --git a/modules/os2forms_fasit/docs/BENYTTELSE.md b/modules/os2forms_fasit/docs/BENYTTELSE.md new file mode 100644 index 0000000..63cc361 --- /dev/null +++ b/modules/os2forms_fasit/docs/BENYTTELSE.md @@ -0,0 +1,62 @@ +# OS2Forms Fasit + +Modulet OS2Forms Fasit giver muligheden for at videresende et genereret +indsendelsesbilag til en borger i fagsystemet Fasit Schultz. + +## Krav + +For at kunne snakke sammen med Fasit skal kommunen og Fasit først indbyrdes +aftale hvilke certifikater der anvendes. Disse certifikater skal være OCES-3, +f.eks. FOCES-3, og skal bruges i pem- eller cer-format. + +Dernæst oplyses det anvendte certifikats thumbprint eller public-key til Fasit, +som derefter aktiverer snitfladen. Se evt. +[README#certificate](../README.md#certificate) +for hvordan et certifikats thumbprint kan findes gennem kommandolinjen. + +## Konfiguration + +Integrationen konfigureres under +**Indstillinger** > **OS2Forms Fasit** (/admin/os2forms_fasit/settings). +Her skal følgende sættes op: + +* Fasit API base url + * Basis url’en til Fasit. Denne specificeres af Fasit. + * Eksempel: https://webservices.fasit.dk/ +* Fasit API tenant + * Fasit tenant. Denne specificeres af Fasit. + * Eksempel: aarhus +* Fasit API version + * Hvilken version af af API’et der skal bruges. Her er mulighederne ’v1’ eller ’v2’. Der bør altid bruges ’v2’. + * Eksempel: v2 +* Certificate + * Her kan angives detaljer til et azure key vault hvori certifikatet ligges (Azure key vault) eller en sti direkte til certifikatet (Filsystem) +* Passphrase + * Passphrase til certifikatet, hvis sådan et eksisterer. + + +Se evt. Fasit Scultz dokumentationen for flere detaljer på opbygningen af endpoint url’er. + +Det er desuden muligt at teste om os2forms kan få fat i certifikatet på samme konfigurations-side. + +## Handler + +For at videresende noget til Fasit skal der på formular niveau opsættes en ’Fasit’-handler. +Dette gøres på en formular under Indstillinger > Emails/Handlers > Add handler. +På denne konfigureres følgende: + +* Document title + * Dokumentets titel i Fasit +* Document description + * Dokumentets beskrivelse i Fasit +* CPR element + * Elementet der indeholdender det CPR-nummer der skal videresendes til i Fasit. + * Her kan benyttes enten ’textfield’, ’os2forms_nemid_cpr’ eller ’os2forms_person_lookup’. +* Attachment element + * Elementet der står for at oprette et OS2Forms attachment, altså et ’os2forms_attachment’-element. + +Alle felter er obligatoriske. + +Når der indsendes en formular bliver et ‘job’ sat i en kø. +Videresendelsen til Fasit sker først når dette job køres. +Se [README#queue](../README.md#queue) for flere detaljer. diff --git a/modules/os2forms_fasit/os2forms_fasit.info.yml b/modules/os2forms_fasit/os2forms_fasit.info.yml new file mode 100644 index 0000000..0142493 --- /dev/null +++ b/modules/os2forms_fasit/os2forms_fasit.info.yml @@ -0,0 +1,10 @@ +name: 'OS2Forms Fasit' +type: module +description: 'Fasit integration' +package: OS2Forms +core_version_requirement: ^9 || ^10 +dependencies: + - drupal:webform + - drupal:advancedqueue + - os2forms:os2forms_attachment +configure: os2forms_fasit.admin.settings diff --git a/modules/os2forms_fasit/os2forms_fasit.links.menu.yml b/modules/os2forms_fasit/os2forms_fasit.links.menu.yml new file mode 100644 index 0000000..ba8e382 --- /dev/null +++ b/modules/os2forms_fasit/os2forms_fasit.links.menu.yml @@ -0,0 +1,5 @@ +os2forms_fasit.admin.settings: + title: OS2Forms Fasit + description: Configure the OS2Forms Fasit module + parent: system.admin_config_system + route_name: os2forms_fasit.admin.settings diff --git a/modules/os2forms_fasit/os2forms_fasit.routing.yml b/modules/os2forms_fasit/os2forms_fasit.routing.yml new file mode 100644 index 0000000..8428f96 --- /dev/null +++ b/modules/os2forms_fasit/os2forms_fasit.routing.yml @@ -0,0 +1,7 @@ +os2forms_fasit.admin.settings: + path: '/admin/os2forms_fasit/settings' + defaults: + _form: '\Drupal\os2forms_fasit\Form\SettingsForm' + _title: 'Fasit settings' + requirements: + _permission: 'administer site configuration' diff --git a/modules/os2forms_fasit/os2forms_fasit.services.yml b/modules/os2forms_fasit/os2forms_fasit.services.yml new file mode 100644 index 0000000..23e41d4 --- /dev/null +++ b/modules/os2forms_fasit/os2forms_fasit.services.yml @@ -0,0 +1,15 @@ +services: + Drupal\os2forms_fasit\Helper\Settings: + arguments: + - "@keyvalue" + + Drupal\os2forms_fasit\Helper\CertificateLocatorHelper: + arguments: + - "@Drupal\\os2forms_fasit\\Helper\\Settings" + + Drupal\os2forms_fasit\Helper\FasitHelper: + arguments: + - '@http_client' + - '@entity_type.manager' + - "@Drupal\\os2forms_fasit\\Helper\\Settings" + - "@Drupal\\os2forms_fasit\\Helper\\CertificateLocatorHelper" diff --git a/modules/os2forms_fasit/src/Exception/CertificateLocatorException.php b/modules/os2forms_fasit/src/Exception/CertificateLocatorException.php new file mode 100644 index 0000000..95ea354 --- /dev/null +++ b/modules/os2forms_fasit/src/Exception/CertificateLocatorException.php @@ -0,0 +1,10 @@ +get(Settings::class), + $container->get(CertificateLocatorHelper::class) + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'os2forms_fasit_settings'; + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + * @phpstan-return array + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + + $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, + '#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, + '#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, + '#description' => $this->t('Specifies which api version to use. Should probably be v2'), + ]; + + $certificate = $this->settings->getCertificate(); + + $form[self::CERTIFICATE] = [ + '#type' => 'fieldset', + '#title' => $this->t('Certificate'), + '#tree' => TRUE, + + 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' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE] ?? NULL, + ], + ]; + + $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]], + ], + ]; + + $settings = [ + 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[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, + '#states' => [ + 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT]], + ], + ]; + } + + $form[self::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]], + ], + + 'path' => [ + '#type' => 'textfield', + '#title' => $this->t('Path'), + '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL, + '#states' => [ + 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]], + ], + ], + ]; + + $form[self::CERTIFICATE][CertificateLocatorHelper::LOCATOR_PASSPHRASE] = [ + '#type' => 'textfield', + '#title' => $this->t('Passphrase'), + '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_PASSPHRASE] ?? NULL, + ]; + + $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'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + * + * @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[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])); + } + } + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + */ + public function submitForm(array &$form, FormStateInterface $formState): void { + $triggeringElement = $formState->getTriggeringElement(); + if ('testCertificate' === ($triggeringElement['#name'] ?? NULL)) { + $this->testCertificate(); + 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')); + } + catch (OptionsResolverException $exception) { + $this->messenger()->addError($this->t('Settings not saved (@message)', ['@message' => $exception->getMessage()])); + + return; + } + + $this->messenger()->addStatus($this->t('Settings saved')); + } + + /** + * 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); + } + } + +} diff --git a/modules/os2forms_fasit/src/Helper/CertificateLocatorHelper.php b/modules/os2forms_fasit/src/Helper/CertificateLocatorHelper.php new file mode 100644 index 0000000..3f244d1 --- /dev/null +++ b/modules/os2forms_fasit/src/Helper/CertificateLocatorHelper.php @@ -0,0 +1,86 @@ +settings->getCertificate(); + + $locatorType = $certificateSettings[self::LOCATOR_TYPE]; + $options = $certificateSettings[$locatorType]; + $options += [ + self::LOCATOR_PASSPHRASE => $certificateSettings[self::LOCATOR_PASSPHRASE] ?: '', + ]; + + if (self::LOCATOR_TYPE_AZURE_KEY_VAULT === $locatorType) { + $httpClient = new GuzzleAdapter(new Client()); + $requestFactory = new RequestFactory(); + + $vaultToken = new VaultToken($httpClient, $requestFactory); + + $token = $vaultToken->getToken( + $options[self::LOCATOR_AZURE_KEY_VAULT_TENANT_ID], + $options[self::LOCATOR_AZURE_KEY_VAULT_APPLICATION_ID], + $options[self::LOCATOR_AZURE_KEY_VAULT_CLIENT_SECRET], + ); + + $vault = new VaultSecret( + $httpClient, + $requestFactory, + $options[self::LOCATOR_AZURE_KEY_VAULT_NAME], + $token->getAccessToken() + ); + + return new AzureKeyVaultCertificateLocator( + $vault, + $options[self::LOCATOR_AZURE_KEY_VAULT_SECRET], + $options[self::LOCATOR_AZURE_KEY_VAULT_VERSION], + $options[self::LOCATOR_PASSPHRASE], + ); + } + elseif (self::LOCATOR_TYPE_FILE_SYSTEM === $locatorType) { + $certificatepath = realpath($options[self::LOCATOR_FILE_SYSTEM_PATH]) ?: NULL; + if (NULL === $certificatepath) { + throw new CertificateLocatorException(sprintf('Invalid certificate path %s', $options[self::LOCATOR_FILE_SYSTEM_PATH])); + } + return new FilesystemCertificateLocator($certificatepath, $options[self::LOCATOR_PASSPHRASE]); + } + + throw new CertificateLocatorException(sprintf('Invalid certificate locator type: %s', $locatorType)); + } + +} diff --git a/modules/os2forms_fasit/src/Helper/FasitHelper.php b/modules/os2forms_fasit/src/Helper/FasitHelper.php new file mode 100644 index 0000000..fb14351 --- /dev/null +++ b/modules/os2forms_fasit/src/Helper/FasitHelper.php @@ -0,0 +1,494 @@ + $handlerConfiguration + */ + public function process(string $submissionId, array $handlerConfiguration): void { + $uploads = $this->uploadFiles($submissionId, $handlerConfiguration); + $this->uploadDocument($uploads, $submissionId, $handlerConfiguration); + } + + /** + * Uploads files to Fasit. + * + * @param string $submissionId + * The submission id. + * @param array $handlerConfiguration + * The Fasit handler configuration. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\os2forms_fasit\Exception\CertificateLocatorException + * @throws \Drupal\os2forms_fasit\Exception\FasitResponseException + * @throws \Drupal\os2forms_fasit\Exception\FileTypeException + * @throws \Drupal\os2forms_fasit\Exception\InvalidSettingException + * + * @phpstan-param array $handlerConfiguration + * @phpstan-return array + */ + private function uploadFiles(string $submissionId, array $handlerConfiguration): array { + $uploads = []; + + // Handle attachment. + $uploads[] = $this->uploadAttachment($submissionId, $handlerConfiguration); + // Handle potential file elements. + $fileElementsUpload = $this->uploadFileElements($submissionId); + + return array_merge($uploads, $fileElementsUpload); + } + + /** + * Uploads document containing uploaded files to Fasit. + * + * @param array $uploads + * The uploaded files. + * @param string $submissionId + * The submission id. + * @param array $handlerConfiguration + * The Fasit handler configuration. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\os2forms_fasit\Exception\CertificateLocatorException + * @throws \Drupal\os2forms_fasit\Exception\FasitResponseException + * @throws \Drupal\os2forms_fasit\Exception\FasitXMLGenerationException + * @throws \Drupal\os2forms_fasit\Exception\InvalidSettingException + * + * @phpstan-param array $uploads + * @phpstan-param array $handlerConfiguration + */ + private function uploadDocument(array $uploads, string $submissionId, array $handlerConfiguration): void { + $endpoint = sprintf('%s/%s/%s/documents/%s', + $this->settings->getFasitApiBaseUrl(), + $this->settings->getFasitApiTenant(), + $this->settings->getFasitApiVersion(), + self::FASIT_API_METHOD_CREATE + ); + + // Check handler configuration. + $this->checkHandlerConfiguration($handlerConfiguration, FasitWebformHandler::FASIT_HANDLER_CPR_ELEMENT); + $this->checkHandlerConfiguration($handlerConfiguration, FasitWebformHandler::FASIT_HANDLER_DOCUMENT_TITLE); + $this->checkHandlerConfiguration($handlerConfiguration, FasitWebformHandler::FASIT_HANDLER_DOCUMENT_DESCRIPTION); + + $fasitDocumentTitle = $handlerConfiguration[FasitWebformHandler::FASIT_HANDLER_GENERAL][FasitWebformHandler::FASIT_HANDLER_DOCUMENT_TITLE]; + $fasitDocumentDescription = $handlerConfiguration[FasitWebformHandler::FASIT_HANDLER_GENERAL][FasitWebformHandler::FASIT_HANDLER_DOCUMENT_DESCRIPTION]; + + // Fasit CPR element. + $webformCprElementId = $handlerConfiguration[FasitWebformHandler::FASIT_HANDLER_GENERAL][FasitWebformHandler::FASIT_HANDLER_CPR_ELEMENT]; + + /** @var \Drupal\webform\Entity\WebformSubmission $submission */ + $submission = $this->getSubmission($submissionId); + $submissionData = $submission->getData(); + + $fasitCpr = $submissionData[$webformCprElementId] ?? NULL; + + // Fix if os2forms_person_lookup (cpr & name validation) element is used. + if (is_array($fasitCpr)) { + // Example: + // [ + // 'cpr_number' => 1234567890, + // 'name' => Eksempel Eksempelsen, + // ]. + $fasitCpr = $fasitCpr['cpr_number'] ?? NULL; + } + + if (NULL === $fasitCpr) { + throw new InvalidSubmissionException(sprintf('Could not determine value of configured CPR element in submission.')); + } + + // Construct XML. + $doc = new \DOMDocument(); + + if (!$doc->load(__DIR__ . '/templateCreateDocument.xml')) { + throw new FasitXMLGenerationException('Could not load template XML'); + } + + // Set Document values. + $doc->getElementsByTagName('BeskrivelseTekst')->item(0)->nodeValue = $fasitDocumentDescription; + $doc->getElementsByTagName('TitelTekst')->item(0)->nodeValue = $fasitDocumentTitle; + $doc->getElementsByTagName('BrevDato')->item(0)->nodeValue = (new \DateTimeImmutable())->format('Y-m-d'); + + // Handle uploads + // Use existing DelEgenskaber as template for each upload. + // DelEgenskaber is the first child of Del. + $delEgenskaber = $doc->getElementsByTagName('DelEgenskaber')->item(0); + $del = $doc->getElementsByTagName('Del')->item(0); + + // Append a DelEgenskaber per upload. + foreach ($uploads as $upload) { + $copyDelEgenskaber = $delEgenskaber->cloneNode(TRUE); + $copyChildNodes = $copyDelEgenskaber->childNodes; + foreach ($copyChildNodes as $childNode) { + if ($childNode->nodeName === 'DelTekst') { + $childNode->nodeValue = $upload['filename']; + } + elseif ($childNode->nodeName === 'IndholdTekst') { + $childNode->nodeValue = $upload['id']; + } + } + + $del->insertBefore($copyDelEgenskaber, $delEgenskaber); + } + + // Remove template 'DelEgenskaber'. + $parent = $delEgenskaber->parentNode; + $parent->removeChild($delEgenskaber); + + // Handle Parter. + $elements = $doc->getElementsByTagName('Parter')->item(0)->childNodes; + + foreach ($elements as $element) { + if ($element->nodeName === 'ReferenceID') { + $referenceIdChildElements = $element->childNodes; + foreach ($referenceIdChildElements as $referenceIdChildElement) { + if ($referenceIdChildElement->nodeName === 'UUIDIdentifikator') { + $referenceIdChildElement->nodeValue = self::FASIT_API_PRIMAERPART_TEMPLATE_VALUE . $fasitCpr; + } + } + } + } + + [$certificateOptions, $tempCertFilename] = $this->getCertificateOptionsAndTempCertFilename(); + + $body = $doc->saveXML(); + + if (!$body) { + throw new FasitXMLGenerationException('Could not save XML body'); + } + + $options = [ + 'headers' => [ + 'Content-Type' => 'application/xml', + ], + 'body' => $body, + 'cert' => $certificateOptions, + ]; + + // Attempt upload. + try { + $response = $this->client->request('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()) { + throw new FasitResponseException(sprintf('Expected status code 200, received %d', $response->getStatusCode())); + } + } + + /** + * Checks that a setting exists in configuration. + * + * @param array $handlerConfiguration + * The Fasit handler configuration. + * @param string $setting + * The setting. + * + * @throws \Drupal\os2forms_fasit\Exception\InvalidSettingException + * Invalid setting exception. + * + * @phpstan-param array $handlerConfiguration + */ + private function checkHandlerConfiguration(array $handlerConfiguration, string $setting): void { + if (!isset($handlerConfiguration[FasitWebformHandler::FASIT_HANDLER_GENERAL][$setting])) { + throw new InvalidSettingException('Handler settings does not contain configuration of ' . str_replace('_', ' ', $setting)); + } + } + + /** + * 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. + * + * @param string $submissionId + * The submission id. + * @param array $handlerConfiguration + * The Fasit handler configuration. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\os2forms_fasit\Exception\CertificateLocatorException + * @throws \Drupal\os2forms_fasit\Exception\FasitResponseException + * @throws \Drupal\os2forms_fasit\Exception\InvalidSettingException + * + * @phpstan-param array $handlerConfiguration + * @phpstan-return array + */ + private function uploadAttachment(string $submissionId, array $handlerConfiguration): array { + /** @var \Drupal\webform\Entity\WebformSubmission $submission */ + $submission = $this->getSubmission($submissionId); + + $this->checkHandlerConfiguration($handlerConfiguration, FasitWebformHandler::FASIT_HANDLER_ATTACHMENT_ELEMENT); + + $webformAttachmentElementId = $handlerConfiguration[FasitWebformHandler::FASIT_HANDLER_GENERAL][FasitWebformHandler::FASIT_HANDLER_ATTACHMENT_ELEMENT]; + $webformAttachmentElement = $submission->getWebform()->getElement($webformAttachmentElementId); + + if ('pdf' !== $webformAttachmentElement['#export_type']) { + throw new InvalidSettingException(sprintf('Export type of attachment element (%s) must be pdf, found %s', $webformAttachmentElementId, $webformAttachmentElement['#export_type'])); + } + + $fileContent = AttachmentElement::getFileContent($webformAttachmentElement, $submission); + $fileName = AttachmentElement::getFileName($webformAttachmentElement, $submission); + $tempAttachmentFilename = tempnam(sys_get_temp_dir(), 'attachment'); + file_put_contents($tempAttachmentFilename, $fileContent); + + return $this->uploadFile($fileName, $tempAttachmentFilename); + } + + /** + * Uploads file to Fasit. + * + * @param string $originalFilename + * The original filename. + * @param string $tempFilename + * The temp filename. + * + * @throws \Drupal\os2forms_fasit\Exception\CertificateLocatorException + * Certificate locator exception. + * @throws \Drupal\os2forms_fasit\Exception\FasitResponseException + * Fasit response exception. + * + * @phpstan-return array + */ + private function uploadFile(string $originalFilename, string $tempFilename): array { + $endpoint = sprintf('%s/%s/%s/documents/%s', + $this->settings->getFasitApiBaseUrl(), + $this->settings->getFasitApiTenant(), + $this->settings->getFasitApiVersion(), + self::FASIT_API_METHOD_UPLOAD + ); + + [$certificateOptions, $tempCertFilename] = $this->getCertificateOptionsAndTempCertFilename(); + + // Attempt upload. + try { + $options = [ + 'headers' => [ + 'Content-Type' => 'application/pdf', + 'X-Filename' => $originalFilename, + 'X-Title' => pathinfo($originalFilename, PATHINFO_FILENAME), + ], + 'body' => Utils::tryFopen($tempFilename, 'r'), + 'cert' => $certificateOptions, + ]; + + $response = $this->client->request('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); + } + } + + if (Response::HTTP_CREATED !== $response->getStatusCode()) { + throw new FasitResponseException(sprintf('Expected status code 201, received %d', $response->getStatusCode())); + } + + $content = json_decode($response->getBody()->getContents(), TRUE); + if (!isset($content['id'])) { + throw new FasitResponseException('Could not get upload id from response'); + } + + return ['filename' => $originalFilename, 'id' => $content['id']]; + } + + /** + * Uploads files from file elements to Fasit. + * + * @param string $submissionId + * The submission id. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\os2forms_fasit\Exception\CertificateLocatorException + * @throws \Drupal\os2forms_fasit\Exception\FasitResponseException + * @throws \Drupal\os2forms_fasit\Exception\FileTypeException + * + * @phpstan-return array + */ + private function uploadFileElements(string $submissionId): array { + // Fetch element ids that may contain pdf files. + /** @var \Drupal\webform\Entity\WebformSubmission $submission */ + $submission = $this->getSubmission($submissionId); + $fileIds = $this->getFileElementKeysFromSubmission($submission); + $fileStorage = $this->entityTypeManager->getStorage('file'); + + $uploads = []; + + foreach ($fileIds as $fileId) { + /** @var \Drupal\file\Entity\File $file */ + $file = $fileStorage->load($fileId); + + // Ensure it is a pdf. + if ('application/pdf' !== $file->getMimeType()) { + throw new FileTypeException('Invalid file type uploaded. Only allowed file type is: pdf'); + } + + $filename = $file->getFilename(); + + $fileContent = file_get_contents($file->getFileUri()); + $tempFilename = tempnam(sys_get_temp_dir(), 'attachment'); + file_put_contents($tempFilename, $fileContent); + + $uploads[] = $this->uploadFile($filename, $tempFilename); + } + + return $uploads; + } + + /** + * Returns array of file elements keys in submission. + * + * @param \Drupal\webform\Entity\WebformSubmission $submission + * The submission id. + * + * @phpstan-return array + */ + private function getFileElementKeysFromSubmission(WebformSubmission $submission): array { + $elements = $submission->getWebform()->getElementsDecodedAndFlattened(); + + $fileElements = []; + + foreach (self::FILE_ELEMENT_TYPES as $fileElementType) { + $fileElements[] = $this->getAvailableElementsByType($fileElementType, $elements); + } + + // https://dev.to/klnjmm/never-use-arraymerge-in-a-for-loop-in-php-5go1 + $fileElements = array_merge(...$fileElements); + + $elementKeys = array_keys($fileElements); + + $fileIds = []; + + foreach ($elementKeys as $elementKey) { + if (empty($submission->getData()[$elementKey])) { + continue; + } + + // Convert occurrences of singular file into array. + $elementFileIds = (array) $submission->getData()[$elementKey]; + + $fileIds[] = $elementFileIds; + } + + return array_merge(...$fileIds); + } + + /** + * Get available elements by type. + * + * @param string $type + * The element type. + * @param array $elements + * The elements. + * + * @phpstan-param array $elements + * @phpstan-return array + */ + private function getAvailableElementsByType(string $type, array $elements): array { + $attachmentElements = array_filter($elements, function ($element) use ($type) { + return $type === $element['#type']; + }); + + return array_map(function ($element) { + return $element['#title']; + }, $attachmentElements); + } + + /** + * Gets WebformSubmission from id. + * + * @param string $submissionId + * The submission id. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + private function getSubmission(string $submissionId): EntityInterface { + $storage = $this->entityTypeManager->getStorage('webform_submission'); + return $storage->load($submissionId); + } + +} diff --git a/modules/os2forms_fasit/src/Helper/Settings.php b/modules/os2forms_fasit/src/Helper/Settings.php new file mode 100644 index 0000000..de065fc --- /dev/null +++ b/modules/os2forms_fasit/src/Helper/Settings.php @@ -0,0 +1,116 @@ +store = $keyValueFactory->get($this->collection); + } + + /** + * Get fasit api base url. + */ + public function getFasitApiBaseUrl(): string { + return $this->get(SettingsForm::FASIT_API_BASE_URL, ''); + } + + /** + * Get fasit api base url. + */ + public function getFasitApiTenant(): string { + return $this->get(SettingsForm::FASIT_API_TENANT, ''); + } + + /** + * Get fasit api base url. + */ + public function getFasitApiVersion(): string { + return $this->get(SettingsForm::FASIT_API_VERSION, ''); + } + + /** + * Get certificate. + * + * @phpstan-return array + */ + public function getCertificate(): array { + $value = $this->get(SettingsForm::CERTIFICATE); + return is_array($value) ? $value : []; + } + + /** + * Get a setting value. + * + * @param string $key + * The key. + * @param mixed|null $default + * The default value. + * + * @return mixed + * The setting 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)); + } + + return $this->store->get($key, $default); + } + + /** + * Set settings. + * + * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface + * + * @phpstan-param array $settings + */ + public function setSettings(array $settings): self { + $settings = $this->getSettingsResolver()->resolve($settings); + foreach ($settings as $key => $value) { + $this->store->set($key, $value); + } + + return $this; + } + + /** + * Get settings resolver. + */ + private function getSettingsResolver(): OptionsResolver { + return (new OptionsResolver()) + ->setDefaults([ + SettingsForm::FASIT_API_BASE_URL => '', + SettingsForm::FASIT_API_TENANT => '', + SettingsForm::FASIT_API_VERSION => '', + SettingsForm::CERTIFICATE => [], + ]); + } + +} diff --git a/modules/os2forms_fasit/src/Helper/templateCreateDocument.xml b/modules/os2forms_fasit/src/Helper/templateCreateDocument.xml new file mode 100644 index 0000000..281f7cf --- /dev/null +++ b/modules/os2forms_fasit/src/Helper/templateCreateDocument.xml @@ -0,0 +1,108 @@ + + + + + + + BeskrivelseTekst + BrevDato + TitelTekst + + + + + + Bruger + + + + + + Modtaget + + + + + + + + + Organisation + + + + + + + + + + + Bruger + + + + + + + + + + + Organisation + + + + + + + + + + + Bruger + + + UUIDIdentifikator0 + + + + + + + + + + + + Organisation + + + + + DelTekst + IndholdTekst + + + + + Bruger + + + + + + + + + Organisation + + + + + + + + + + diff --git a/modules/os2forms_fasit/src/Plugin/AdvancedQueue/JobType/Fasit.php b/modules/os2forms_fasit/src/Plugin/AdvancedQueue/JobType/Fasit.php new file mode 100644 index 0000000..efc8b2b --- /dev/null +++ b/modules/os2forms_fasit/src/Plugin/AdvancedQueue/JobType/Fasit.php @@ -0,0 +1,100 @@ + $configuration + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get(FasitHelper::class), + $container->get('logger.factory') + ); + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $configuration + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, FasitHelper $helper, LoggerChannelFactoryInterface $loggerFactory) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->helper = $helper; + $this->submissionLogger = $loggerFactory->get('webform_submission'); + } + + /** + * Processes the Fasit job. + */ + public function process(Job $job): JobResult { + try { + $payload = $job->getPayload(); + + /** @var \Drupal\webform\WebformSubmissionInterface $webformSubmission */ + $webformSubmission = WebformSubmission::load($payload['submissionId']); + $logger_context = [ + 'handler_id' => 'os2forms_fasit', + 'channel' => 'webform_submission', + 'webform_submission' => $webformSubmission, + 'operation' => 'response from queue', + ]; + + try { + $this->helper->process($payload['submissionId'], $payload['handlerConfiguration']); + $this->submissionLogger->notice($this->t('The submission #@serial was successfully delivered', ['@serial' => $webformSubmission->serial()]), $logger_context); + + return JobResult::success(); + } + catch (\Exception $e) { + $this->submissionLogger->error($this->t('The submission #@serial failed (@message)', [ + '@serial' => $webformSubmission->serial(), + '@message' => $e->getMessage(), + ]), $logger_context); + + return JobResult::failure($e->getMessage()); + } + } + catch (\Exception $e) { + return JobResult::failure($e->getMessage()); + } + } + +} diff --git a/modules/os2forms_fasit/src/Plugin/WebformHandler/FasitWebformHandler.php b/modules/os2forms_fasit/src/Plugin/WebformHandler/FasitWebformHandler.php new file mode 100644 index 0000000..84eef37 --- /dev/null +++ b/modules/os2forms_fasit/src/Plugin/WebformHandler/FasitWebformHandler.php @@ -0,0 +1,188 @@ + $configuration + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerChannelFactoryInterface $loggerFactory, ConfigFactoryInterface $configFactory, RendererInterface $renderer, EntityTypeManagerInterface $entityTypeManager, WebformSubmissionConditionsValidatorInterface $conditionsValidator, WebformTokenManagerInterface $tokenManager) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->setConfiguration($configuration); + $this->loggerFactory = $loggerFactory; + $this->configFactory = $configFactory; + $this->renderer = $renderer; + $this->entityTypeManager = $entityTypeManager; + $this->conditionsValidator = $conditionsValidator; + $this->tokenManager = $tokenManager; + $this->submissionLogger = $loggerFactory->get('webform_submission'); + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $configuration + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('logger.factory'), + $container->get('config.factory'), + $container->get('renderer'), + $container->get('entity_type.manager'), + $container->get('webform_submission.conditions_validator'), + $container->get('webform.token_manager') + ); + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + * @phpstan-return array + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $elements = $this->getWebform()->getElementsDecodedAndFlattened(); + + $form[self::FASIT_HANDLER_GENERAL] = [ + '#type' => 'fieldset', + '#title' => $this->t('General settings'), + ]; + + $form[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_DOCUMENT_TITLE] = [ + '#type' => 'textfield', + '#title' => $this->t('Document title'), + '#description' => $this->t('Fasit document title'), + '#default_value' => $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_DOCUMENT_TITLE] ?? '', + '#required' => TRUE, + ]; + + $form[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_DOCUMENT_DESCRIPTION] = [ + '#type' => 'textfield', + '#title' => $this->t('Document description'), + '#description' => $this->t('Fasit document description'), + '#default_value' => $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_DOCUMENT_DESCRIPTION] ?? '', + '#required' => TRUE, + ]; + + $form[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_CPR_ELEMENT] = [ + '#type' => 'select', + '#title' => $this->t('CPR element'), + '#options' => $this->getAvailableElementsByType(['textfield', 'os2forms_nemid_cpr', 'os2forms_person_lookup'], $elements), + '#default_value' => $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_CPR_ELEMENT] ?? '', + '#description' => $this->t('Choose element containing CPR.'), + '#required' => TRUE, + '#size' => 5, + ]; + + $form[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_ATTACHMENT_ELEMENT] = [ + '#type' => 'select', + '#title' => $this->t('Attachment element'), + '#options' => $this->getAvailableElementsByType(['os2forms_attachment'], $elements), + '#default_value' => $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_ATTACHMENT_ELEMENT] ?? '', + '#description' => $this->t('Choose the element responsible for creating the submission attachment.'), + '#required' => TRUE, + '#size' => 5, + ]; + + return $this->setSettingsParents($form); + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void { + parent::submitConfigurationForm($form, $form_state); + $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_DOCUMENT_TITLE] = $form_state->getValue(self::FASIT_HANDLER_GENERAL)[self::FASIT_HANDLER_DOCUMENT_TITLE]; + $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_DOCUMENT_DESCRIPTION] = $form_state->getValue(self::FASIT_HANDLER_GENERAL)[self::FASIT_HANDLER_DOCUMENT_DESCRIPTION]; + $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_CPR_ELEMENT] = $form_state->getValue(self::FASIT_HANDLER_GENERAL)[self::FASIT_HANDLER_CPR_ELEMENT]; + $this->configuration[self::FASIT_HANDLER_GENERAL][self::FASIT_HANDLER_ATTACHMENT_ELEMENT] = $form_state->getValue(self::FASIT_HANDLER_GENERAL)[self::FASIT_HANDLER_ATTACHMENT_ELEMENT]; + } + + /** + * {@inheritdoc} + */ + public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE): void { + $queueStorage = $this->entityTypeManager->getStorage('advancedqueue_queue'); + /** @var \Drupal\advancedqueue\Entity\Queue $queue */ + $queue = $queueStorage->load('fasit_queue'); + $job = Job::create(Fasit::class, [ + 'submissionId' => $webform_submission->id(), + 'handlerConfiguration' => $this->configuration, + ]); + $queue->enqueueJob($job); + + $logger_context = [ + 'handler_id' => 'os2forms_fasit', + 'channel' => 'webform_submission', + 'webform_submission' => $webform_submission, + 'operation' => 'submission queued', + ]; + + $this->submissionLogger->notice($this->t('Added submission #@serial to queue for processing', ['@serial' => $webform_submission->serial()]), $logger_context); + } + + /** + * Get available elements by type. + * + * @phpstan-param array $types + * @phpstan-param array $elements + * @phpstan-return array + */ + private function getAvailableElementsByType(array $types, array $elements): array { + $attachmentElements = array_filter($elements, function ($element) use ($types) { + return in_array($element['#type'], $types); + }); + + return array_map(function ($element) { + return $element['#title']; + }, $attachmentElements); + } + +}