Handle HTTP errors when extracting links
[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 ($links === false) {
229             return $this->errorOut('Error fetching URL: ' . $me);
230         }
231         if (!count($links)) {
232             return $this->errorOut('No links found');
233         }
234         if (!isset($links['micropub'])) {
235             return $this->errorOut('No micropub endpoint found');
236         }
237
238         $parameters = [
239             'access_token' => $account['access_token'],
240             'h'            => 'entry',
241         ];
242
243         if ($action == 'bookmark') {
244             $parameters['bookmark-of'] = $postUrl;
245
246         } else if ($action == 'comment') {
247             $parameters['in-reply-to'] = $postUrl;
248             $parameters['content']     = $content;
249
250         } else if ($action == 'like') {
251             $parameters['like-of'] = $postUrl;
252         }
253
254
255         /* unfortunately fetch_file_contents() does not return headers
256            so we have to bring our own way to POST data */
257         $opts = [
258             'http' => [
259                 'method'  => 'POST',
260                 'header'  => 'Content-type: application/x-www-form-urlencoded',
261                 'content' => http_build_query($parameters),
262                 'ignore_errors' => true,
263             ]
264         ];
265         $stream = fopen(
266             $links['micropub'], 'r', false,
267             stream_context_create($opts)
268         );
269         $meta    = stream_get_meta_data($stream);
270         $headers = $meta['wrapper_data'];
271         $content = stream_get_contents($stream);
272
273         //we hope there were no redirects and this is actually the only
274         // HTTP line in the headers
275         $status = array_shift($headers);
276         list($httpver, $code, $text) = explode(' ', $status, 3);
277         if ($code != 201 && $code != 202) {
278             $errData = json_decode($content);
279             if (isset($errData->error_description)
280                 && $errData->error_description != ''
281             ) {
282                 return $this->errorOut(
283                     'Error creating post: '
284                     . $errData->error_description
285                 );
286             }
287             return $this->errorOut(
288                 'Error creating post: '
289                 . $code . ' ' . $text.$content
290             );
291         }
292
293         $location = null;
294         foreach ($headers as $header) {
295             $parts = explode(':', $header, 2);
296             if (count($parts) == 2 && strtolower($parts[0]) == 'location') {
297                 $location = trim($parts[1]);
298             }
299         }
300         if ($location === null) {
301             return $this->errorOut(
302                 'Location header missing in successful creation response.'
303             );
304         }
305
306         header('Content-type: application/json');
307         echo json_encode(
308             [
309                 'code'     => intval($code),
310                 'location' => $location,
311                 'message'  => 'Post created',
312             ]
313         );
314         exit();
315     }
316
317     protected function authorizeAction($args = [])
318     {
319         if (count($args)) {
320             $url = array_shift($args);
321         } else if (isset($_POST['url'])) {
322             $url = $_POST['url'];
323         }
324         if (!filter_var($url, FILTER_VALIDATE_URL)) {
325             return $this->errorOut('Invalid URL');
326         }
327
328         //step 1: micropub discovery
329         $links = $this->getLinks($url);
330
331         if ($links === false) {
332             return $this->errorOut('Error fetching URL: ' . $url);
333         }
334         if (!count($links)) {
335             return $this->errorOut('No links found');
336         }
337         if (!isset($links['micropub'])) {
338             return $this->errorOut('No micropub endpoint found');
339         }
340         if (!isset($links['token_endpoint'])) {
341             return $this->errorOut('No token endpoint found');
342         }
343         if (!isset($links['authorization_endpoint'])) {
344             return $this->errorOut('No authorization endpoint found');
345         }
346
347         $redirUrl = get_self_url_prefix() . '/backend.php'
348             . '?op=micropub&method=action&mode=authreturn';
349         $authUrl = $links['authorization_endpoint']
350             . '?me=' . $url
351             . '&redirect_uri=' . urlencode($redirUrl)
352             . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
353             //. '&state=' . 'FIXME'
354             . '&scope=create'
355             . '&response_type=code';
356         header('Location: ' . $authUrl);
357         echo $authUrl . "\n";
358         exit();
359     }
360
361     /**
362      * Return from authorization
363      */
364     public function authreturnAction()
365     {
366         if (!isset($_GET['me'])) {
367             return $this->errorOut('"me" parameter missing');
368         }
369         if (!isset($_GET['code'])) {
370             return $this->errorOut('"code" parameter missing');
371         }
372
373         $links = $this->getLinks($_GET['me']);
374         if ($links === false) {
375             return $this->errorOut('Error fetching URL: ' . $_GET['me']);
376         }
377         if (!isset($links['token_endpoint'])) {
378             return $this->errorOut('No token endpoint found');
379         }
380
381         //obtain access token from the code
382         $redirUrl = get_self_url_prefix() . '/backend.php'
383             . '?op=micropub&method=action&mode=authreturn';
384         $res = fetch_file_contents(
385             [
386                 //FIXME: add accept header once this is fixed:
387                 // https://discourse.tt-rss.org/t//207
388                 'url'        => $links['token_endpoint'],
389                 'post_query' => [
390                     'grant_type'   => 'authorization_code',
391                     'me'           => $_GET['me'],
392                     'code'         => $_GET['code'],
393                     'redirect_uri' => $redirUrl,
394                     'client_id'    => get_self_url_prefix()
395                 ]
396             ]
397         );
398
399         //we have no way to get the content type :/
400         if ($res{0} == '{') {
401             //json
402             $data = json_decode($res);
403         } else {
404             parse_str($res, $data);
405         }
406         if (!isset($data['access_token'])) {
407             return $this->errorOut('access token missing');
408         }
409         if (!isset($data['me'])) {
410             return $this->errorOut('access token missing');
411         }
412         if (!isset($data['scope'])) {
413             return $this->errorOut('scope token missing');
414         }
415
416         $host = PluginHost::getInstance();
417         $accounts = $host->get($this, 'accounts', []);
418         $accounts[$data['me']] = [
419             'access_token' => $data['access_token'],
420             'scope'        => $data['scope'],
421         ];
422         $accounts = $this->fixDefaultIdentity($accounts);
423         $host->set($this, 'accounts', $accounts);
424
425         //all fine now.
426         //the accordion parameter will never work
427         // because fox has serious mental problems
428         // https://discourse.tt-rss.org/t/open-a-certain-accordion-in-preferences-by-url-parameter/234
429         header('Location: prefs.php?accordion=micropub');
430     }
431
432     /**
433      * Backend preferences action: Remove a given account
434      */
435     protected function deleteIdentityAction()
436     {
437         if (!isset($_POST['me'])) {
438             return $this->errorOut('"me" parameter missing');
439         }
440         $me = trim($_POST['me']);
441
442         $host = PluginHost::getInstance();
443         $accounts = $host->get($this, 'accounts', []);
444         if (!isset($accounts[$me])) {
445             return $this->errorOut('Unknown identity');
446         }
447
448         unset($accounts[$me]);
449         $accounts = $this->fixDefaultIdentity($accounts);
450         $host->set($this, 'accounts', $accounts);
451
452         header('Content-type: application/json');
453         echo json_encode(
454             [
455                 'code'     => '200',
456                 'message'  => 'Identity removed',
457             ]
458         );
459         exit();
460     }
461
462     /**
463      * Backend preferences action: Make a given account the default
464      */
465     protected function setDefaultIdentityAction()
466     {
467         if (!isset($_POST['me'])) {
468             return $this->errorOut('"me" parameter missing');
469         }
470         $me = trim($_POST['me']);
471
472         $host = PluginHost::getInstance();
473         $accounts = $host->get($this, 'accounts', []);
474         if (!isset($accounts[$me])) {
475             return $this->errorOut('Unknown identity');
476         }
477         foreach ($accounts as $url => $data) {
478             $accounts[$url]['default'] = ($url == $me);
479         }
480         $host->set($this, 'accounts', $accounts);
481
482         header('Content-type: application/json');
483         echo json_encode(
484             [
485                 'code'     => '200',
486                 'message'  => 'Default account set',
487             ]
488         );
489         exit();
490     }
491
492     /**
493      * Set the default identity if there is none
494      *
495      * @param array $accounts Array of account data arrays
496      *
497      * @return array Array of account data arrays
498      */
499     protected function fixDefaultIdentity($accounts)
500     {
501         if (!count($accounts)) {
502             return $accounts;
503         }
504
505         $hasDefault = false;
506         foreach ($accounts as $account) {
507             if ($account['default']) {
508                 $hasDefault = true;
509             }
510         }
511
512         if (!$hasDefault) {
513             reset($accounts);
514             $accounts[key($accounts)]['default'] = true;
515         }
516         return $accounts;
517     }
518
519     /**
520      * Send an error message.
521      * Automatically in the correct format (plain text or json)
522      *
523      * @param string $msg Error message
524      *
525      * @return void
526      */
527     protected function errorOut($msg)
528     {
529         header('HTTP/1.0 400 Bad Request');
530
531         //this does not take "q"uality values into account, I know.
532         if (isset($_SERVER['HTTP_ACCEPT'])
533             && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false
534         ) {
535             //send json error
536             header('Content-type: application/json');
537             echo json_encode(
538                 [
539                     'error' => $msg,
540                 ]
541             );
542         } else {
543             header('Content-type: text/plain');
544             echo $msg . "\n";
545         }
546         exit(1);
547     }
548
549     /**
550      * Extract link relations from a given URL
551      *
552      * @param string $url URL to extract links from
553      *
554      * @return bool|array Array of links, or false on HTTP error
555      */
556     protected function getLinks($url)
557     {
558         //FIXME: HTTP Link header support with HTTP2
559         $html = fetch_file_contents(
560             [
561                 'url' => $url,
562             ]
563         );
564         if ($html === false) {
565             return false;
566         }
567
568         //Loading invalid HTML is tedious.
569         // quick hack with regex. yay!
570         preg_match_all('#<link[^>]+?>#', $html, $matches);
571         $links = [];
572         foreach ($matches[0] as $match) {
573             if (substr($match, -2) != '/>') {
574                 //make it valid xml...
575                 $match = substr($match, 0, -1) . '/>';
576             }
577             $sx = simplexml_load_string($match);
578             if (isset($sx['rel']) && isset($sx['href'])
579                 && !isset($links[(string) $sx['rel']])
580             ) {
581                 $links[(string) $sx['rel']] = (string) $sx['href'];
582             }
583         }
584         return $links;
585     }
586
587     /**
588      * If a valid CSRF token is necessary or not
589      *
590      * @param string $method Plugin method name (here: "action")
591      *
592      * @return boolean True if an invalid CSRF token shall be ignored
593      */
594     function csrf_ignore($method)
595     {
596         $mode = null;
597         if (isset($_POST['mode'])) {
598             $mode = $_POST['mode'];
599         } else if (isset($_GET['mode'])) {
600             $mode = $_GET['mode'];
601         }
602
603         if ($mode == 'authreturn') {
604             return true;
605         }
606
607         return false;
608     }
609
610     /**
611      * Check which method is allowed via HTTP
612      */
613     function before($method)
614     {
615         if ($method == 'action') {
616             return true;
617         }
618         return false;
619     }
620
621     function after()
622     {
623         return true;
624     }
625 }
626 ?>