| 1 | <?php |
| 2 | |
| 3 | use Psr\Http\Message\ResponseInterface; |
| 4 | use Psr\Http\Message\RequestInterface; |
| 5 | |
| 6 | /** |
| 7 | * Additional helpers/utilities for use as Guzzle middleware. |
| 8 | */ |
| 9 | class CRM_Utils_GuzzleMiddleware { |
| 10 | |
| 11 | /** |
| 12 | * The authx middleware sends authenticated requests via JWT. |
| 13 | * |
| 14 | * To add an authentication token to a specific request, the `$options` |
| 15 | * must specify `authx_user` or `authx_contact_id`. Examples: |
| 16 | * |
| 17 | * $http = new GuzzleHttp\Client(['authx_user' => 'admin']); |
| 18 | * $http->post('civicrm/admin', ['authx_user' => 'admin']); |
| 19 | * |
| 20 | * Supported options: |
| 21 | * - authx_ttl (int): Seconds of validity for JWT's |
| 22 | * - authx_host (string): Only send tokens for the given host. |
| 23 | * - authx_contact_id (int): The CiviCRM contact to authenticate with |
| 24 | * - authx_user (string): The CMS user to authenticate with |
| 25 | * - authx_flow (string): How to format the auth token. One of: 'param', 'xheader', 'header'. |
| 26 | * |
| 27 | * @return \Closure |
| 28 | */ |
| 29 | public static function authx($defaults = []) { |
| 30 | $defaults = array_merge([ |
| 31 | 'authx_ttl' => 60, |
| 32 | 'authx_host' => parse_url(CIVICRM_UF_BASEURL, PHP_URL_HOST), |
| 33 | 'authx_contact_id' => NULL, |
| 34 | 'authx_user' => NULL, |
| 35 | 'authx_flow' => 'param', |
| 36 | ], $defaults); |
| 37 | return function(callable $handler) use ($defaults) { |
| 38 | return function (RequestInterface $request, array $options) use ($handler, $defaults) { |
| 39 | if ($request->getUri()->getHost() !== $defaults['authx_host']) { |
| 40 | return $handler($request, $options); |
| 41 | } |
| 42 | |
| 43 | $options = array_merge($defaults, $options); |
| 44 | if (!empty($options['authx_contact_id'])) { |
| 45 | $cid = $options['authx_contact_id']; |
| 46 | } |
| 47 | elseif (!empty($options['authx_user'])) { |
| 48 | $r = civicrm_api3("Contact", "get", ["id" => "@user:" . $options['authx_user']]); |
| 49 | foreach ($r['values'] as $id => $value) { |
| 50 | $cid = $id; |
| 51 | break; |
| 52 | } |
| 53 | if (empty($cid)) { |
| 54 | throw new \RuntimeException("Failed to identify user ({$options['authx_user']})"); |
| 55 | } |
| 56 | } |
| 57 | else { |
| 58 | $cid = NULL; |
| 59 | } |
| 60 | |
| 61 | if ($cid) { |
| 62 | if (!CRM_Extension_System::singleton()->getMapper()->isActiveModule('authx')) { |
| 63 | throw new \RuntimeException("Authx is not enabled. Authenticated requests will not work."); |
| 64 | } |
| 65 | $tok = \Civi::service('crypto.jwt')->encode([ |
| 66 | 'exp' => time() + $options['authx_ttl'], |
| 67 | 'sub' => 'cid:' . $cid, |
| 68 | 'scope' => 'authx', |
| 69 | ]); |
| 70 | |
| 71 | switch ($options['authx_flow']) { |
| 72 | case 'header': |
| 73 | $request = $request->withHeader('Authorization', "Bearer $tok"); |
| 74 | break; |
| 75 | |
| 76 | case 'xheader': |
| 77 | $request = $request->withHeader('X-Civi-Auth', "Bearer $tok"); |
| 78 | break; |
| 79 | |
| 80 | case 'param': |
| 81 | if ($request->getMethod() === 'POST') { |
| 82 | if (!empty($request->getHeader('Content-Type')) && !preg_grep(';application/x-www-form-urlencoded;', $request->getHeader('Content-Type'))) { |
| 83 | throw new \RuntimeException("Cannot append authentication credentials to HTTP POST. Unrecognized content type."); |
| 84 | } |
| 85 | $query = (string) $request->getBody(); |
| 86 | $request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded'); |
| 87 | $request = new GuzzleHttp\Psr7\Request( |
| 88 | $request->getMethod(), |
| 89 | $request->getUri(), |
| 90 | $request->getHeaders(), |
| 91 | http_build_query(['_authx' => "Bearer $tok"]) . ($query ? '&' : '') . $query |
| 92 | ); |
| 93 | } |
| 94 | else { |
| 95 | $query = $request->getUri()->getQuery(); |
| 96 | $request = $request->withUri($request->getUri()->withQuery( |
| 97 | http_build_query(['_authx' => "Bearer $tok"]) . ($query ? '&' : '') . $query |
| 98 | )); |
| 99 | } |
| 100 | break; |
| 101 | |
| 102 | default: |
| 103 | throw new \RuntimeException("Unrecognized authx flow: {$options['authx_flow']}"); |
| 104 | } |
| 105 | } |
| 106 | return $handler($request, $options); |
| 107 | }; |
| 108 | }; |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Add this as a Guzzle handler/middleware if you wish to simplify |
| 113 | * the construction of Civi-related URLs. It enables URL schemes for: |
| 114 | * |
| 115 | * - route://ROUTE_NAME (aka) route:ROUTE_NAME |
| 116 | * - backend://ROUTE_NAME (aka) backend:ROUTE_NAME |
| 117 | * - frontend://ROUTE_NAME (aka) frontend:ROUTE_NAME |
| 118 | * - var://PATH_EXPRESSION (aka) var:PATH_EXPRESSION |
| 119 | * - ext://EXTENSION/FILE (aka) ext:EXTENSION/FILE |
| 120 | * - assetBuilder://ASSET_NAME?PARAMS (aka) assetBuilder:ASSET_NAME?PARAMS |
| 121 | * |
| 122 | * Compare: |
| 123 | * |
| 124 | * $http->get(CRM_Utils_System::url('civicrm/dashboard', NULL, TRUE, NULL, FALSE, ??)) |
| 125 | * $http->get('route://civicrm/dashboard') |
| 126 | * $http->get('frontend://civicrm/dashboard') |
| 127 | * $http->get('backend://civicrm/dashboard') |
| 128 | * |
| 129 | * $http->get(Civi::paths()->getUrl('[civicrm.files]/foo.txt')) |
| 130 | * $http->get('var:[civicrm.files]/foo.txt') |
| 131 | * |
| 132 | * $http->get(Civi::resources()->getUrl('my.other.ext', 'foo.js')) |
| 133 | * $http->get('ext:my.other.ext/foo.js') |
| 134 | * |
| 135 | * $http->get(Civi::service('asset_builder')->getUrl('my-asset.css', ['a'=>1, 'b'=>2])) |
| 136 | * $http->get('assetBuilder:my-asset.css?a=1&b=2') |
| 137 | * |
| 138 | * Note: To further simplify URL expressions, Guzzle allows you to set a 'base_uri' |
| 139 | * option (which is applied as a prefix to any relative URLs). Consider using |
| 140 | * `base_uri=auto:`. This allows you to implicitly use the most common types |
| 141 | * (routes+variables): |
| 142 | * |
| 143 | * $http->get('civicrm/dashboard') |
| 144 | * $http->get('[civicrm.files]/foo.txt') |
| 145 | * |
| 146 | * @return \Closure |
| 147 | */ |
| 148 | public static function url() { |
| 149 | return function(callable $handler) { |
| 150 | return function (RequestInterface $request, array $options) use ($handler) { |
| 151 | $newUri = self::filterUri($request->getUri()); |
| 152 | if ($newUri !== NULL) { |
| 153 | $request = $request->withUri(\CRM_Utils_Url::parseUrl($newUri)); |
| 154 | } |
| 155 | |
| 156 | return $handler($request, $options); |
| 157 | }; |
| 158 | }; |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * @param \Psr\Http\Message\UriInterface $oldUri |
| 163 | * |
| 164 | * @return string|null |
| 165 | * The string formation of the new URL, or NULL for unchanged URLs. |
| 166 | */ |
| 167 | protected static function filterUri(\Psr\Http\Message\UriInterface $oldUri) { |
| 168 | // Copy the old ?query-params and #fragment-params on top of $newBase. |
| 169 | $copyParams = function ($newBase) use ($oldUri) { |
| 170 | if ($oldUri->getQuery()) { |
| 171 | $newBase .= strpos($newBase, '?') !== FALSE ? '&' : '?'; |
| 172 | $newBase .= $oldUri->getQuery(); |
| 173 | } |
| 174 | if ($oldUri->getFragment()) { |
| 175 | $newBase .= '#' . $oldUri->getFragment(); |
| 176 | } |
| 177 | return $newBase; |
| 178 | }; |
| 179 | |
| 180 | $hostPath = urldecode($oldUri->getHost() . $oldUri->getPath()); |
| 181 | $scheme = $oldUri->getScheme(); |
| 182 | if ($scheme === 'auto') { |
| 183 | // Ex: 'auto:civicrm/my-page' ==> Router |
| 184 | // Ex: 'auto:[civicrm.root]/js/foo.js' ==> Resource file |
| 185 | $scheme = ($hostPath[0] === '[') ? 'var' : 'route'; |
| 186 | } |
| 187 | |
| 188 | if ($scheme === 'route') { |
| 189 | $menu = CRM_Core_Menu::get($hostPath); |
| 190 | $scheme = ($menu && !empty($menu['is_public'])) ? 'frontend' : 'backend'; |
| 191 | } |
| 192 | |
| 193 | switch ($scheme) { |
| 194 | case 'assetBuilder': |
| 195 | // Ex: 'assetBuilder:dynamic.css' or 'assetBuilder://dynamic.css?foo=bar' |
| 196 | // Note: It's more useful to pass params to the asset-builder than to the final HTTP request. |
| 197 | $assetParams = []; |
| 198 | parse_str('' . $oldUri->getQuery(), $assetParams); |
| 199 | return \Civi::service('asset_builder')->getUrl($hostPath, $assetParams); |
| 200 | |
| 201 | case 'ext': |
| 202 | // Ex: 'ext:other.ext.name/file.js' or 'ext://other.ext.name/file.js' |
| 203 | [$ext, $file] = explode('/', $hostPath, 2); |
| 204 | return $copyParams(\Civi::resources()->getUrl($ext, $file)); |
| 205 | |
| 206 | case 'var': |
| 207 | // Ex: 'var:[civicrm.files]/foo.txt' or 'var://[civicrm.files]/foo.txt' |
| 208 | return $copyParams(\Civi::paths()->getUrl($hostPath, 'absolute')); |
| 209 | |
| 210 | case 'backend': |
| 211 | // Ex: 'backend:civicrm/my-page' or 'backend://civicrm/my-page' |
| 212 | return $copyParams(\CRM_Utils_System::url($hostPath, NULL, TRUE, NULL, FALSE)); |
| 213 | |
| 214 | case 'frontend': |
| 215 | // Ex: 'frontend:civicrm/my-page' or 'frontend://civicrm/my-page' |
| 216 | return $copyParams(\CRM_Utils_System::url($hostPath, NULL, TRUE, NULL, FALSE, TRUE)); |
| 217 | |
| 218 | default: |
| 219 | return NULL; |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * This logs the list of outgoing requests in curl format. |
| 225 | */ |
| 226 | public static function curlLog(\Psr\Log\LoggerInterface $logger) { |
| 227 | |
| 228 | $curlFmt = new class() extends \GuzzleHttp\MessageFormatter { |
| 229 | |
| 230 | public function format(RequestInterface $request, ResponseInterface $response = NULL, \Exception $error = NULL) { |
| 231 | $cmd = '$ curl'; |
| 232 | if ($request->getMethod() !== 'GET') { |
| 233 | $cmd .= ' -X ' . escapeshellarg($request->getMethod()); |
| 234 | } |
| 235 | foreach ($request->getHeaders() as $header => $lines) { |
| 236 | foreach ($lines as $line) { |
| 237 | $cmd .= ' -H ' . escapeshellarg("$header: $line"); |
| 238 | } |
| 239 | } |
| 240 | $body = (string) $request->getBody(); |
| 241 | if ($body !== '') { |
| 242 | $cmd .= ' -d ' . escapeshellarg($body); |
| 243 | } |
| 244 | $cmd .= ' ' . escapeshellarg((string) $request->getUri()); |
| 245 | return $cmd; |
| 246 | } |
| 247 | |
| 248 | }; |
| 249 | |
| 250 | return \GuzzleHttp\Middleware::log($logger, $curlFmt); |
| 251 | } |
| 252 | |
| 253 | } |