Skip to content

Commit

Permalink
[civiremote_entity] Add support for files in forms
Browse files Browse the repository at this point in the history
  • Loading branch information
Dominic Tubach committed Aug 5, 2024
1 parent 6388aa9 commit 6e6424f
Show file tree
Hide file tree
Showing 14 changed files with 734 additions and 7 deletions.
16 changes: 16 additions & 0 deletions modules/civiremote_entity/civiremote_entity.routing.yml
Original file line number Diff line number Diff line change
@@ -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'
28 changes: 28 additions & 0 deletions modules/civiremote_entity/civiremote_entity.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' ]
Expand All @@ -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
17 changes: 10 additions & 7 deletions modules/civiremote_entity/src/Access/RemoteContactIdProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@

namespace Drupal\civiremote_entity\Access;

use Assert\Assertion;
use Drupal\Core\Session\AccountProxyInterface;

final class RemoteContactIdProvider implements RemoteContactIdProviderInterface {
Expand All @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@

interface RemoteContactIdProviderInterface {

/**
* @throws \RuntimeException
* If current user has no remote contact ID.
*/
public function getRemoteContactId(): string;

public function hasRemoteContactId(): bool;

}
101 changes: 101 additions & 0 deletions modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

/*
* Copyright (C) 2024 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation in version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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<string, string|null>
*/
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() : '',
];
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* Copyright (C) 2024 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation in version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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<string, mixed> $options
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function request(string $method, string $uri, array $options = []): ResponseInterface;

}
116 changes: 116 additions & 0 deletions modules/civiremote_entity/src/CiviCRMPage/CiviCRMPageProxy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

/*
* Copyright (C) 2024 SYSTOPIA GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation in version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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<string, array<string>>
*/
private function buildResponseHeaders(ResponseInterface $remoteResponse): array {
$headers = [];
foreach (self::RETURNED_HEADERS as $headerName) {
if ($remoteResponse->hasHeader($headerName)) {
$headers[$headerName] = $remoteResponse->getHeader($headerName);
}
}

return $headers;
}

}
Loading

0 comments on commit 6e6424f

Please sign in to comment.