Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
193 / 193 |
|
100.00% |
15 / 15 |
CRAP | |
100.00% |
1 / 1 |
Taproot\Micropub\getAccessToken | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
7 | |||
Taproot\Micropub\normalizeUrlencodedCreateRequest | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
MicropubAdapter | |
100.00% |
177 / 177 |
|
100.00% |
13 / 13 |
81 | |
100.00% |
1 / 1 |
verifyAccessTokenCallback | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
extensionCallback | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
configurationQueryCallback | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
sourceQueryCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
deleteCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
undeleteCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
updateCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
createCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
mediaEndpointCallback | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
mediaEndpointExtensionCallback | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getLogger | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
handleRequest | |
100.00% |
110 / 110 |
|
100.00% |
1 / 1 |
48 | |||
handleMediaEndpointRequest | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
11 | |||
toResponse | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
11 |
1 | <?php declare(strict_types=1); |
2 | |
3 | namespace Taproot\Micropub; |
4 | |
5 | use Nyholm\Psr7\Response; |
6 | use Psr\Http\Message\RequestInterface; |
7 | use Psr\Http\Message\ServerRequestInterface; |
8 | use Psr\Http\Message\ResponseInterface; |
9 | use Psr\Http\Message\UploadedFileInterface; |
10 | use Psr\Log\LoggerInterface; |
11 | use Psr\Log\NullLogger; |
12 | |
13 | const MICROPUB_ERROR_CODES = ['invalid_request', 'unauthorized', 'insufficient_scope', 'forbidden']; |
14 | |
15 | /** |
16 | * Micropub Adapter Abstract Superclass |
17 | * |
18 | * Subclass this class and implement the various `*Callback()` methods to handle different |
19 | * types of micropub request. |
20 | * |
21 | * Then, handling a micropub request is as simple as |
22 | * |
23 | * $mp = new YourMicropubAdapter(); |
24 | * return $mp->handleRequest($request); |
25 | * |
26 | * The same goes for a media endpoint: |
27 | * |
28 | * return $mp->handleMediaEndpointRequest($request); |
29 | * |
30 | * Subclasses **must** implement the abstract callback method `verifyAccessToken()` in order |
31 | * to have a functional micropub endpoint. All other callback methods are optional, and |
32 | * their functionality is enabled if a subclass implements them. Feel free to define your own |
33 | * constructor, and make any implementation-specific objects available to callbacks by storing |
34 | * them as properties. |
35 | * |
36 | * Each callback is passed data corresponding to the type of micropub request dispatched |
37 | * to it, but can also access the original request via `$this->request`. Data about the |
38 | * currently authenticated user is available in `$this->user`. |
39 | * |
40 | * Each callback return data in a format defined by the callback, which will be |
41 | * converted into the appropriate HTTP Response. Returning an instance of `ResponseInterface` |
42 | * from a callback will cause that response to immediately be returned unchanged. Most callbacks |
43 | * will also automatically convert an array return value into a JSON response, and will convert |
44 | * the following string error codes into properly formatted micropub error responses: |
45 | * |
46 | * * `'invalid_request'` |
47 | * * `'insufficient_scope'` |
48 | * * `'unauthorized'` |
49 | * * `'forbidden'` |
50 | * |
51 | * In practise, you’ll mostly be returning the first two, as the others are handled automatically. |
52 | * |
53 | * MicropubAdapter **does not handle any authorization or permissions**, as which users and |
54 | * scopes have what permissions depends on your implementation. It’s up to you to confirm that |
55 | * the current access token has sufficient scope and permissions to carry out any given action |
56 | * within your callback, and return `'insufficient_scope'` or your own custom instance of |
57 | * `ResponseInterface`. |
58 | * |
59 | * Most callbacks halt execution, but some are optional. Returning a falsy value from these |
60 | * optional callbacks continues execution uninterrupted. This is usually to allow you to pre- |
61 | * empt standard micropub handling and implement custom extensions. |
62 | * |
63 | * MicropubAdapter works with PSR-7 HTTP Interfaces. Specifically, expects an object implementing |
64 | * `ServerRequestInterface`, and will return an object implemeting `ResponseInterface`. If you |
65 | * want to return responses from your callbacks, you’re free to use any suitable implementation. |
66 | * For internally-generated responses, `Nyholm\Psr7\Response` is used. |
67 | * |
68 | * If you’re not using a framework which works with PSR-7 objects, you’ll have to convert |
69 | * whatever request data you have into something implementing PSR-7 `ServerRequestInterface`, |
70 | * and convert the returned `ResponseInterface`s to something you can work with. |
71 | * |
72 | * @link https://micropub.spec.indieweb.org/ |
73 | * @link https://indieweb.org/micropub |
74 | */ |
75 | abstract class MicropubAdapter { |
76 | |
77 | /** |
78 | * @var array $user The validated access_token, made available for use in callback methods. |
79 | */ |
80 | public $user; |
81 | |
82 | /** |
83 | * @var RequestInterface $request The current request, made available for use in callback methods. |
84 | */ |
85 | public $request; |
86 | |
87 | /** |
88 | * @var null|LoggerInterface $logger The logger used by MicropubAdaptor for internal logging. |
89 | */ |
90 | public $logger; |
91 | |
92 | /** |
93 | * @var string[] $errorMessages An array mapping micropub and adapter-specific error codes to human-friendly descriptions. |
94 | */ |
95 | private $errorMessages = [ |
96 | // Built-in micropub error types |
97 | 'insufficient_scope' => 'Your access token does not grant the scope required for this action.', |
98 | 'forbidden' => 'The authenticated user does not have permission to perform this request.', |
99 | 'unauthorized' => 'The request did not provide an access token.', |
100 | 'invalid_request' => 'The request was invalid.', |
101 | // Custom errors |
102 | 'access_token_invalid' => 'The provided access token could not be verified.', |
103 | 'missing_url_parameter' => 'The request did not provide the required url parameter.', |
104 | 'post_with_given_url_not_found' => 'A post with the given URL could not be found.', |
105 | 'not_implemented' => 'This functionality is not implemented.', |
106 | ]; |
107 | |
108 | /** |
109 | * Verify Access Token Callback |
110 | * |
111 | * Given an access token, attempt to verify it. |
112 | * |
113 | * * If it’s valid, return an array to be stored in `$this->user`, which typically looks |
114 | * something like this: |
115 | * |
116 | * [ |
117 | * 'me' => 'https://example.com', |
118 | * 'client_id' => 'https://clientapp.example', |
119 | * 'scope' => ['array', 'of', 'granted', 'scopes'], |
120 | * 'date_issued' => \Datetime |
121 | * ] |
122 | * * If the toke in invalid, return one of the following: |
123 | * * `false`, which will be converted into an appropriate error message. |
124 | * * `'forbidden'`, which will be converted into an appropriate error message. |
125 | * * An array to be converted into an error response, with the form: |
126 | * |
127 | * [ |
128 | * 'error': 'forbidden' |
129 | * 'error_description': 'Your custom error description' |
130 | * ] |
131 | * * Your own instance of `ResponseInterface` |
132 | * |
133 | * |
134 | * MicropubAdapter treats the data as being opaque, and simply makes it |
135 | * available to your callback methods for further processing, so you’re free |
136 | * to structure it however you want. |
137 | * |
138 | * @param string $token The Authentication: Bearer access token. |
139 | * @return array|string|false|ResponseInterface |
140 | * @link https://micropub.spec.indieweb.org/#authentication-0 |
141 | * @api |
142 | */ |
143 | abstract public function verifyAccessTokenCallback(string $token); |
144 | |
145 | /** |
146 | * Micropub Extension Callback |
147 | * |
148 | * This callback is called after an access token is verified, but before any micropub- |
149 | * specific handling takes place. This is the place to implement support for micropub |
150 | * extensions. |
151 | * |
152 | * If a falsy value is returned, the request continues to be handled as a regular |
153 | * micropub request. If it returns a truthy value (either a MP error code, an array to |
154 | * be returned as JSON, or a ready-made ResponseInterface), request handling is halted |
155 | * and the returned value is converted into a response and returned. |
156 | * |
157 | * @param ServerRequestInterface $request |
158 | * @return false|array|string|ResponseInterface |
159 | * @link https://indieweb.org/Micropub-extensions |
160 | * @api |
161 | */ |
162 | public function extensionCallback(ServerRequestInterface $request) { |
163 | // Default implementation: no-op; |
164 | return false; |
165 | } |
166 | |
167 | /** |
168 | * Configuration Query Callback |
169 | * |
170 | * Handle a GET q=config query. Should return either a custom ResponseInterface, or an |
171 | * array structure conforming to the micropub specification, e.g.: |
172 | * |
173 | * [ |
174 | * 'media-endpoint' => 'http://example.com/your-media-endpoint', |
175 | * 'syndicate-to' => [[ |
176 | * 'uid' => 'https://myfavoritesocialnetwork.example/aaronpk', // Required |
177 | * 'name' => 'aaronpk on myfavoritesocialnetwork', // Required |
178 | * 'service' => [ // Optional |
179 | * 'name' => 'My Favorite Social Network', |
180 | * 'url' => 'https://myfavoritesocialnetwork.example/', |
181 | * 'photo' => 'https://myfavoritesocialnetwork.example/img/icon.png', |
182 | * ], |
183 | * 'user' => [ // Optional |
184 | * 'name' => 'aaronpk', |
185 | * 'photo' => 'https://myfavoritesocialnetwork.example/aaronpk', |
186 | * 'url' => 'https://myfavoritesocialnetwork.example/aaronpk/photo.jpg' |
187 | * ] |
188 | * ]] |
189 | * ] |
190 | * |
191 | * The results from this function are also used to respond to syndicate-to queries. If |
192 | * a raw ResponseInterface is returned, that will be used as-is. If an array structure |
193 | * is returned, syndicate-to queries will extract the syndicate-to information and |
194 | * return just that. |
195 | * |
196 | * @param array $params The unaltered query string parameters from the request. |
197 | * @return array|string|ResponseInterface Return either an array with config data, a micropub error string, or a ResponseInterface to short-circuit |
198 | * @link https://micropub.spec.indieweb.org/#configuration |
199 | * @api |
200 | */ |
201 | public function configurationQueryCallback(array $params) { |
202 | // Default response: an empty JSON object. |
203 | return $this->toResponse(null); |
204 | } |
205 | |
206 | /** |
207 | * Source Query Callback |
208 | * |
209 | * Handle a GET q=source query. |
210 | * |
211 | * The callback should return a microformats2 canonical JSON representation |
212 | * of the post identified by $url, either as an array or as a ready-made ResponseInterface. |
213 | * |
214 | * If the post identified by $url cannot be found, returning false will return a |
215 | * correctly-formatted error response. Alternatively, you can return a string micropub |
216 | * error code (e.g. `'invalid_request'`) or your own instance of `ResponseInterface`. |
217 | * |
218 | * @param string $url The URL of the post for which to return properties. |
219 | * @param array|null $properties = null The list of properties to return (all if null) |
220 | * @return array|false|string|ResponseInterface Return either an array with canonical mf2 data, false if the post could not be found, a micropub error string, or a ResponseInterface to short-circuit. |
221 | * @link https://micropub.spec.indieweb.org/#source-content |
222 | * @api |
223 | */ |
224 | public function sourceQueryCallback(string $url, array $properties = null) { |
225 | // Default response: not implemented. |
226 | return $this->toResponse([ |
227 | 'error' => 'invalid_request', |
228 | 'error_description' => $this->errorMessages['not_implemented'] |
229 | ]); |
230 | } |
231 | |
232 | /** |
233 | * Delete Callback |
234 | * |
235 | * Handle a POST action=delete request. |
236 | * |
237 | * * Look for a post identified by the $url parameter. |
238 | * * If it doesn’t exist: return `false` or `'invalid_request'` as a shortcut for an |
239 | * HTTP 400 invalid_request response. |
240 | * * If the current access token scope doesn’t permit deletion, return `'insufficient_scope'`, |
241 | * an array with `'error'` and `'error_description'` keys, or your own ResponseInterface. |
242 | * * If the post exists and can be deleted or is already deleted, delete it and return true. |
243 | * |
244 | * @param string $url The URL of the post to be deleted. |
245 | * @return string|true|array|ResponseInterface |
246 | * @link https://micropub.spec.indieweb.org/#delete |
247 | * @api |
248 | */ |
249 | public function deleteCallback(string $url) { |
250 | // Default response: not implemented. |
251 | return $this->toResponse([ |
252 | 'error' => 'invalid_request', |
253 | 'error_description' => $this->errorMessages['not_implemented'] |
254 | ]); |
255 | } |
256 | |
257 | /** |
258 | * Undelete Callback |
259 | * |
260 | * Handle a POST action=undelete request. |
261 | * |
262 | * * Look for a post identified by the $url parameter. |
263 | * * If it doesn’t exist: return `false` or `'invalid_request'` as a shortcut for an |
264 | * HTTP 400 invalid_request response. |
265 | * * If the current access token scope doesn’t permit undeletion, return `'insufficient_scope'`, |
266 | * an array with `'error'` and `'error_description'` keys, or your own ResponseInterface. |
267 | * * If the post exists and can be undeleted, do so. Return true for success, or a URL if the |
268 | * undeletion caused the post’s URL to change. |
269 | * |
270 | * @param string $url The URL of the post to be undeleted. |
271 | * @return string|true|array|ResponseInterface true on basic success, otherwise either an error string, or a URL if the undeletion caused the post’s location to change. |
272 | * @link https://micropub.spec.indieweb.org/#delete |
273 | * @api |
274 | */ |
275 | public function undeleteCallback(string $url) { |
276 | // Default response: not implemented. |
277 | return $this->toResponse([ |
278 | 'error' => 'invalid_request', |
279 | 'error_description' => $this->errorMessages['not_implemented'] |
280 | ]); |
281 | } |
282 | |
283 | /** |
284 | * Update Callback |
285 | * |
286 | * Handles a POST action=update request. |
287 | * |
288 | * * Look for a post identified by the $url parameter. |
289 | * * If it doesn’t exist: return `false` or `'invalid_request'` as a shortcut for an |
290 | * HTTP 400 invalid_request response. |
291 | * * If the current access token scope doesn’t permit updates, return `'insufficient_scope'`, |
292 | * an array with `'error'` and `'error_description'` keys, or your own ResponseInterface. |
293 | * * If the post exists and can be updated, do so. Return true for basic success, or a URL if the |
294 | * undeletion caused the post’s URL to change. |
295 | * |
296 | * @param string $url The URL of the post to be updated. |
297 | * @param array $actions The parsed body of the request, containing 'replace', 'add' and/or 'delete' keys describing the operations to perfom on the post. |
298 | * @return true|string|array|ResponseInterface Return true for a basic success, a micropub error string, an array to be converted to a JSON response, or a ready-made ResponseInterface |
299 | * @link https://micropub.spec.indieweb.org/#update |
300 | * @api |
301 | */ |
302 | public function updateCallback(string $url, array $actions) { |
303 | // Default response: not implemented. |
304 | return $this->toResponse([ |
305 | 'error' => 'invalid_request', |
306 | 'error_description' => $this->errorMessages['not_implemented'] |
307 | ]); |
308 | } |
309 | |
310 | /** |
311 | * Create Callback |
312 | * |
313 | * Handles a create request. JSON parameters are left unchanged, urlencoded |
314 | * form parameters are normalized into canonical microformats-2 JSON form. |
315 | * |
316 | * * If the current access token scope doesn’t permit updates, return either |
317 | * `'insufficient_scope'`, an array with `'error'` and `'error_description'` |
318 | * keys, or your own ResponseInterface. |
319 | * * Create the post. |
320 | * * On an error, return either a micropub error code to be upgraded into a |
321 | * full error response, or your own ResponseInterface. |
322 | * * On success, return either the URL of the created post to be upgraded into |
323 | * a HTTP 201 success response, or your own ResponseInterface. |
324 | * |
325 | * @param array $data The data to create a post with in canonical MF2 structure |
326 | * @param array $uploadedFiles an associative array mapping property names to UploadedFileInterface objects, or arrays thereof |
327 | * @return string|array|ResponseInterface A URL on success, a micropub error code, an array to be returned as JSON response, or a ready-made ResponseInterface |
328 | * @link https://micropub.spec.indieweb.org/#create |
329 | * @api |
330 | */ |
331 | public function createCallback(array $data, array $uploadedFiles) { |
332 | // Default response: not implemented. |
333 | return $this->toResponse([ |
334 | 'error' => 'invalid_request', |
335 | 'error_description' => $this->errorMessages['not_implemented'] |
336 | ]); |
337 | } |
338 | |
339 | /** |
340 | * Media Endpoint Callback |
341 | * |
342 | * To handle file upload requests: |
343 | * |
344 | * * If the current access token scope doesn’t permit uploads, return either |
345 | * `'insufficient_scope'`, an array with `'error'` and `'error_description'` |
346 | * keys, or your own ResponseInterface. |
347 | * * Handle the uploaded file. |
348 | * * On an error, return either a micropub error code to be upgraded into a |
349 | * full error response, or your own ResponseInterface. |
350 | * * On success, return either the URL of the created URL to be upgraded into |
351 | * a HTTP 201 success response, or your own ResponseInterface. |
352 | * |
353 | * @param UploadedFileInterface $file The file to upload |
354 | * @return string|array|ResponseInterface Return the URL of the uploaded file on success, a micropub error code to be upgraded into an error response, an array for a JSON response, or a ready-made ResponseInterface |
355 | * @link https://micropub.spec.indieweb.org/#media-endpoint |
356 | * @api |
357 | */ |
358 | public function mediaEndpointCallback(UploadedFileInterface $file) { |
359 | // Default implementation: not implemented. |
360 | return $this->toResponse([ |
361 | 'error' => 'invalid_request', |
362 | 'error_description' => $this->errorMessages['not_implemented'] |
363 | ]); |
364 | } |
365 | |
366 | /** |
367 | * Micropub Media Endpoint Extension Callback |
368 | * |
369 | * This callback is called after an access token is verified, but before any media- |
370 | * endpoint-specific handling takes place. This is the place to implement support |
371 | * for micropub media endpoint extensions. |
372 | * |
373 | * If a falsy value is returned, the request continues to be handled as a regular |
374 | * micropub request. If it returns a truthy value (either a MP error code, an array to |
375 | * be returned as JSON, or a ready-made ResponseInterface), request handling is halted |
376 | * and the returned value is converted into a response and returned. |
377 | * |
378 | * @param ServerRequestInterface $request |
379 | * @return false|array|string|ResponseInterface |
380 | * @link https://indieweb.org/Micropub-extensions |
381 | */ |
382 | public function mediaEndpointExtensionCallback(ServerRequestInterface $request) { |
383 | // Default implementation: no-op. |
384 | return false; |
385 | } |
386 | |
387 | /** |
388 | * Get Logger |
389 | * |
390 | * Returns an instance of Psr\LoggerInterface, used for logging. Override to |
391 | * provide with your logger of choice. |
392 | * |
393 | * @return \Psr\Log\LoggerInterface |
394 | */ |
395 | protected function getLogger(): LoggerInterface { |
396 | if (!isset($this->logger)) { |
397 | $this->logger = new NullLogger(); |
398 | } |
399 | return $this->logger; |
400 | } |
401 | |
402 | /** |
403 | * Handle Micropub Request |
404 | * |
405 | * Handle an incoming request to a micropub endpoint, performing error checking and |
406 | * handing execution off to the appropriate callback. |
407 | * |
408 | * `$this->request` is set to the value of the `$request` argument, for use within |
409 | * callbacks. If the access token could be verified, `$this->user` is set to the value |
410 | * returned from `verifyAccessTokenCallback()` for use within callbacks. |
411 | * |
412 | * @param ServerRequestInterface $request |
413 | * @return ResponseInterface |
414 | */ |
415 | public function handleRequest(ServerRequestInterface $request) { |
416 | // Make $request available to callbacks. |
417 | $this->request = $request; |
418 | |
419 | $logger = $this->getLogger(); |
420 | |
421 | // Get and verify auth token. |
422 | $accessToken = getAccessToken($request); |
423 | if ($accessToken === null) { |
424 | $logger->warning($this->errorMessages['unauthorized']); |
425 | return $this->toResponse('unauthorized'); |
426 | } |
427 | |
428 | $accessTokenResult = $this->verifyAccessTokenCallback($accessToken); |
429 | if ($accessTokenResult instanceof ResponseInterface) { |
430 | return $accessTokenResult; // Short-circuit. |
431 | } elseif (is_array($accessTokenResult)) { |
432 | // Log success. |
433 | $logger->info('Access token verified successfully.', ['user' => $accessTokenResult]); |
434 | $this->user = $accessTokenResult; |
435 | } else { |
436 | // Log error, return not authorized response. |
437 | $logger->error($this->errorMessages['access_token_invalid']); |
438 | return $this->toResponse('forbidden'); |
439 | } |
440 | |
441 | // Give subclasses an opportunity to pre-emptively handle any extension cases before moving on to |
442 | // standard micropub handling. |
443 | $extensionCallbackResult = $this->extensionCallback($request); |
444 | if ($extensionCallbackResult) { |
445 | return $this->toResponse($extensionCallbackResult); |
446 | } |
447 | |
448 | // Check against method. |
449 | if (strtolower($request->getMethod()) == 'get') { |
450 | $queryParams = $request->getQueryParams(); |
451 | |
452 | if (isset($queryParams['q']) and is_string($queryParams['q'])) { |
453 | $q = $queryParams['q']; |
454 | if ($q == 'config') { |
455 | // Handle configuration query. |
456 | $logger->info('Handling config query', $queryParams); |
457 | return $this->toResponse($this->configurationQueryCallback($queryParams)); |
458 | } elseif ($q == 'source') { |
459 | // Handle source query. |
460 | $logger->info('Handling source query', $queryParams); |
461 | |
462 | // Normalize properties([]) paramter. |
463 | if (isset($queryParams['properties']) and is_array($queryParams['properties'])) { |
464 | $sourceProperties = $queryParams['properties']; |
465 | } elseif (isset($queryParams['properties']) and is_string($queryParams['properties'])) { |
466 | $sourceProperties = [$queryParams['properties']]; |
467 | } else { |
468 | $sourceProperties = null; |
469 | } |
470 | |
471 | // Check for a valid (string) url parameter. |
472 | if (!isset($queryParams['url']) or !is_string($queryParams['url'])) { |
473 | $logger->error($this->errorMessages['missing_url_parameter']); |
474 | return $this->toResponse(json_encode([ |
475 | 'error' => 'invalid_request', |
476 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
477 | ]), 400); |
478 | } |
479 | |
480 | $sourceQueryResult = $this->sourceQueryCallback($queryParams['url'], $sourceProperties); |
481 | if ($sourceQueryResult === false) { |
482 | // Returning false is a shortcut for an “invalid URL” error. |
483 | $logger->error($this->errorMessages['post_with_given_url_not_found']); |
484 | $sourceQueryResult = [ |
485 | 'error' => 'invalid_request', |
486 | 'error_description' => $this->errorMessages['post_with_given_url_not_found'] |
487 | ]; |
488 | } |
489 | |
490 | return $this->toResponse($sourceQueryResult); |
491 | } elseif ($q == 'syndicate-to') { |
492 | // Handle syndicate-to query via the configuration query callback. |
493 | $logger->info('Handling syndicate-to query.', $queryParams); |
494 | $configQueryResult = $this->configurationQueryCallback($queryParams); |
495 | if ($configQueryResult instanceof ResponseInterface) { |
496 | // Short-circuit, assume that the response from q=config will suffice for q=syndicate-to. |
497 | return $configQueryResult; |
498 | } elseif (is_array($configQueryResult) and array_key_exists('syndicate-to', $configQueryResult)) { |
499 | return new Response(200, ['content-type' => 'application/json'], json_encode([ |
500 | 'syndicate-to' => $configQueryResult['syndicate-to'] |
501 | ])); |
502 | } else { |
503 | // We don’t have anything to return, so return an empty result. |
504 | return new Response(200, ['content-type' => 'application/json'], '{"syndicate-to": []}'); |
505 | } |
506 | } |
507 | } |
508 | |
509 | // We weren’t able to handle this GET request. |
510 | $logger->error('Micropub endpoint was not able to handle GET request', $queryParams); |
511 | return $this->toResponse('invalid_request'); |
512 | } elseif (strtolower($request->getMethod()) == 'post') { |
513 | $contentType = $request->getHeaderLine('content-type'); |
514 | $jsonRequest = str_contains($contentType, 'application/json'); |
515 | |
516 | // Get a parsed body sufficient to determine the nature of the request. |
517 | if ($jsonRequest) { |
518 | $parsedBody = json_decode((string) $request->getBody(), true); |
519 | } else { |
520 | $parsedBody = $request->getParsedBody(); |
521 | } |
522 | |
523 | // The rest of the code assumes that parsedBody is an array. If we don’t have an array by now, |
524 | // the request is invalid. |
525 | if (!is_array($parsedBody)) { |
526 | return $this->toResponse('invalid_request'); |
527 | } |
528 | |
529 | // Prevent the access_token from being stored. |
530 | unset($parsedBody['access_token']); |
531 | |
532 | // Check for action. |
533 | if (isset($parsedBody['action']) and is_string($parsedBody['action'])) { |
534 | $action = $parsedBody['action']; |
535 | if ($action == 'delete') { |
536 | // Handle delete request. |
537 | $logger->info('Handling delete request.', $parsedBody); |
538 | if (isset($parsedBody['url']) and is_string($parsedBody['url'])) { |
539 | $deleteResult = $this->deleteCallback($parsedBody['url']); |
540 | if ($deleteResult === true) { |
541 | // If the delete was successful, respond with an empty 204 response. |
542 | return $this->toResponse('', 204); |
543 | } else { |
544 | return $this->toResponse($deleteResult); |
545 | } |
546 | } else { |
547 | $logger->warning($this->errorMessages['missing_url_parameter']); |
548 | return new Response(400, ['content-type' => 'application/json'], json_encode([ |
549 | 'error' => 'invalid_request', |
550 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
551 | ])); |
552 | } |
553 | |
554 | } elseif ($action == 'undelete') { |
555 | // Handle undelete request. |
556 | if (isset($parsedBody['url']) and is_string($parsedBody['url'])) { |
557 | $undeleteResult = $this->undeleteCallback($parsedBody['url']); |
558 | if ($undeleteResult === true) { |
559 | // If the delete was successful, respond with an empty 204 response. |
560 | return $this->toResponse('', 204); |
561 | } elseif (is_string($undeleteResult) and !in_array($undeleteResult, MICROPUB_ERROR_CODES)) { |
562 | // The non-error-code string returned from undelete is the URL of the new location of the |
563 | // undeleted content. |
564 | return new Response(201, ['location' => $undeleteResult]); |
565 | } else { |
566 | return $this->toResponse($undeleteResult); |
567 | } |
568 | } else { |
569 | $logger->warning($this->errorMessages['missing_url_parameter']); |
570 | return new Response(400, ['content-type' => 'application/json'], json_encode([ |
571 | 'error' => 'invalid_request', |
572 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
573 | ])); |
574 | } |
575 | } elseif ($action == 'update') { |
576 | // Handle update request. |
577 | // Check for the required url parameter. |
578 | if (!isset($parsedBody['url']) or !is_string($parsedBody['url'])) { |
579 | $logger->warning("An update request had a missing or invalid url parameter."); |
580 | return new Response(400, ['content-type' => 'application/json'], json_encode([ |
581 | 'error' => 'invalid_request', |
582 | 'error_description' => $this->errorMessages['missing_url_parameter'] |
583 | ])); |
584 | } |
585 | |
586 | // Check that the three possible update action parameters are all arrays. |
587 | foreach (['replace', 'add', 'delete'] as $updateAction) { |
588 | if (isset($parsedBody[$updateAction]) and !is_array($parsedBody[$updateAction])) { |
589 | $logger->warning("An update request had an invalid (non-array) $updateAction", [$updateAction => $parsedBody[$updateAction]]); |
590 | return $this->toResponse('invalid_request'); |
591 | } |
592 | } |
593 | |
594 | $updateResult = $this->updateCallback($parsedBody['url'], $parsedBody); |
595 | if ($updateResult === true) { |
596 | // Basic success. |
597 | return $this->toResponse('', 204); |
598 | } elseif (is_string($updateResult) and !in_array($updateResult, MICROPUB_ERROR_CODES)) { |
599 | // The non-error-code string returned from update is the URL of the new location of the |
600 | // undeleted content. |
601 | return new Response(201, ['location' => $updateResult]); |
602 | } else { |
603 | return $this->toResponse($updateResult); |
604 | } |
605 | } |
606 | |
607 | // An unknown action was provided. Return invalid_request. |
608 | $logger->error('An unknown action parameter was provided.', $parsedBody); |
609 | return $this->toResponse('invalid_request'); |
610 | } |
611 | |
612 | // Assume that the request is a Create request. |
613 | // If we’re dealing with an x-www-form-urlencoded or multipart/form-data request, |
614 | // normalise form data to match JSON structure. |
615 | if (!$jsonRequest) { |
616 | $logger->info('Normalizing URL-encoded data into canonical JSON format.'); |
617 | $parsedBody = normalizeUrlencodedCreateRequest($parsedBody); |
618 | } |
619 | |
620 | // Pass data off to create callback. |
621 | $createResponse = $this->createCallback($parsedBody, $request->getUploadedFiles()); |
622 | if (is_string($createResponse) and !in_array($createResponse, MICROPUB_ERROR_CODES)) { |
623 | // Success, return HTTP 201 with Location header. |
624 | return new Response(201, ['location' => $createResponse]); |
625 | } else { |
626 | return $this->toResponse($createResponse); |
627 | } |
628 | |
629 | } |
630 | |
631 | // Request method was something other than GET or POST. |
632 | $logger->error('The request had a method other than POST or GET.', ['method' => $request->getMethod()]); |
633 | return $this->toResponse('invalid_request'); |
634 | } |
635 | |
636 | /** |
637 | * Handle Media Endpoint Request |
638 | * |
639 | * Handle a request to a micropub media-endpoint. |
640 | * |
641 | * As with `handleRequest()`, `$this->request` and `$this->user` are made available |
642 | * for use within callbacks. |
643 | * |
644 | * @param ServerRequestInterface $request |
645 | * @return ResponseInterface |
646 | */ |
647 | public function handleMediaEndpointRequest(ServerRequestInterface $request) { |
648 | $logger = $this->getLogger(); |
649 | $this->request = $request; |
650 | |
651 | // Get and verify auth token. |
652 | $accessToken = getAccessToken($request); |
653 | if ($accessToken === null) { |
654 | $logger->warning($this->errorMessages['unauthorized']); |
655 | return new Response(401, ['content-type' => 'application/json'], json_encode([ |
656 | 'error' => 'unauthorized', |
657 | 'error_description' => $this->errorMessages['unauthorized'] |
658 | ])); |
659 | } |
660 | |
661 | $accessTokenResult = $this->verifyAccessTokenCallback($accessToken); |
662 | if ($accessTokenResult instanceof ResponseInterface) { |
663 | return $accessTokenResult; // Short-circuit. |
664 | } elseif (is_array($accessTokenResult)) { |
665 | // Log success. |
666 | $logger->info('Access token verified successfully.', ['user' => $accessTokenResult]); |
667 | $this->user = $accessTokenResult; |
668 | } else { |
669 | // Log error, return not authorized response. |
670 | $logger->error($this->errorMessages['access_token_invalid']); |
671 | return new Response(403, ['content-type' => 'application/json'], json_encode([ |
672 | 'error' => 'forbidden', |
673 | 'error_description' => $this->errorMessages['access_token_invalid'] |
674 | ])); |
675 | } |
676 | |
677 | // Give implementations a chance to pre-empt regular media endpoint handling, in order |
678 | // to implement extensions. |
679 | $mediaEndpointExtensionResult = $this->mediaEndpointExtensionCallback($request); |
680 | if ($mediaEndpointExtensionResult) { |
681 | return $this->toResponse($mediaEndpointExtensionResult); |
682 | } |
683 | |
684 | // Only support POST requests to the media endpoint. |
685 | if (strtolower($request->getMethod()) != 'post') { |
686 | $logger->error('Got a non-POST request to the media endpoint', ['method' => $request->getMethod()]); |
687 | return $this->toResponse('invalid_request'); |
688 | } |
689 | |
690 | // Look for the presence of an uploaded file called 'file' |
691 | $uploadedFiles = $request->getUploadedFiles(); |
692 | if (isset($uploadedFiles['file']) and $uploadedFiles['file'] instanceof UploadedFileInterface) { |
693 | $mediaCallbackResult = $this->mediaEndpointCallback($uploadedFiles['file']); |
694 | |
695 | if ($mediaCallbackResult) { |
696 | if (is_string($mediaCallbackResult) and !in_array($mediaCallbackResult, MICROPUB_ERROR_CODES)) { |
697 | // Success! Return an HTTP 201 response with the location header. |
698 | return new Response(201, ['location' => $mediaCallbackResult]); |
699 | } |
700 | |
701 | // Otherwise, handle whatever it is we got. |
702 | return $this->toResponse($mediaCallbackResult); |
703 | } |
704 | } |
705 | |
706 | // Either no file was provided, or mediaEndpointCallback returned a falsy value. |
707 | return $this->toResponse('invalid_request'); |
708 | } |
709 | |
710 | /** |
711 | * To Response |
712 | * |
713 | * Intelligently convert various shortcuts into a suitable instance of |
714 | * ResponseInterface. Existing ResponseInterfaces are passed through |
715 | * without alteration. |
716 | * |
717 | * @param null|string|array|ResponseInterface $resultOrResponse |
718 | * @param int $status=200 |
719 | * @return ResponseInterface |
720 | */ |
721 | private function toResponse($resultOrResponse, int $status=200): ResponseInterface { |
722 | if ($resultOrResponse instanceof ResponseInterface) { |
723 | return $resultOrResponse; |
724 | } |
725 | |
726 | // Convert micropub error messages into error responses. |
727 | if (is_string($resultOrResponse) && in_array($resultOrResponse, MICROPUB_ERROR_CODES)) { |
728 | $resultOrResponse = [ |
729 | 'error' => $resultOrResponse, |
730 | 'error_description' => $this->errorMessages[$resultOrResponse] |
731 | ]; |
732 | } |
733 | |
734 | if ($resultOrResponse === null) { |
735 | $resultOrResponse = '{}'; // Default to an empty object response if none given. |
736 | } elseif (is_array($resultOrResponse)) { |
737 | // If this is a known error response, adjust the status accordingly. |
738 | if (array_key_exists('error', $resultOrResponse)) { |
739 | if ($resultOrResponse['error'] == 'invalid_request') { |
740 | $status = 400; |
741 | } elseif ($resultOrResponse['error'] == 'unauthorized') { |
742 | $status = 401; |
743 | } elseif ($resultOrResponse['error'] == 'insufficient_scope') { |
744 | $status = 403; |
745 | } elseif ($resultOrResponse['error'] == 'forbidden') { |
746 | $status = 403; |
747 | } |
748 | } |
749 | $resultOrResponse = json_encode($resultOrResponse); |
750 | } |
751 | return new Response($status, ['content-type' => 'application/json'], $resultOrResponse); |
752 | } |
753 | } |
754 | |
755 | /** |
756 | * Get Access Token |
757 | * |
758 | * Given a request, return the Micropub access token, or null. |
759 | * |
760 | * @return string|null |
761 | */ |
762 | function getAccessToken(ServerRequestInterface $request) { |
763 | if ($request->hasHeader('authorization')) { |
764 | foreach ($request->getHeader('authorization') as $authVal) { |
765 | if (strtolower(substr($authVal, 0, 6)) == 'bearer') { |
766 | return substr($authVal, 7); |
767 | } |
768 | } |
769 | } |
770 | |
771 | $parsedBody = $request->getParsedBody(); |
772 | if (is_array($parsedBody) and array_key_exists('access_token', $parsedBody) and is_string($parsedBody['access_token'])) { |
773 | return $parsedBody['access_token']; |
774 | } |
775 | |
776 | return null; |
777 | } |
778 | |
779 | /** |
780 | * Normalize URL-encoded Create Request |
781 | * |
782 | * Given an array of PHP-parsed form parameters (such as from $_POST), convert |
783 | * them into canonical microformats2 format. |
784 | * |
785 | * @param array $body |
786 | * @return array |
787 | */ |
788 | function normalizeUrlencodedCreateRequest(array $body) { |
789 | $result = [ |
790 | 'type' => ['h-entry'], |
791 | 'properties' => [] |
792 | ]; |
793 | |
794 | foreach ($body as $key => $value) { |
795 | if ($key == 'h') { |
796 | $result['type'] = ["h-$value"]; |
797 | } elseif (is_array($value)) { |
798 | $result['properties'][$key] = $value; |
799 | } else { |
800 | $result['properties'][$key] = [$value]; |
801 | } |
802 | } |
803 | |
804 | return $result; |
805 | } |