7fced8e926b1f43394faef56f2b4683642359b15
[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     protected function postAction()
189     {
190         if (!isset($_POST['me'])) {
191             return $this->errorOut('"me" parameter missing');
192         }
193         $me = trim($_POST['me']);
194
195         if (!isset($_POST['replyTo'])) {
196             return $this->errorOut('"replyTo" parameter missing');
197         }
198         $replyTo = trim($_POST['replyTo']);
199
200         if (!isset($_POST['content'])) {
201             return $this->errorOut('"content" parameter missing');
202         }
203         $content = trim($_POST['content']);
204
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         $links = $this->getLinks($me);
212         if (!count($links)) {
213             return $this->errorOut('No links found');
214         }
215         if (!isset($links['micropub'])) {
216             return $this->errorOut('No micropub endpoint found');
217         }
218
219         /* unfortunately fetch_file_contents() does not return headers
220            so we have to bring our own way to POST data */
221         $opts = [
222             'http' => [
223                 'method'  => 'POST',
224                 'header'  => 'Content-type: application/x-www-form-urlencoded',
225                 'content' => http_build_query(
226                     [
227                         'access_token' => $account['access_token'],
228                         'h'            => 'entry',
229                         'in-reply-to'  => $replyTo,
230                         'content'      => $content,
231                     ]
232                 ),
233                 'ignore_errors' => true,
234             ]
235         ];
236         $stream = fopen(
237             $links['micropub'], 'r', false,
238             stream_context_create($opts)
239         );
240         $meta    = stream_get_meta_data($stream);
241         $headers = $meta['wrapper_data'];
242         $content = stream_get_contents($stream);
243
244         //we hope there were no redirects and this is actually the only
245         // HTTP line in the headers
246         $status = array_shift($headers);
247         list($httpver, $code, $text) = explode(' ', $status, 3);
248         if ($code != 201 && $code != 202) {
249             return $this->errorOut(
250                 'An error occured: '
251                 . $code . ' ' . $text
252             );
253         }
254
255         $location = null;
256         foreach ($headers as $header) {
257             $parts = explode(':', $header, 2);
258             if (count($parts) == 2 && strtolower($parts[0]) == 'location') {
259                 $location = trim($parts[1]);
260             }
261         }
262         if ($location === null) {
263             return $this->errorOut(
264                 'Location header missing in successful creation response.'
265             );
266         }
267
268         header('Content-type: application/json');
269         echo json_encode(
270             [
271                 'code'     => intval($code),
272                 'location' => $location,
273                 'message'  => 'Post created',
274             ]
275         );
276         exit();
277     }
278
279     protected function authorizeAction($args = [])
280     {
281         if (count($args)) {
282             $url = array_shift($args);
283         } else if (isset($_POST['url'])) {
284             $url = $_POST['url'];
285         }
286         if (!filter_var($url, FILTER_VALIDATE_URL)) {
287             return $this->errorOut('Invalid URL');
288         }
289
290         //step 1: micropub discovery
291         $links = $this->getLinks($url);
292
293         if (!count($links)) {
294             return $this->errorOut('No links found');
295         }
296         if (!isset($links['micropub'])) {
297             return $this->errorOut('No micropub endpoint found');
298         }
299         if (!isset($links['token_endpoint'])) {
300             return $this->errorOut('No token endpoint found');
301         }
302         if (!isset($links['authorization_endpoint'])) {
303             return $this->errorOut('No authorization endpoint found');
304         }
305
306         $redirUrl = get_self_url_prefix() . '/backend.php'
307             . '?op=micropub&method=action&mode=authreturn';
308         $authUrl = $links['authorization_endpoint']
309             . '?me=' . $url
310             . '&redirect_uri=' . urlencode($redirUrl)
311             . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
312             //. '&state=' . 'FIXME'
313             . '&scope=create'
314             . '&response_type=code';
315         header('Location: ' . $authUrl);
316         echo $authUrl . "\n";
317         exit();
318     }
319
320     /**
321      * Return from authorization
322      */
323     public function authreturnAction()
324     {
325         if (!isset($_GET['me'])) {
326             return $this->errorOut('"me" parameter missing');
327         }
328         if (!isset($_GET['code'])) {
329             return $this->errorOut('"code" parameter missing');
330         }
331
332         $links = $this->getLinks($_GET['me']);
333         if (!isset($links['token_endpoint'])) {
334             return $this->errorOut('No token endpoint found');
335         }
336
337         //obtain access token from the code
338         $redirUrl = get_self_url_prefix() . '/backend.php'
339             . '?op=micropub&method=action&mode=authreturn';
340         $res = fetch_file_contents(
341             [
342                 //FIXME: add accept header once this is fixed:
343                 // https://discourse.tt-rss.org/t//207
344                 'url'        => $links['token_endpoint'],
345                 'post_query' => [
346                     'grant_type'   => 'authorization_code',
347                     'me'           => $_GET['me'],
348                     'code'         => $_GET['code'],
349                     'redirect_uri' => $redirUrl,
350                     'client_id'    => get_self_url_prefix()
351                 ]
352             ]
353         );
354
355         //we have no way to get the content type :/
356         if ($res{0} == '{') {
357             //json
358             $data = json_decode($res);
359         } else {
360             parse_str($res, $data);
361         }
362         if (!isset($data['access_token'])) {
363             return $this->errorOut('access token missing');
364         }
365         if (!isset($data['me'])) {
366             return $this->errorOut('access token missing');
367         }
368         if (!isset($data['scope'])) {
369             return $this->errorOut('scope token missing');
370         }
371
372         $host = PluginHost::getInstance();
373         $accounts = $host->get($this, 'accounts', []);
374         $accounts[$data['me']] = [
375             'access_token' => $data['access_token'],
376             'scope'        => $data['scope'],
377         ];
378         $host->set($this, 'accounts', $accounts);
379
380         //all fine now.
381         //the accordion parameter will never work
382         // because fox has serious mental problems
383         // https://discourse.tt-rss.org/t/open-a-certain-accordion-in-preferences-by-url-parameter/234
384         header('Location: prefs.php?accordion=micropub');
385     }
386
387     /**
388      * Backend preferences action: Remove a given account
389      */
390     protected function deleteIdentityAction()
391     {
392         if (!isset($_POST['me'])) {
393             return $this->errorOut('"me" parameter missing');
394         }
395         $me = trim($_POST['me']);
396
397         $host = PluginHost::getInstance();
398         $accounts = $host->get($this, 'accounts', []);
399         if (!isset($accounts[$me])) {
400             return $this->errorOut('Unknown identity');
401         }
402
403         unset($accounts[$me]);
404         $host->set($this, 'accounts', $accounts);
405         header('Content-type: application/json');
406
407         echo json_encode(
408             [
409                 'code'     => '200',
410                 'message'  => 'Identity removed',
411             ]
412         );
413         exit();
414     }
415
416     /**
417      * Backend preferences action: Make a given account the default
418      */
419     protected function setDefaultIdentityAction()
420     {
421         if (!isset($_POST['me'])) {
422             return $this->errorOut('"me" parameter missing');
423         }
424         $me = trim($_POST['me']);
425
426         $host = PluginHost::getInstance();
427         $accounts = $host->get($this, 'accounts', []);
428         if (!isset($accounts[$me])) {
429             return $this->errorOut('Unknown identity');
430         }
431
432         foreach ($accounts as $url => $data) {
433             $accounts[$url]['default'] = ($url == $me);
434         }
435         $host->set($this, 'accounts', $accounts);
436         header('Content-type: application/json');
437
438         echo json_encode(
439             [
440                 'code'     => '200',
441                 'message'  => 'Default account set',
442             ]
443         );
444         exit();
445     }
446
447     /**
448      * Send an error message.
449      * Automatically in the correct format (plain text or json)
450      *
451      * @param string $msg Error message
452      *
453      * @return void
454      */
455     protected function errorOut($msg)
456     {
457         header('HTTP/1.0 400 Bad Request');
458
459         //this does not take "q"uality values into account, I know.
460         if (isset($_SERVER['HTTP_ACCEPT'])
461             && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false
462         ) {
463             //send json error
464             header('Content-type: application/json');
465             echo json_encode(
466                 [
467                     'error' => $msg,
468                 ]
469             );
470         } else {
471             header('Content-type: text/plain');
472             echo $msg . "\n";
473         }
474         exit(1);
475     }
476
477     /**
478      * Extract link relations from a given URL
479      */
480     protected function getLinks($url)
481     {
482         //FIXME: HTTP Link header support with HTTP2
483         $html = fetch_file_contents(
484             [
485                 'url' => $url,
486             ]
487         );
488         //Loading invalid HTML is tedious.
489         // quick hack with regex. yay!
490         preg_match_all('#<link[^>]+?>#', $html, $matches);
491         $links = [];
492         foreach ($matches[0] as $match) {
493             if (substr($match, -2) != '/>') {
494                 //make it valid xml...
495                 $match = substr($match, 0, -1) . '/>';
496             }
497             $sx = simplexml_load_string($match);
498             if (isset($sx['rel']) && isset($sx['href'])
499                 && !isset($links[(string) $sx['rel']])
500             ) {
501                 $links[(string) $sx['rel']] = (string) $sx['href'];
502             }
503         }
504         return $links;
505     }
506
507     function csrf_ignore($method)
508     {
509         return true;
510     }
511
512     /**
513      * Check which method is allowed via HTTP
514      */
515     function before($method)
516     {
517         if ($method == 'action') {
518             return true;
519         }
520         return false;
521     }
522
523     function after()
524     {
525         return true;
526     }
527 }
528 ?>