diff --git a/modules/civiremote_entity/civiremote_entity.routing.yml b/modules/civiremote_entity/civiremote_entity.routing.yml new file mode 100644 index 0000000..714fcaf --- /dev/null +++ b/modules/civiremote_entity/civiremote_entity.routing.yml @@ -0,0 +1,16 @@ +civiremote_entity.remote_page_get: + path: '/civiremote/get/{identifier}/{filename}' + defaults: + _controller: 'Drupal\civiremote_entity\RemotePageController::get' + # The parameter filename is optional and is just used to show a convenient + # URL to the user. + filename: ~ + options: + no_cache: TRUE + parameters: + identifier: + type: string + filename: + type: string + requirements: + _user_is_logged_in: 'TRUE' diff --git a/modules/civiremote_entity/civiremote_entity.services.yml b/modules/civiremote_entity/civiremote_entity.services.yml index 8028da1..a8b1c81 100644 --- a/modules/civiremote_entity/civiremote_entity.services.yml +++ b/modules/civiremote_entity/civiremote_entity.services.yml @@ -11,6 +11,10 @@ services: factory: [ 'Drupal', 'config' ] arguments: [ 'civiremote.settings' ] + civiremote_entity.logger: + parent: logger.channel_base + arguments: [ 'civiremote_entity' ] + Drupal\civiremote_entity\Api\CiviCRMApiClientInterface: class: Drupal\civiremote_entity\Api\CiviCRMApiClient factory: [ 'Drupal\civiremote_entity\Api\CiviCRMApiClient', 'create' ] @@ -29,3 +33,27 @@ services: arguments: $messenger: '@messenger' public: true + + Drupal\civiremote_entity\CiviCRMPage\CiviCRMPageClientInterface: + class: Drupal\civiremote_entity\CiviCRMPage\CiviCRMPageClient + factory: ['Drupal\civiremote_entity\CiviCRMPage\CiviCRMPageClient', 'create'] + arguments: + $cmrfCore: '@cmrf_core.core' + $config: '@civiremote.civiremote.settings' + $connectorConfigKey: '%civiremote.cmrf_connector_config_key%' + $httpClient: '@http_client' + + Drupal\civiremote_entity\CiviCRMPage\CiviCRMPageProxyInterface: + class: Drupal\civiremote_entity\CiviCRMPage\CiviCRMPageProxy + arguments: + $logger: '@civiremote_entity.logger' + + Drupal\civiremote_entity\CiviCRMPage\CiviCRMUrlStorageInterface: + class: Drupal\civiremote_entity\CiviCRMPage\CiviCRMUrlStorage + arguments: + $session: '@session' + $uuidGenerator: '@uuid' + + Drupal\civiremote_entity\Controller\CiviCRMPageController: + class: Drupal\civiremote_entity\Controller\CiviCRMPageController + public: true diff --git a/modules/civiremote_entity/src/Access/RemoteContactIdProvider.php b/modules/civiremote_entity/src/Access/RemoteContactIdProvider.php index bf734d7..9bc4b02 100644 --- a/modules/civiremote_entity/src/Access/RemoteContactIdProvider.php +++ b/modules/civiremote_entity/src/Access/RemoteContactIdProvider.php @@ -20,7 +20,6 @@ namespace Drupal\civiremote_entity\Access; -use Assert\Assertion; use Drupal\Core\Session\AccountProxyInterface; final class RemoteContactIdProvider implements RemoteContactIdProviderInterface { @@ -32,15 +31,19 @@ public function __construct(AccountProxyInterface $currentUser) { } public function getRemoteContactId(): string { - $account = $this->currentUser->getAccount(); - Assertion::propertyExists($account, 'civiremote_id'); + if (!$this->hasRemoteContactId()) { + throw new \RuntimeException(sprintf('User "%s" has no remote contact ID', $this->currentUser->getAccountName())); + } + // @phpstan-ignore-next-line - $remoteContactId = $account->civiremote_id; + return $this->currentUser->getAccount()->civiremote_id; + } - Assertion::string($remoteContactId); - Assertion::notEmpty($remoteContactId); + public function hasRemoteContactId(): bool { + $account = $this->currentUser->getAccount(); + $remoteContactId = $account->civiremote_id ?? NULL; - return $remoteContactId; + return is_string($remoteContactId) && '' !== $remoteContactId; } } diff --git a/modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php b/modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php index 787c421..ffad251 100644 --- a/modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php +++ b/modules/civiremote_entity/src/Access/RemoteContactIdProviderInterface.php @@ -22,6 +22,12 @@ interface RemoteContactIdProviderInterface { + /** + * @throws \RuntimeException + * If current user has no remote contact ID. + */ public function getRemoteContactId(): string; + public function hasRemoteContactId(): bool; + } diff --git a/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageClient.php b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageClient.php new file mode 100644 index 0000000..8abee73 --- /dev/null +++ b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageClient.php @@ -0,0 +1,101 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\CiviCRMPage; + +use Assert\Assertion; +use CMRF\Core\Core; +use Drupal\civiremote_entity\Access\RemoteContactIdProviderInterface; +use Drupal\Core\Config\ImmutableConfig; +use GuzzleHttp\ClientInterface; +use Psr\Http\Message\ResponseInterface; + +/** + * @codeCoverageIgnore + */ +final class CiviCRMPageClient implements CiviCRMPageClientInterface { + + private const DEFAULT_CONNECT_TIMEOUT = 3.0; + + private const DEFAULT_TIMEOUT = 7.0; + + private string $apiKey; + + private ClientInterface $httpClient; + + private RemoteContactIdProviderInterface $remoteContactIdProvider; + + private string $siteKey; + + public static function create( + Core $cmrfCore, + ImmutableConfig $config, + string $connectorConfigKey, + ClientInterface $httpClient, + RemoteContactIdProviderInterface $remoteContactIdProvider + ): CiviCRMPageClientInterface { + $connectorId = $config->get($connectorConfigKey); + Assertion::string($connectorId); + + $profile = $cmrfCore->getConnectionProfile($connectorId); + Assertion::string($profile['api_key'] ?? NULL); + Assertion::string($profile['site_key'] ?? NULL); + + return new self($profile['api_key'], $httpClient, $remoteContactIdProvider, $profile['site_key']); + } + + public function __construct( + string $apiKey, + ClientInterface $httpClient, + RemoteContactIdProviderInterface $remoteContactIdProvider, + string $siteKey + ) { + $this->apiKey = $apiKey; + $this->httpClient = $httpClient; + $this->remoteContactIdProvider = $remoteContactIdProvider; + $this->siteKey = $siteKey; + } + + /** + * {@inheritDoc} + */ + public function request(string $method, string $uri, array $options = []): ResponseInterface { + // @phpstan-ignore-next-line + $options['headers'] = array_merge($options['headers'] ?? [], $this->buildHeaders()); + $options['connect_timeout'] ??= self::DEFAULT_CONNECT_TIMEOUT; + $options['timeout'] ??= self::DEFAULT_TIMEOUT; + $options['http_errors'] ??= FALSE; + + return $this->httpClient->request($method, $uri, $options); + } + + /** + * @phpstan-return array + */ + private function buildHeaders(): array { + return [ + 'X-Civi-Auth' => 'Bearer ' . $this->apiKey, + 'X-Civi-Key' => $this->siteKey, + 'X-Civi-Remote-Contact-Id' => $this->remoteContactIdProvider->hasRemoteContactId() + ? $this->remoteContactIdProvider->getRemoteContactId() : '', + ]; + } + +} diff --git a/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageClientInterface.php b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageClientInterface.php new file mode 100644 index 0000000..0969c53 --- /dev/null +++ b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageClientInterface.php @@ -0,0 +1,42 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\CiviCRMPage; + +use Psr\Http\Message\ResponseInterface; + +/** + * Client for pages in CiviCRM. Authentication is done via AuthX headers. + * Additionally the remote contact ID is delivered in the header + * X-Civi-Remote-Contact-Id. An empty string is used if the current user has no + * remote contact ID. + */ +interface CiviCRMPageClientInterface { + + /** + * Does not throw exceptions on HTTP status codes >= 400 by default. + * + * @phpstan-param array $options + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function request(string $method, string $uri, array $options = []): ResponseInterface; + +} diff --git a/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageProxy.php b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageProxy.php new file mode 100644 index 0000000..c5669f4 --- /dev/null +++ b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageProxy.php @@ -0,0 +1,116 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\CiviCRMPage; + +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message\ResponseInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; + +/** + * @codeCoverageIgnore + */ +class CiviCRMPageProxy implements CiviCRMPageProxyInterface { + + private const RETURNED_HEADERS = [ + 'Content-Type', + 'Content-Length', + 'Content-Disposition', + ]; + + private CiviCRMPageClientInterface $client; + + private LoggerInterface $logger; + + public function __construct(CiviCRMPageClientInterface $client, LoggerInterface $logger) { + $this->client = $client; + $this->logger = $logger; + } + + /** + * {@inheritDoc} + */ + public function get(string $uri): Response { + try { + $remoteResponse = $this->client->request('GET', $uri); + } + catch (GuzzleException $e) { + $this->logger->error(sprintf('Loading "%s" from CiviCRM failed: %s', $uri, $e->getMessage())); + + throw new ServiceUnavailableHttpException(NULL, '', $e, $e->getCode()); + } + + if (Response::HTTP_NOT_FOUND === $remoteResponse->getStatusCode()) { + throw new NotFoundHttpException(); + } + + if (Response::HTTP_UNAUTHORIZED === $remoteResponse->getStatusCode()) { + $this->logger->error('Authentication at CiviCRM failed', [ + 'uri' => $uri, + ]); + + throw new ServiceUnavailableHttpException(); + } + + if (Response::HTTP_FORBIDDEN === $remoteResponse->getStatusCode()) { + throw new AccessDeniedHttpException(); + } + + if (Response::HTTP_OK === $remoteResponse->getStatusCode()) { + return new StreamedResponse( + function () use ($remoteResponse) { + $body = $remoteResponse->getBody(); + while (!$body->eof()) { + echo $body->read(1024); + } + }, + Response::HTTP_OK, + $this->buildResponseHeaders($remoteResponse), + ); + } + + $this->logger->error(sprintf('Unexpected response while loading "%s" from CiviCRM', $uri), [ + 'statusCode' => $remoteResponse->getStatusCode(), + 'reasonPhrase' => $remoteResponse->getReasonPhrase(), + ]); + + throw new ServiceUnavailableHttpException(); + } + + /** + * @phpstan-return array> + */ + private function buildResponseHeaders(ResponseInterface $remoteResponse): array { + $headers = []; + foreach (self::RETURNED_HEADERS as $headerName) { + if ($remoteResponse->hasHeader($headerName)) { + $headers[$headerName] = $remoteResponse->getHeader($headerName); + } + } + + return $headers; + } + +} diff --git a/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageProxyInterface.php b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageProxyInterface.php new file mode 100644 index 0000000..6d4d276 --- /dev/null +++ b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageProxyInterface.php @@ -0,0 +1,39 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\CiviCRMPage; + +use Symfony\Component\HttpFoundation\Response; + +/** + * Proxy for pages in CiviCRM. + * + * @see \Drupal\civiremote_entity\CiviCRMPage\CiviCRMPageClientInterface + */ +interface CiviCRMPageProxyInterface { + + /** + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @throws \Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException + */ + public function get(string $uri): Response; + +} diff --git a/modules/civiremote_entity/src/CiviCRMPage/CiviCRMUrlStorage.php b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMUrlStorage.php new file mode 100644 index 0000000..01b66a5 --- /dev/null +++ b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMUrlStorage.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\CiviCRMPage; + +use Drupal\Component\Uuid\UuidInterface; +use Drupal\Core\Url; +use Symfony\Component\HttpFoundation\Session\SessionInterface; + +final class CiviCRMUrlStorage implements CiviCRMUrlStorageInterface { + + private SessionInterface $session; + + private UuidInterface $uuidGenerator; + + public function __construct( + SessionInterface $session, + UuidInterface $uuidGenerator + ) { + $this->session = $session; + $this->uuidGenerator = $uuidGenerator; + } + + /** + * @{inheritDoc} + */ + public function addRemoteUrl(string $url, ?string $filename = NULL): Url { + $identifier = $this->uuidGenerator->generate(); + $this->session->set('civiremote_entity.remote_url:' . $identifier, $url); + + return Url::fromRoute('civiremote_entity.remote_page_get', ['identifier' => $identifier, 'filename' => $filename]); + } + + /** + * @{inheritDoc} + */ + public function getRemoteUrl(string $identifier): ?string { + // @phpstan-ignore return.type + return $this->session->get('civiremote_entity.remote_url:' . $identifier); + } + +} diff --git a/modules/civiremote_entity/src/CiviCRMPage/CiviCRMUrlStorageInterface.php b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMUrlStorageInterface.php new file mode 100644 index 0000000..e31b51a --- /dev/null +++ b/modules/civiremote_entity/src/CiviCRMPage/CiviCRMUrlStorageInterface.php @@ -0,0 +1,54 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\CiviCRMPage; + +use Drupal\Core\Url; + +/** + * Shall be used to avoid exposing CiviCRM URLs to users. + */ +interface CiviCRMUrlStorageInterface { + + /** + * Stores the given URL in the current user's session. + * + * @param string|null $filename + * Used to generate a convenient URL (see return). Has no technical + * implication. + * + * @return \Drupal\Core\Url + * Contains the URL where the user can access the given remote URL. + * + * @see \Drupal\civiremote_entity\Controller\CiviCRMPageController + */ + public function addRemoteUrl(string $url, ?string $filename = NULL): Url; + + /** + * @param string $identifier + * Identifier that is part of the Url object returned by addRemoteUrl(). + * + * @return string|null + * URL that has been added via addRemoteUrl() before, or NULL if the given + * identifier is unknown. + */ + public function getRemoteUrl(string $identifier): ?string; + +} diff --git a/modules/civiremote_entity/src/CiviremoteEntityServiceProvider.php b/modules/civiremote_entity/src/CiviremoteEntityServiceProvider.php new file mode 100644 index 0000000..a04773b --- /dev/null +++ b/modules/civiremote_entity/src/CiviremoteEntityServiceProvider.php @@ -0,0 +1,40 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderInterface; +use Drupal\json_forms\Form\Util\FactoryRegistrator; + +/** + * @codeCoverageIgnore + */ +final class CiviremoteEntityServiceProvider implements ServiceProviderInterface { + + public function register(ContainerBuilder $container): void { + FactoryRegistrator::registerFactories( + $container, + __DIR__ . '/Form/Control', + 'Drupal\\civiremote_entity\\Form\\Control' + ); + } + +} diff --git a/modules/civiremote_entity/src/Controller/CiviCRMPageController.php b/modules/civiremote_entity/src/Controller/CiviCRMPageController.php new file mode 100644 index 0000000..2fab07d --- /dev/null +++ b/modules/civiremote_entity/src/Controller/CiviCRMPageController.php @@ -0,0 +1,52 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Controller; + +use Drupal\civiremote_entity\CiviCRMPage\CiviCRMPageProxyInterface; +use Drupal\civiremote_entity\CiviCRMPage\CiviCRMUrlStorageInterface; +use Drupal\Core\Controller\ControllerBase; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +final class CiviCRMPageController extends ControllerBase { + + private CiviCRMPageProxyInterface $civiCRMPageProxy; + + private CiviCRMUrlStorageInterface $civiCRMUrlManager; + + public function __construct( + CiviCRMPageProxyInterface $civiCRMPageProxy, + CiviCRMUrlStorageInterface $civiCRMUrlManager + ) { + $this->civiCRMPageProxy = $civiCRMPageProxy; + $this->civiCRMUrlManager = $civiCRMUrlManager; + } + + public function get(string $identifier): Response { + $remoteUrl = $this->civiCRMUrlManager->getRemoteUrl($identifier); + if (NULL === $remoteUrl) { + throw new NotFoundHttpException(); + } + + return $this->civiCRMPageProxy->get($remoteUrl); + } + +} diff --git a/modules/civiremote_entity/src/Form/Control/Callbacks/FileValueCallback.php b/modules/civiremote_entity/src/Form/Control/Callbacks/FileValueCallback.php new file mode 100644 index 0000000..9e82ef0 --- /dev/null +++ b/modules/civiremote_entity/src/Form/Control/Callbacks/FileValueCallback.php @@ -0,0 +1,75 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form\Control\Callbacks; + +use Drupal\Core\Form\FormStateInterface; +use Symfony\Component\HttpFoundation\File\UploadedFile; + +final class FileValueCallback { + + /** + * @param array $element + * @param mixed $input + * + * @return mixed + */ + public static function convert(array $element, $input, FormStateInterface $formState) { + // @todo It's currently not possible to remove an existing file. + $uploadedFile = self::getUploadedFile($element, $input); + if (NULL === $uploadedFile) { + $value = $element['#default_value'] ?? NULL; + if (NULL === $value) { + // Prevent empty string as value. Drupal sets an empty string in this + // case if no value is set in the form state. + $formState->setValueForElement($element, NULL); + } + } + else { + $value = [ + 'filename' => $uploadedFile->getClientOriginalName(), + 'content' => base64_encode($uploadedFile->getContent()), + ]; + + unlink($uploadedFile->getRealPath()); + } + + return $value; + } + + /** + * @param array $element + * @param mixed $input + */ + private static function getUploadedFile(array $element, $input): ?UploadedFile { + if (FALSE === $input) { + return NULL; + } + + /** @phpstan-var list $parents */ + $parents = $element['#parents']; + $elementName = array_shift($parents); + /** @var array $uploadedFiles */ + $uploadedFiles = \Drupal::request()->files->get('files', []); + + return $uploadedFiles[$elementName] ?? NULL; + } + +} diff --git a/modules/civiremote_entity/src/Form/Control/FileArrayFactory.php b/modules/civiremote_entity/src/Form/Control/FileArrayFactory.php new file mode 100644 index 0000000..a113980 --- /dev/null +++ b/modules/civiremote_entity/src/Form/Control/FileArrayFactory.php @@ -0,0 +1,96 @@ +. + */ + +declare(strict_types=1); + +namespace Drupal\civiremote_entity\Form\Control; + +use Assert\Assertion; +use Drupal\civiremote_entity\CiviCRMPage\CiviCRMUrlStorageInterface; +use Drupal\civiremote_entity\Form\Control\Callbacks\FileValueCallback; +use Drupal\Core\Form\FormStateInterface; +use Drupal\json_forms\Form\AbstractConcreteFormArrayFactory; +use Drupal\json_forms\Form\Control\ObjectArrayFactory; +use Drupal\json_forms\Form\Control\Util\BasicFormPropertiesFactory; +use Drupal\json_forms\Form\FormArrayFactoryInterface; +use Drupal\json_forms\JsonForms\Definition\Control\ControlDefinition; +use Drupal\json_forms\JsonForms\Definition\DefinitionInterface; + +class FileArrayFactory extends AbstractConcreteFormArrayFactory { + + public static function getPriority(): int { + return ObjectArrayFactory::getPriority() + 1; + } + + private CiviCRMUrlStorageInterface $civiCRMUrlManager; + + public function __construct(CiviCRMUrlStorageInterface $civiCRMUrlManager) { + $this->civiCRMUrlManager = $civiCRMUrlManager; + } + + /** + * {@inheritDoc} + */ + public function createFormArray( + DefinitionInterface $definition, + FormStateInterface $formState, + FormArrayFactoryInterface $formArrayFactory + ): array { + Assertion::isInstanceOf($definition, ControlDefinition::class); + /** @var \Drupal\json_forms\JsonForms\Definition\Control\ControlDefinition $definition */ + + $form = [ + 'file' => [ + '#type' => 'file', + '#value_callback' => FileValueCallback::class . '::convert', + ] + BasicFormPropertiesFactory::createFieldProperties($definition, $formState), + ]; + + if (($form['file']['#default_value'] ?? NULL) instanceof \stdClass + && is_string($form['file']['#default_value']->url ?? NULL) + && is_string($form['file']['#default_value']->filename ?? NULL) + ) { + $url = $form['file']['#default_value']->url; + $filename = $form['file']['#default_value']->filename; + + $form['file']['#required'] = FALSE; + + $form['link'] = [ + '#type' => 'link', + '#title' => $filename, + '#url' => $this->civiCRMUrlManager->addRemoteUrl($url, $filename), + '#attributes' => ['target' => '_blank'], + '#prefix' => '

', + '#suffix' => '

', + ]; + + if (isset($form['file']['#states'])) { + $form['link']['#states'] = $form['file']['#states']; + } + } + + return $form; + } + + public function supportsDefinition(DefinitionInterface $definition): bool { + return $definition instanceof ControlDefinition + && 'object' === $definition->getType() + && 'file' === $definition->getControlFormat(); + } + +}