Show micropub error descriptions
[tt-rss-micropub.git] / init.php
1 <?php
2 /**
3  * Simple Micropub client to post reponses
4  *
5  * PHP version 5
6  *
7  * @author  Christian Weiske <cweiske@cweiske.de>
8  * @license AGPLv3 or later
9  * @link    https://www.w3.org/TR/micropub/
10  */
11 class Micropub extends Plugin implements IHandler
12 {
13     /**
14      * Dumb workaround for "private $host" in Plugin class
15      * + the re-creation of the plugin instance without calling init().
16      *
17      * @var PluginHost
18      * @see https://discourse.tt-rss.org/t//208
19      * @see https://discourse.tt-rss.org/t//209
20      */
21     protected static $myhost;
22
23     public function __construct()
24     {
25         //do nothing. only here to not have micropub() called as constructor
26     }
27
28     public function about()
29     {
30         return [
31             0.1,
32             'Micropub',
33             'cweiske',
34             false
35         ];
36     }
37
38     public function api_version()
39     {
40         return 2;
41     }
42
43
44     /**
45      * Register our hooks
46      */
47     public function init(/*PluginHost*/ $host)
48     {
49         static::$myhost = $host;
50         $host->add_hook($host::HOOK_PREFS_TAB, $this);
51         $host->add_hook($host::HOOK_RENDER_ARTICLE, $this);
52         //command line option --micropub
53                 $host->add_command(
54             'micropub', 'Add Micropub identity', $this, ':', 'MODE'
55         );
56     }
57
58     /**
59      * @param array $article Article data. Keys:
60      *                       - id
61      *                       - title
62      *                       - link
63      *                       - content
64      *                       - feed_id
65      *                       - comments
66      *                       - int_id
67      *                       - lang
68      *                       - updated
69      *                       - site_url
70      *                       - feed_title
71      *                       - hide_images
72      *                       - always_display_enclosures
73      *                       - num_comments
74      *                       - author
75      *                       - guid
76      *                       - orig_feed_id
77      *                       - note
78      *                       - tags
79      */
80     public function hook_render_article($article)
81     {
82         $quillUrl = 'https://quill.p3k.io/new'
83             . '?reply=' . urlencode($article['link']);
84         // did I tell you I hate dojo/dijit?
85
86         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
87         if (!count($accounts)) {
88             return $article;
89         }
90
91         $accountUrls = array_keys($accounts);
92         $defaultAccount = null;
93         foreach ($accounts as $url => $account) {
94             if ($account['default']) {
95                 $defaultAccount = $url;
96             }
97         }
98
99         ob_start();
100         include __DIR__ . '/commentform.phtml';
101         $html = ob_get_clean();
102         $article['content'] .= $html;
103
104         return $article;
105     }
106
107     /**
108      * Render our configuration page.
109      * Directly echo it out.
110      *
111      * @param string $args Preferences tab that is currently open
112      *
113      * @return void
114      */
115     public function hook_prefs_tab($args)
116     {
117         if ($args != "prefPrefs") {
118             return;
119         }
120
121         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
122         if (isset($_REQUEST['accordion'])
123             && $_REQUEST['accordion'] == 'micropub'
124         ) {
125             $accordionActive = 'selected="true"';
126         } else {
127             $accordionActive = '';
128         }
129
130         foreach ($accounts as $url => $account) {
131             $accounts[$url]['checked'] = '';
132             if ($account['default']) {
133                 $accounts[$url]['checked'] = 'checked="checked"';
134             }
135         }
136
137         //FIXME: default identity
138         include __DIR__ . '/settings.phtml';
139     }
140
141     public function get_prefs_js()
142     {
143         return file_get_contents(__DIR__ . '/settings.js');
144     }
145
146     /**
147      * CLI command
148      */
149     public function micropub($args)
150     {
151         //we do not get all arguments passed here, to we work around
152         $args = $GLOBALS['argv'];
153         array_shift($args);//update.php
154         array_shift($args);//--micropub
155         $mode = array_shift($args);
156         return $this->action($mode, $args);
157     }
158
159     /**
160      * HTTP command.
161      * Also used by micropub() cli command method.
162      *
163      * /backend.php?op=pluginhandler&plugin=micropub&method=action
164      */
165     public function action($mode = null, $args = [])
166     {
167         if (isset($_POST['mode'])) {
168             $mode = $_POST['mode'];
169         } else if (isset($_GET['mode'])) {
170             $mode = $_GET['mode'];
171         }
172
173         if ($mode == 'authorize') {
174             return $this->authorizeAction($args);
175         } else if ($mode == 'authreturn') {
176             return $this->authreturnAction();
177         } else if ($mode == 'post') {
178             return $this->postAction();
179         } else if ($mode == 'deleteIdentity') {
180             return $this->deleteIdentityAction();
181         } else if ($mode == 'setDefaultIdentity') {
182             return $this->setDefaultIdentityAction();
183         } else {
184             return $this->errorOut('Unsupported mode');
185         }
186     }
187
188     /**
189      * Post a comment, like or bookmark via micropub
190      */
191     protected function postAction()
192     {
193         $action = 'comment';
194         if (isset($_POST['action'])) {
195             $action = trim($_POST['action']);
196         }
197         if (array_search($action, ['bookmark', 'comment', 'like']) === false) {
198             return $this->errorOut('"action" parameter invalid');
199         }
200
201         if (!isset($_POST['me'])) {
202             return $this->errorOut('"me" parameter missing');
203         }
204         $me = trim($_POST['me']);
205         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
206         if (!isset($accounts[$me])) {
207             return $this->errorOut('"me" parameter invalid');
208         }
209         $account = $accounts[$me];
210
211         if (!isset($_POST['postUrl'])) {
212             return $this->errorOut('"postUrl" parameter missing');
213         }
214         $postUrl = trim($_POST['postUrl']);
215
216         if ($action == 'comment') {
217             if (!isset($_POST['content'])) {
218                 return $this->errorOut('"content" parameter missing');
219             }
220             $content = trim($_POST['content']);
221             if (!strlen($_POST['content'])) {
222                 return $this->errorOut('"content" is empty');
223             }
224         }
225
226
227         $links = $this->getLinks($me);
228         if (!count($links)) {
229             return $this->errorOut('No links found');
230         }
231         if (!isset($links['micropub'])) {
232             return $this->errorOut('No micropub endpoint found');
233         }
234
235         $parameters = [
236             'access_token' => $account['access_token'],
237             'h'            => 'entry',
238         ];
239
240         if ($action == 'bookmark') {
241             $parameters['bookmark-of'] = $postUrl;
242
243         } else if ($action == 'comment') {
244             $parameters['in-reply-to'] = $postUrl;
245             $parameters['content']     = $content;
246
247         } else if ($action == 'like') {
248             $parameters['like-of'] = $postUrl;
249         }
250
251
252         /* unfortunately fetch_file_contents() does not return headers
253            so we have to bring our own way to POST data */
254         $opts = [
255             'http' => [
256                 'method'  => 'POST',
257                 'header'  => 'Content-type: application/x-www-form-urlencoded',
258                 'content' => http_build_query($parameters),
259                 'ignore_errors' => true,
260             ]
261         ];
262         $stream = fopen(
263             $links['micropub'], 'r', false,
264             stream_context_create($opts)
265         );
266         $meta    = stream_get_meta_data($stream);
267         $headers = $meta['wrapper_data'];
268         $content = stream_get_contents($stream);
269
270         //we hope there were no redirects and this is actually the only
271         // HTTP line in the headers
272         $status = array_shift($headers);
273         list($httpver, $code, $text) = explode(' ', $status, 3);
274         if ($code != 201 && $code != 202) {
275             $errData = json_decode($content);
276             if (isset($errData->error_description)
277                 && $errData->error_description != ''
278             ) {
279                 return $this->errorOut(
280                     'Error creating post: '
281                     . $errData->error_description
282                 );
283             }
284             return $this->errorOut(
285                 'Error creating post: '
286                 . $code . ' ' . $text.$content
287             );
288         }
289
290         $location = null;
291         foreach ($headers as $header) {
292             $parts = explode(':', $header, 2);
293             if (count($parts) == 2 && strtolower($parts[0]) == 'location') {
294                 $location = trim($parts[1]);
295             }
296         }
297         if ($location === null) {
298             return $this->errorOut(
299                 'Location header missing in successful creation response.'
300             );
301         }
302
303         header('Content-type: application/json');
304         echo json_encode(
305             [
306                 'code'     => intval($code),
307                 'location' => $location,
308                 'message'  => 'Post created',
309             ]
310         );
311         exit();
312     }
313
314     protected function authorizeAction($args = [])
315     {
316         if (count($args)) {
317             $url = array_shift($args);
318         } else if (isset($_POST['url'])) {
319             $url = $_POST['url'];
320         }
321         if (!filter_var($url, FILTER_VALIDATE_URL)) {
322             return $this->errorOut('Invalid URL');
323         }
324
325         //step 1: micropub discovery
326         $links = $this->getLinks($url);
327
328         if (!count($links)) {
329             return $this->errorOut('No links found');
330         }
331         if (!isset($links['micropub'])) {
332             return $this->errorOut('No micropub endpoint found');
333         }
334         if (!isset($links['token_endpoint'])) {
335             return $this->errorOut('No token endpoint found');
336         }
337         if (!isset($links['authorization_endpoint'])) {
338             return $this->errorOut('No authorization endpoint found');
339         }
340
341         $redirUrl = get_self_url_prefix() . '/backend.php'
342             . '?op=micropub&method=action&mode=authreturn';
343         $authUrl = $links['authorization_endpoint']
344             . '?me=' . $url
345             . '&redirect_uri=' . urlencode($redirUrl)
346             . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
347             //. '&state=' . 'FIXME'
348             . '&scope=create'
349             . '&response_type=code';
350         header('Location: ' . $authUrl);
351         echo $authUrl . "\n";
352         exit();
353     }
354
355     /**
356      * Return from authorization
357      */
358     public function authreturnAction()
359     {
360         if (!isset($_GET['me'])) {
361             return $this->errorOut('"me" parameter missing');
362         }
363         if (!isset($_GET['code'])) {
364             return $this->errorOut('"code" parameter missing');
365         }
366
367         $links = $this->getLinks($_GET['me']);
368         if (!isset($links['token_endpoint'])) {
369             return $this->errorOut('No token endpoint found');
370         }
371
372         //obtain access token from the code
373         $redirUrl = get_self_url_prefix() . '/backend.php'
374             . '?op=micropub&method=action&mode=authreturn';
375         $res = fetch_file_contents(
376             [
377                 //FIXME: add accept header once this is fixed:
378                 // https://discourse.tt-rss.org/t//207
379                 'url'        => $links['token_endpoint'],
380                 'post_query' => [
381                     'grant_type'   => 'authorization_code',
382                     'me'           => $_GET['me'],
383                     'code'         => $_GET['code'],
384                     'redirect_uri' => $redirUrl,
385                     'client_id'    => get_self_url_prefix()
386                 ]
387             ]
388         );
389
390         //we have no way to get the content type :/
391         if ($res{0} == '{') {
392             //json
393             $data = json_decode($res);
394         } else {
395             parse_str($res, $data);
396         }
397         if (!isset($data['access_token'])) {
398             return $this->errorOut('access token missing');
399         }
400         if (!isset($data['me'])) {
401             return $this->errorOut('access token missing');
402         }
403         if (!isset($data['scope'])) {
404             return $this->errorOut('scope token missing');
405         }
406
407         $host = PluginHost::getInstance();
408         $accounts = $host->get($this, 'accounts', []);
409         $accounts[$data['me']] = [
410             'access_token' => $data['access_token'],
411             'scope'        => $data['scope'],
412         ];
413         $accounts = $this->fixDefaultIdentity($accounts);
414         $host->set($this, 'accounts', $accounts);
415
416         //all fine now.
417         //the accordion parameter will never work
418         // because fox has serious mental problems
419         // https://discourse.tt-rss.org/t/open-a-certain-accordion-in-preferences-by-url-parameter/234
420         header('Location: prefs.php?accordion=micropub');
421     }
422
423     /**
424      * Backend preferences action: Remove a given account
425      */
426     protected function deleteIdentityAction()
427     {
428         if (!isset($_POST['me'])) {
429             return $this->errorOut('"me" parameter missing');
430         }
431         $me = trim($_POST['me']);
432
433         $host = PluginHost::getInstance();
434         $accounts = $host->get($this, 'accounts', []);
435         if (!isset($accounts[$me])) {
436             return $this->errorOut('Unknown identity');
437         }
438
439         unset($accounts[$me]);
440         $accounts = $this->fixDefaultIdentity($accounts);
441         $host->set($this, 'accounts', $accounts);
442
443         header('Content-type: application/json');
444         echo json_encode(
445             [
446                 'code'     => '200',
447                 'message'  => 'Identity removed',
448             ]
449         );
450         exit();
451     }
452
453     /**
454      * Backend preferences action: Make a given account the default
455      */
456     protected function setDefaultIdentityAction()
457     {
458         if (!isset($_POST['me'])) {
459             return $this->errorOut('"me" parameter missing');
460         }
461         $me = trim($_POST['me']);
462
463         $host = PluginHost::getInstance();
464         $accounts = $host->get($this, 'accounts', []);
465         if (!isset($accounts[$me])) {
466             return $this->errorOut('Unknown identity');
467         }
468         foreach ($accounts as $url => $data) {
469             $accounts[$url]['default'] = ($url == $me);
470         }
471         $host->set($this, 'accounts', $accounts);
472
473         header('Content-type: application/json');
474         echo json_encode(
475             [
476                 'code'     => '200',
477                 'message'  => 'Default account set',
478             ]
479         );
480         exit();
481     }
482
483     /**
484      * Set the default identity if there is none
485      *
486      * @param array $accounts Array of account data arrays
487      *
488      * @return array Array of account data arrays
489      */
490     protected function fixDefaultIdentity($accounts)
491     {
492         if (!count($accounts)) {
493             return $accounts;
494         }
495
496         $hasDefault = false;
497         foreach ($accounts as $account) {
498             if ($account['default']) {
499                 $hasDefault = true;
500             }
501         }
502
503         if (!$hasDefault) {
504             reset($accounts);
505             $accounts[key($accounts)]['default'] = true;
506         }
507         return $accounts;
508     }
509
510     /**
511      * Send an error message.
512      * Automatically in the correct format (plain text or json)
513      *
514      * @param string $msg Error message
515      *
516      * @return void
517      */
518     protected function errorOut($msg)
519     {
520         header('HTTP/1.0 400 Bad Request');
521
522         //this does not take "q"uality values into account, I know.
523         if (isset($_SERVER['HTTP_ACCEPT'])
524             && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false
525         ) {
526             //send json error
527             header('Content-type: application/json');
528             echo json_encode(
529                 [
530                     'error' => $msg,
531                 ]
532             );
533         } else {
534             header('Content-type: text/plain');
535             echo $msg . "\n";
536         }
537         exit(1);
538     }
539
540     /**
541      * Extract link relations from a given URL
542      */
543     protected function getLinks($url)
544     {
545         //FIXME: HTTP Link header support with HTTP2
546         $html = fetch_file_contents(
547             [
548                 'url' => $url,
549             ]
550         );
551         //Loading invalid HTML is tedious.
552         // quick hack with regex. yay!
553         preg_match_all('#<link[^>]+?>#', $html, $matches);
554         $links = [];
555         foreach ($matches[0] as $match) {
556             if (substr($match, -2) != '/>') {
557                 //make it valid xml...
558                 $match = substr($match, 0, -1) . '/>';
559             }
560             $sx = simplexml_load_string($match);
561             if (isset($sx['rel']) && isset($sx['href'])
562                 && !isset($links[(string) $sx['rel']])
563             ) {
564                 $links[(string) $sx['rel']] = (string) $sx['href'];
565             }
566         }
567         return $links;
568     }
569
570     /**
571      * If a valid CSRF token is necessary or not
572      *
573      * @param string $method Plugin method name (here: "action")
574      *
575      * @return boolean True if an invalid CSRF token shall be ignored
576      */
577     function csrf_ignore($method)
578     {
579         $mode = null;
580         if (isset($_POST['mode'])) {
581             $mode = $_POST['mode'];
582         } else if (isset($_GET['mode'])) {
583             $mode = $_GET['mode'];
584         }
585
586         if ($mode == 'authreturn') {
587             return true;
588         }
589
590         return false;
591     }
592
593     /**
594      * Check which method is allowed via HTTP
595      */
596     function before($method)
597     {
598         if ($method == 'action') {
599             return true;
600         }
601         return false;
602     }
603
604     function after()
605     {
606         return true;
607     }
608 }
609 ?>