diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..59c34f6 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,119 @@ +on: pull_request +name: PR Review +jobs: + test-composer-files: + name: Validate composer + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '7.4', '8.0', '8.1' ] + dependency-version: [ prefer-lowest, prefer-stable ] + steps: + - uses: actions/checkout@master + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Validate composer files + run: | + composer validate --strict composer.json + # Check that dependencies resolve. + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + + php-check-coding-standards: + name: PHP - Check Coding Standards + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '7.4', '8.0', '8.1' ] + dependency-version: [ prefer-lowest, prefer-stable ] + steps: + - uses: actions/checkout@master + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install Dependencies + run: | + composer install --no-interaction --no-progress + - name: PHPCS + run: | + composer coding-standards-check/phpcs + + php-code-analysis: + name: PHP - Code analysis + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '7.4', '8.0', '8.1' ] + dependency-version: [ prefer-lowest, prefer-stable ] + steps: + - uses: actions/checkout@master + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json, gd + coverage: none + tools: composer:v2 + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: drupal-check + run: | + # We need a Drupal project to run drupal-check (cf. https://github.com/mglaman/drupal-check#usage) + # Install Drupal + composer --no-interaction create-project drupal/recommended-project:^9 --stability=dev drupal + # Copy our module source code into the Drupal module folder. + mkdir -p drupal/web/modules/contrib/os2forms_rest_api + cp -r os2forms_rest_api.* composer.json src drupal/web/modules/contrib/os2forms_rest_api + # Add our module as a composer repository. + composer --no-interaction --working-dir=drupal config repositories.os2forms/os2forms_rest_api path web/modules/contrib/os2forms_rest_api + # Restore Drupal composer repository. + composer --no-interaction --working-dir=drupal config repositories.drupal composer https://packages.drupal.org/8 + + # Require our module. + composer --no-interaction --working-dir=drupal require 'os2forms/os2forms_rest_api:*' + + # Check code + composer --no-interaction --working-dir=drupal require --dev drupal/core-dev + cd drupal/web/modules/contrib/os2forms_rest_api + # Remove our non-dev dependencies to prevent duplicated Drupal installation + # PHP Fatal error: Cannot redeclare drupal_get_filename() (previously declared in /home/runner/work/os2forms_rest_api/os2forms_rest_api/drupal/web/modules/contrib/os2forms_rest_api/vendor/drupal/core/includes/bootstrap.inc:190) in /home/runner/work/os2forms_rest_api/os2forms_rest_api/drupal/web/core/includes/bootstrap.inc on line 190 + # Use sed to remove the "require" property in composer.json + sed -i '/^\s*"require":/,/^\s*}/d' composer.json + composer --no-interaction install + composer code-analysis diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a9875b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/.php-version b/.php-version new file mode 100644 index 0000000..37722eb --- /dev/null +++ b/.php-version @@ -0,0 +1 @@ +7.4 diff --git a/README.md b/README.md index 1edce88..09fc73c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,146 @@ # OS2Forms REST API +We use [Webform REST](https://www.drupal.org/project/webform_rest) to expose a +number of API endpoints. + +## Installation + +```sh +composer require os2forms/os2forms_rest_api +vendor/bin/drush pm:enable os2forms_rest_api +``` + +## Authentication + +We use [Key auth](https://www.drupal.org/project/key_auth) for authenticating +api users. + +A user can access the Webforrm REST API if + +1. it has the “OS2Form REST API user” (`os2forms_rest_api_user`) role and +2. has a generated key (User > Edit > Key authentication; `/user/«user + id»/key-auth`). + +The “OS2Form REST API user” role gives read-only access to the API. To get read +access, a user must also have the “OS2Form REST API user (write)” +(`os2forms_rest_api_user_write`) role. + +## Endpoints + +| Name | Path | Methods | +|--------------------|------------------------------------------------|---------| +| Webform Elements | `/webform_rest/{webform_id}/elements` | GET | +| Webform Fields | `/webform_rest/{webform_id}/fields` | GET | +| Webform Submission | `/webform_rest/{webform_id}/submission/{uuid}` | GET | +| Webform Submit | `/webform_rest/submit` | POST | +| File | `/entity/file/{file_id}` | GET | + +## Examples + +### Get file content from webform submission + +Example uses `some_webform_id` as webform id, `some_submission_id` as submission +id and `dokumenter` as the webform file element key. + +Request: + +```sh +> curl --silent --header 'api-key: …' https://127.0.0.1:8000/webform_rest/some_webform_id/submission/some_submission_uuid +``` + +Response: + +```json +{ + …, + "data": { + "navn": "Jack", + "telefon": "12345678" + "dokumenter": { + "some_document_id", + "some_other_docuent_id" + } + } +} +``` + +Use the file endpoint from above to get information on a file, substituting +`{file_id}` with the actual file id (`some_document_id`) from the previous +request. + +Request: + +```sh +> curl --silent --header 'api-key: …' https://127.0.0.1:8000/webform_rest/entity/file/some_document_id +``` + +Response: + +```json +{ + …, + "uri": [ + { + "value": "private:…", + "url": "/system/files/webform/some_webform_id/…" + } + ], + … +} +``` + +Finally, you can get the actual file by combining the base url +with the url from above response: + +```sh +> curl --silent --header 'api-key: …' http://127.0.0.1:8000/system/files/webform/some_webform_id/… +``` + +Response: + +The actual document content. + +### Submit webform + +Request: + +```sh +> curl --silent --location --header 'api-key: …' --header 'content-type: application/json' https://127.0.0.1:8000/webform_rest/submit --data @- <<'JSON' +{ + "webform_id": "{webform_id}", + "//": "Webform field values (cf. /webform_rest/{webform_id}/fields)", + "navn_": "Mikkel", + "adresse": "Livets landevej", + "mail_": "mikkel@example.com", + "telefonnummer_": "12345678" +} +JSON +``` + +Response: + +```json +{"sid":"6d95afe9-18d1-4a7d-a1bf-fd38c58c7733"} +``` + +(the `sid` value is a webform submission uuid). + +## Custom access control + +To limit access to webforms, you can specify a list of API users that are +allowed to access a webform's data via the API. + +Go to Settings > General > Third party settings > OS2Forms > REST API to specify +which users can access a webform's data. **If no users are specified, all API +users can access the data.** + +### Technical details + +The custom access check is implemented in an event subscriber listening on the +`KernelEvents::REQUEST` event. See +[EventSubscriber::onRequest](src/EventSubscriber/EventSubscriber.php) for +details. + +In order to make documents accessible for api users the Key auth +`authentication_provider` service has been overwritten to be global. See +[os2forms_rest_api.services](os2forms_rest_api.services.yml). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..95ee865 --- /dev/null +++ b/composer.json @@ -0,0 +1,55 @@ +{ + "name": "os2forms/os2forms_rest_api", + "description": "OS2Forms REST API", + "type": "drupal-module", + "license": "MIT", + "authors": [ + { + "name": "Mikkel Ricky", + "email": "rimi@aarhus.dk" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "repositories": [ + { + "type": "composer", + "url": "https://packages.drupal.org/8" + } + ], + "require": { + "drupal/key_auth": "^2.0", + "drupal/webform_rest": "^4.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2", + "drupal/coder": "^8.3", + "mglaman/drupal-check": "^1.4" + }, + "scripts": { + "code-analysis/drupal-check": [ + "vendor/bin/drupal-check --deprecations --analysis --exclude-dir=vendor *.* src" + ], + "code-analysis": [ + "@code-analysis/drupal-check" + ], + "coding-standards-check/phpcs": [ + "vendor/bin/phpcs --standard=phpcs.xml.dist" + ], + "coding-standards-check": [ + "@coding-standards-check/phpcs" + ], + "coding-standards-apply/phpcs": [ + "vendor/bin/phpcbf --standard=phpcs.xml.dist" + ], + "coding-standards-apply": [ + "@coding-standards-apply/phpcs" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/config/install/rest.resource.entity.file.yml b/config/install/rest.resource.entity.file.yml new file mode 100644 index 0000000..642cbda --- /dev/null +++ b/config/install/rest.resource.entity.file.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - file + - os2forms_rest_api + - serialization + enforced: + module: + - os2forms_rest_api +id: entity.file +plugin_id: 'entity:file' +granularity: resource +configuration: + methods: + - GET + formats: + - json + authentication: + - key_auth diff --git a/config/install/rest.resource.webform_rest_elements.yml b/config/install/rest.resource.webform_rest_elements.yml new file mode 100644 index 0000000..3d84b76 --- /dev/null +++ b/config/install/rest.resource.webform_rest_elements.yml @@ -0,0 +1,21 @@ +langcode: da +status: true +dependencies: + module: + - key_auth + - os2forms_rest_api + - serialization + - webform_rest + enforced: + module: + - os2forms_rest_api +id: webform_rest_elements +plugin_id: webform_rest_elements +granularity: resource +configuration: + methods: + - GET + formats: + - json + authentication: + - key_auth diff --git a/config/install/rest.resource.webform_rest_fields.yml b/config/install/rest.resource.webform_rest_fields.yml new file mode 100644 index 0000000..70e581e --- /dev/null +++ b/config/install/rest.resource.webform_rest_fields.yml @@ -0,0 +1,21 @@ +langcode: da +status: true +dependencies: + module: + - key_auth + - os2forms_rest_api + - serialization + - webform_rest + enforced: + module: + - os2forms_rest_api +id: webform_rest_fields +plugin_id: webform_rest_fields +granularity: resource +configuration: + methods: + - GET + formats: + - json + authentication: + - key_auth diff --git a/config/install/rest.resource.webform_rest_submission.yml b/config/install/rest.resource.webform_rest_submission.yml new file mode 100644 index 0000000..8518ea1 --- /dev/null +++ b/config/install/rest.resource.webform_rest_submission.yml @@ -0,0 +1,21 @@ +langcode: da +status: true +dependencies: + module: + - key_auth + - os2forms_rest_api + - serialization + - webform_rest + enforced: + module: + - os2forms_rest_api +id: webform_rest_submission +plugin_id: webform_rest_submission +granularity: resource +configuration: + methods: + - GET + formats: + - json + authentication: + - key_auth diff --git a/config/install/rest.resource.webform_rest_submit.yml b/config/install/rest.resource.webform_rest_submit.yml new file mode 100644 index 0000000..3fef87f --- /dev/null +++ b/config/install/rest.resource.webform_rest_submit.yml @@ -0,0 +1,21 @@ +langcode: da +status: true +dependencies: + module: + - key_auth + - os2forms_rest_api + - serialization + - webform_rest + enforced: + module: + - os2forms_rest_api +id: webform_rest_submit +plugin_id: webform_rest_submit +granularity: resource +configuration: + methods: + - POST + formats: + - json + authentication: + - key_auth diff --git a/config/install/system.action.user_add_role_action.os2forms_rest_api_user.yml b/config/install/system.action.user_add_role_action.os2forms_rest_api_user.yml new file mode 100644 index 0000000..476efb8 --- /dev/null +++ b/config/install/system.action.user_add_role_action.os2forms_rest_api_user.yml @@ -0,0 +1,17 @@ +langcode: da +status: true +dependencies: + config: + - user.role.os2forms_rest_api_user + module: + - os2forms_rest_api + - user + enforced: + module: + - os2forms_rest_api +id: user_add_role_action.os2forms_rest_api_user +label: 'Add the OS2Form REST API user role to the selected user(s)' +type: user +plugin: user_add_role_action +configuration: + rid: os2forms_rest_api_user diff --git a/config/install/system.action.user_add_role_action.os2forms_rest_api_user_write.yml b/config/install/system.action.user_add_role_action.os2forms_rest_api_user_write.yml new file mode 100644 index 0000000..2b23c99 --- /dev/null +++ b/config/install/system.action.user_add_role_action.os2forms_rest_api_user_write.yml @@ -0,0 +1,17 @@ +langcode: da +status: true +dependencies: + config: + - user.role.os2forms_rest_api_user_write + module: + - user + - os2forms_rest_api + enforced: + module: + - os2forms_rest_api +id: user_add_role_action.os2forms_rest_api_user_write +label: 'Add the OS2Form REST API user (write) role to the selected user(s)' +type: user +plugin: user_add_role_action +configuration: + rid: os2forms_rest_api_user_write diff --git a/config/install/system.action.user_remove_role_action.os2forms_rest_api_user.yml b/config/install/system.action.user_remove_role_action.os2forms_rest_api_user.yml new file mode 100644 index 0000000..7c5fe60 --- /dev/null +++ b/config/install/system.action.user_remove_role_action.os2forms_rest_api_user.yml @@ -0,0 +1,17 @@ +langcode: da +status: true +dependencies: + config: + - user.role.os2forms_rest_api_user + module: + - user + - os2forms_rest_api + enforced: + module: + - os2forms_rest_api +id: user_remove_role_action.os2forms_rest_api_user +label: 'Remove the OS2Form REST API user role from the selected user(s)' +type: user +plugin: user_remove_role_action +configuration: + rid: os2forms_rest_api_user diff --git a/config/install/system.action.user_remove_role_action.os2forms_rest_api_user_write.yml b/config/install/system.action.user_remove_role_action.os2forms_rest_api_user_write.yml new file mode 100644 index 0000000..4b6b47c --- /dev/null +++ b/config/install/system.action.user_remove_role_action.os2forms_rest_api_user_write.yml @@ -0,0 +1,17 @@ +langcode: da +status: true +dependencies: + config: + - user.role.os2forms_rest_api_user_write + module: + - user + - os2forms_rest_api + enforced: + module: + - os2forms_rest_api +id: user_remove_role_action.os2forms_rest_api_user_write +label: 'Remove the OS2Form REST API user (write) role from the selected user(s)' +type: user +plugin: user_remove_role_action +configuration: + rid: os2forms_rest_api_user_write diff --git a/config/install/user.role.os2forms_rest_api_user.yml b/config/install/user.role.os2forms_rest_api_user.yml new file mode 100644 index 0000000..8d413bc --- /dev/null +++ b/config/install/user.role.os2forms_rest_api_user.yml @@ -0,0 +1,23 @@ +langcode: en +status: true +dependencies: + config: + - rest.resource.webform_rest_elements + - rest.resource.webform_rest_fields + - rest.resource.webform_rest_submission + module: + - key_auth + - os2forms_rest_api + - rest + enforced: + module: + - os2forms_rest_api +id: os2forms_rest_api_user +label: 'OS2Form REST API user' +weight: 1 +is_admin: null +permissions: + - 'restful get webform_rest_elements' + - 'restful get webform_rest_fields' + - 'restful get webform_rest_submission' + - 'use key authentication' diff --git a/config/install/user.role.os2forms_rest_api_user_write.yml b/config/install/user.role.os2forms_rest_api_user_write.yml new file mode 100644 index 0000000..9b79b28 --- /dev/null +++ b/config/install/user.role.os2forms_rest_api_user_write.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + config: + - rest.resource.webform_rest_submit + module: + - key_auth + - os2forms_rest_api + - rest + enforced: + module: + - os2forms_rest_api +id: os2forms_rest_api_user_write +label: 'OS2Form REST API (write)' +weight: 2 +is_admin: null +permissions: + - 'restful post webform_rest_submit' diff --git a/os2forms_rest_api.info.yml b/os2forms_rest_api.info.yml new file mode 100644 index 0000000..06a73f5 --- /dev/null +++ b/os2forms_rest_api.info.yml @@ -0,0 +1,9 @@ +name: 'OS2Forms REST API' +type: module +description: 'OS2Forms REST API' +package: OS2Forms +core_version_requirement: ^9 +dependencies: + - drupal:key_auth + - drupal:webform + - drupal:webform_rest diff --git a/os2forms_rest_api.module b/os2forms_rest_api.module new file mode 100644 index 0000000..dced77b --- /dev/null +++ b/os2forms_rest_api.module @@ -0,0 +1,29 @@ + $form + */ +function os2forms_rest_api_webform_third_party_settings_form_alter(array &$form, FormStateInterface $form_state): void { + \Drupal::service(WebformHelper::class)->webformThirdPartySettingsFormAlter($form, $form_state); +} + +/** + * Implements hook_file_download(). + * + * @phpstan-return int|array|null + */ +function os2forms_rest_api_file_download(string $uri) { + return \Drupal::service(WebformHelper::class)->fileDownload($uri); +} diff --git a/os2forms_rest_api.services.yml b/os2forms_rest_api.services.yml new file mode 100644 index 0000000..1d0772e --- /dev/null +++ b/os2forms_rest_api.services.yml @@ -0,0 +1,23 @@ +services: + Drupal\os2forms_rest_api\WebformHelper: + arguments: + - '@entity_type.manager' + - '@current_user' + - '@key_auth.authentication.key_auth' + - '@request_stack' + + Drupal\os2forms_rest_api\EventSubscriber\EventSubscriber: + arguments: + - '@current_route_match' + - '@current_user' + - '@Drupal\os2forms_rest_api\WebformHelper' + tags: + - { name: 'event_subscriber' } + + # Overwrite, adding global tag + # @see https://www.drupal.org/docs/drupal-apis/services-and-dependency-injection/altering-existing-services-providing-dynamic-services + key_auth.authentication.key_auth: + class: Drupal\key_auth\Authentication\Provider\KeyAuth + arguments: [ '@key_auth' ] + tags: + - { name: authentication_provider, provider_id: 'key_auth', priority: 200, global: true } diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100755 index 0000000..0f9161d --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,23 @@ + + + The coding standard. + + . + + vendor/ + + + + + + + + + + + + + + + + diff --git a/src/EventSubscriber/EventSubscriber.php b/src/EventSubscriber/EventSubscriber.php new file mode 100644 index 0000000..e67ecdf --- /dev/null +++ b/src/EventSubscriber/EventSubscriber.php @@ -0,0 +1,114 @@ +routeMatch = $routeMatch; + $this->currentUser = $currentUser; + $this->webformHelper = $webformHelper; + } + + /** + * On request handler. + * + * Check for user access to webform API resource. + */ + public function onRequest(KernelEvent $event): void { + $routeName = $this->routeMatch->getRouteName(); + $restRouteNames = [ + 'rest.webform_rest_elements.GET', + 'rest.webform_rest_fields.GET', + 'rest.webform_rest_submission.GET', + 'rest.webform_rest_submission.PATCH', + 'rest.webform_rest_submit.POST', + ]; + if (!in_array($routeName, $restRouteNames, TRUE)) { + return; + } + + // GET request have the webform id and (optional) submission uuid in the + // query string. + if (preg_match('/\.GET$/', $routeName)) { + $webformId = $this->routeMatch->getParameter('webform_id'); + $submissionUuid = $this->routeMatch->getParameter('uuid'); + } + else { + // POST and PATCH requests have webform id and submission uuid in the + // request body. + try { + $content = json_decode($event->getRequest()->getContent(), TRUE, 512, JSON_THROW_ON_ERROR); + if (isset($content['webform_id'])) { + $webformId = (string) $content['webform_id']; + } + if (isset($content['submission_uuid'])) { + $submissionUuid = (string) $content['submission_uuid']; + } + } + catch (\JsonException $exception) { + // Invalid JSON body. We cannot get webform id from request body. + } + } + + if (!isset($webformId)) { + throw new BadRequestHttpException('Cannot get webform id'); + } + + $webform = $this->webformHelper->getWebform($webformId, $submissionUuid ?? NULL); + + if (NULL === $webform) { + return; + } + + if (!$this->webformHelper->hasWebformAccess($webform, $this->currentUser)) { + throw new AccessDeniedHttpException('Access denied'); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + // @see https://www.drupal.org/project/drupal/issues/2924954#comment-12350447 + KernelEvents::REQUEST => ['onRequest', 31], + ]; + } + +} diff --git a/src/WebformHelper.php b/src/WebformHelper.php new file mode 100644 index 0000000..740534f --- /dev/null +++ b/src/WebformHelper.php @@ -0,0 +1,308 @@ +entityTypeManager = $entityTypeManager; + $this->currentUser = $currentUser; + $this->keyAuth = $keyAuth; + $this->requestStack = $requestStack; + } + + /** + * Implements hook_webform_third_party_settings_form_alter(). + * + * @phpstan-param array $form + */ + public function webformThirdPartySettingsFormAlter(array &$form, FormStateInterface $form_state): void { + /** @var \Drupal\Core\Entity\EntityForm $formObject */ + $formObject = $form_state->getFormObject(); + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $formObject->getEntity(); + + $form['third_party_settings']['os2forms']['os2forms_rest_api'] = [ + '#type' => 'details', + '#open' => TRUE, + '#title' => 'REST API', + '#tree' => TRUE, + ]; + + $allowedUsers = $this->getAllowedUsers($webform); + $form['third_party_settings']['os2forms']['os2forms_rest_api']['allowed_users'] = [ + '#type' => 'entity_autocomplete', + '#target_type' => 'user', + '#tags' => TRUE, + '#title' => $this->t('Allowed users'), + '#description' => $this->t("Limits users allowed to access this form's data via the REST API"), + '#default_value' => $allowedUsers, + ]; + + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints'] = [ + '#type' => 'fieldset', + '#title' => $this->t('API endpoints'), + + 'links' => [], + + 'messages' => [ + '#markup' => $this->t('Share these endpoints with people that must will use the REST API. Authentification is required to access the endpoints.'), + ], + + ]; + + $routes = [ + 'rest.webform_rest_elements.GET', + 'rest.webform_rest_fields.GET', + 'rest.webform_rest_submission.GET', + ]; + $requireUuid = static function ($route) { + return in_array( + $route, + [ + 'rest.webform_rest_submission.GET', + 'rest.webform_rest_submission.PATCH', + ], + TRUE + ); + }; + + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints']['links']['#prefix'] = '
    '; + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints']['links']['#suffix'] = '
