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