Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
193 / 193
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
Taproot\Micropub\getAccessToken
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
Taproot\Micropub\normalizeUrlencodedCreateRequest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
MicropubAdapter
100.00% covered (success)
100.00%
177 / 177
100.00% covered (success)
100.00%
13 / 13
81
100.00% covered (success)
100.00%
1 / 1
 verifyAccessTokenCallback
n/a
0 / 0
n/a
0 / 0
0
 extensionCallback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configurationQueryCallback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sourceQueryCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 undeleteCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 updateCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mediaEndpointCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mediaEndpointExtensionCallback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 handleRequest
100.00% covered (success)
100.00%
110 / 110
100.00% covered (success)
100.00%
1 / 1
48
 handleMediaEndpointRequest
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
11
 toResponse
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
11
1<?php declare(strict_types=1);
2
3namespace Taproot\Micropub;
4
5use Nyholm\Psr7\Response;
6use Psr\Http\Message\RequestInterface;
7use Psr\Http\Message\ServerRequestInterface;
8use Psr\Http\Message\ResponseInterface;
9use Psr\Http\Message\UploadedFileInterface;
10use Psr\Log\LoggerInterface;
11use Psr\Log\NullLogger;
12
13const 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 */
75abstract 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 */
762function 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 */
788function 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}