province abbreviation patch - issue 724
[civicrm-core.git] / CRM / Utils / GuzzleMiddleware.php
CommitLineData
f344f1e3
TO
1<?php
2
12b478ef 3use Psr\Http\Message\ResponseInterface;
f344f1e3
TO
4use Psr\Http\Message\RequestInterface;
5
6/**
7 * Additional helpers/utilities for use as Guzzle middleware.
8 */
9class CRM_Utils_GuzzleMiddleware {
10
ee4d3a2d
TO
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
f344f1e3
TO
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
1c5f00b3
TO
116 * - backend://ROUTE_NAME (aka) backend:ROUTE_NAME
117 * - frontend://ROUTE_NAME (aka) frontend:ROUTE_NAME
f344f1e3
TO
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 *
1c5f00b3
TO
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')
f344f1e3
TO
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
1c5f00b3
TO
188 if ($scheme === 'route') {
189 $menu = CRM_Core_Menu::get($hostPath);
190 $scheme = ($menu && !empty($menu['is_public'])) ? 'frontend' : 'backend';
191 }
192
f344f1e3
TO
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
1c5f00b3
TO
210 case 'backend':
211 // Ex: 'backend:civicrm/my-page' or 'backend://civicrm/my-page'
f344f1e3
TO
212 return $copyParams(\CRM_Utils_System::url($hostPath, NULL, TRUE, NULL, FALSE));
213
1c5f00b3
TO
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
f344f1e3
TO
218 default:
219 return NULL;
220 }
221 }
222
12b478ef
TO
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
f344f1e3 253}