'; + + foreach ($routes as $route) { + $parameters = []; + + if ('rest.webform_rest_submit.POST' !== $route) { + $parameters['webform_id'] = $webform->id(); + } + $uuidPlaceholder = '{uuid}'; + if ($requireUuid($route)) { + $parameters['uuid'] = $uuidPlaceholder; + } + + $url = Url::fromRoute($route, $parameters, ['absolute' => TRUE]); + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints']['links'][$route] = [ + '#type' => 'link', + '#title' => str_replace(urlencode($uuidPlaceholder), $uuidPlaceholder, $url->toString()), + '#url' => $url, + '#prefix' => '
  • ', + '#suffix' => '
  • ', + ]; + } + + if ($this->currentUser->isAuthenticated()) { + /** @var \Drupal\user\Entity\User $apiUser */ + $apiUser = $this->entityTypeManager->getStorage('user')->load($this->currentUser->id()); + // Don't show API data links if current user is not included in + // (non-empty) list of allowed users. + if (!empty($allowedUsers) && !isset($allowedUsers[$apiUser->id()])) { + $apiUser = NULL; + } + $apiKey = $apiUser ? $apiUser->api_key->value : NULL; + if (!empty($apiKey)) { + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints_test'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Test API endpoints'), + + 'links' => [], + + 'message' => [ + '#markup' => $this->t('These are only for checking the API responses for user %user. Do not share these urls!', ['%user' => $apiUser->getAccountName()]), + ], + ]; + + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints_test']['links']['#prefix'] = '
      '; + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints_test']['links']['#suffix'] = '
    '; + + foreach ($routes as $route) { + $parameters = []; + + if ('rest.webform_rest_submit.POST' !== $route) { + $parameters['webform_id'] = $webform->id(); + } + $uuidPlaceholder = '{uuid}'; + if ($requireUuid($route)) { + $parameters['uuid'] = $uuidPlaceholder; + } + $parameters['api-key'] = $apiKey; + + $url = Url::fromRoute($route, $parameters, ['absolute' => TRUE]); + $form['third_party_settings']['os2forms']['os2forms_rest_api']['api_info']['endpoints_test']['links'][$route] = [ + '#type' => 'link', + '#title' => str_replace(urlencode($uuidPlaceholder), $uuidPlaceholder, $url->toString()), + '#url' => $url, + '#prefix' => '
  • ', + '#suffix' => '
  • ', + ]; + } + } + } + } + + /** + * Get webform by id or submission uuid. + * + * If submission uuid is specified (i.e. not null), the submission's webform's + * id must match the specified webform id. + * + * @return \Drupal\webform\WebformInterface|null + * The webform if found. + */ + public function getWebform(string $webformId, string $submissionUuid = NULL): ?WebformInterface { + if (NULL !== $submissionUuid) { + $storage = $this->entityTypeManager->getStorage('webform_submission'); + $submissionIds = $storage + ->getQuery() + ->accessCheck(FALSE) + ->condition('uuid', $submissionUuid) + ->execute(); + $submission = $storage->load(array_key_first($submissionIds)); + + if (NULL === $submission) { + return NULL; + } + + assert($submission instanceof WebformSubmissionInterface); + $webform = $submission->getWebform(); + if ($webformId !== $webform->id()) { + return NULL; + } + + return $webform; + } + + return $this->entityTypeManager + ->getStorage('webform') + ->load($webformId); + } + + /** + * Get users allowed to access a webform's data. + * + * @return \Drupal\user\UserInterface[]|array + * The users. + */ + private function getAllowedUsers(WebformInterface $webform): array { + $settings = $webform->getThirdPartySetting('os2forms', 'os2forms_rest_api'); + $allowedUserIds = $settings['allowed_users'] ?? []; + + return $this->loadUsers($allowedUserIds); + } + + /** + * Check if a user has access to a webform. + * + * A user has access to a webform if the list of allowed users is empty or the + * user is included in the list. + * + * @param \Drupal\webform\WebformInterface $webform + * The webform. + * @param \Drupal\Core\Session\AccountInterface|int $user + * The user or user id. + * + * @return bool + * True if user has access to the webform. + */ + public function hasWebformAccess(WebformInterface $webform, $user): bool { + $userId = $user instanceof AccountInterface ? $user->id() : $user; + assert(is_int($userId)); + + $allowedUsers = $this->getAllowedUsers($webform); + + return empty($allowedUsers) || isset($allowedUsers[$userId]); + } + + /** + * Load users. + * + * @phpstan-param array $spec + * @phpstan-return array + */ + private function loadUsers(array $spec): array { + return $this->entityTypeManager + ->getStorage('user') + ->loadMultiple(array_column($spec, 'target_id')); + } + + /** + * Implements hook_file_download(). + * + * Note: This is only used to deny access to a file that is attached to a + * webform (submission) that the user does not have permission to access. + * Permission to access private files are handles elsewhere. + * + * @phpstan-return int|array|null + */ + public function fileDownload(string $uri) { + $request = $this->requestStack->getCurrentRequest(); + + // We are only concerned with users authenticated via Key Auth (cf. + // os2forms_rest_api.services.yml). + if ($user = $this->keyAuth->authenticate($request)) { + // Find webform id from uri, see example uri. + // @Example: private://webform/some_webform_id/119/some_file_name.png + $pattern = '/private:\/\/webform\/(?[^\/]*)/'; + if (preg_match($pattern, $uri, $matches)) { + $webform = $this->getWebform($matches['webform']); + if (NULL !== $webform) { + // Deny access to file if user does not have access to the webform. + if (!$this->hasWebformAccess($webform, $user)) { + return -1; + } + } + } + } + + // We cannot deny access to the file. Let others handle the access control + // for the (private) file. + return NULL; + } + +}