Posting works via AJAX now
[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      * Register our hooks
45      */
46     public function init(/*PluginHost*/ $host)
47     {
48         parent::init($host);
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 = array_keys(PluginHost::getInstance()->get($this, 'accounts', []));
87         if (!count($accounts)) {
88             return $article;
89         }
90         ob_start();
91         include __DIR__ . '/commentform.phtml';
92         $html = ob_get_clean();
93         $article['content'] .= $html;
94
95         return $article;
96     }
97
98     /**
99      * Render our configuration page.
100      * Directly echo it out.
101      *
102      * @param string $args Preferences tab that is currently open
103      *
104      * @return void
105      */
106     public function hook_prefs_tab($args)
107     {
108         if ($args != "prefPrefs") {
109             return;
110         }
111
112         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
113
114         //FIXME: default identity
115         include __DIR__ . '/settings.phtml';
116     }
117
118     /**
119      * CLI command
120      */
121     public function micropub($args)
122     {
123         //we do not get all arguments passed here, to we work around
124         $args = $GLOBALS['argv'];
125         array_shift($args);//update.php
126         array_shift($args);//--micropub
127         $mode = array_shift($args);
128         return $this->action($mode, $args);
129     }
130
131     /**
132      * HTTP command.
133      * Also used by micropub() cli command method.
134      *
135      * /backend.php?op=pluginhandler&plugin=micropub&method=action
136      */
137     public function action($mode = null, $args = [])
138     {
139         if (isset($_POST['mode'])) {
140             $mode = $_POST['mode'];
141         } else if (isset($_GET['mode'])) {
142             $mode = $_GET['mode'];
143         }
144
145         if ($mode == 'authorize') {
146             return $this->authorizeAction($args);
147         } else if ($mode == 'authreturn') {
148             return $this->authreturnAction();
149         } else if ($mode == 'post') {
150             return $this->postAction();
151         } else {
152             return $this->errorOut('Unsupported mode');
153         }
154     }
155
156     protected function postAction()
157     {
158         if (!isset($_POST['me'])) {
159             return $this->errorOut('"me" parameter missing');
160         }
161         $me = $_POST['me'];
162
163         if (!isset($_POST['replyTo'])) {
164             return $this->errorOut('"replyTo" parameter missing');
165         }
166         $replyTo = $_POST['replyTo'];
167
168         if (!isset($_POST['content'])) {
169             return $this->errorOut('"content" parameter missing');
170         }
171         $content = $_POST['content'];
172
173         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
174         if (!isset($accounts[$me])) {
175             return $this->errorOut('"me" parameter invalid');
176         }
177         $account = $accounts[$me];
178
179         $links = $this->getLinks($me);
180         if (!count($links)) {
181             return $this->errorOut('No links found');
182         }
183         if (!isset($links['micropub'])) {
184             return $this->errorOut('No micropub endpoint found');
185         }
186
187         /* unfortunately fetch_file_contents() does not return headers
188            so we have to bring our own way to POST data */
189         $opts = [
190             'http' => [
191                 'method'  => 'POST',
192                 'header'  => 'Content-type: application/x-www-form-urlencoded',
193                 'content' => http_build_query(
194                     [
195                         'access_token' => $account['access_token'],
196                         'h'            => 'entry',
197                         'in-reply-to'  => $replyTo,
198                         'content'      => $content,
199                     ]
200                 ),
201                 'ignore_errors' => true,
202             ]
203         ];
204         $stream = fopen(
205             $links['micropub'], 'r', false,
206             stream_context_create($opts)
207         );
208         $meta    = stream_get_meta_data($stream);
209         $headers = $meta['wrapper_data'];
210         $content = stream_get_contents($stream);
211
212         //we hope there were no redirects and this is actually the only
213         // HTTP line in the headers
214         $status = array_shift($headers);
215         list($httpver, $code, $text) = explode(' ', $status, 3);
216         if ($code != 201 && $code != 202) {
217             return $this->errorOut(
218                 'An error occured: '
219                 . $code . ' ' . $text
220             );
221         }
222
223         $location = null;
224         foreach ($headers as $header) {
225             $parts = explode(':', $header, 2);
226             if (count($parts) == 2 && strtolower($parts[0]) == 'location') {
227                 $location = trim($parts[1]);
228             }
229         }
230         if ($location === null) {
231             return $this->errorOut(
232                 'Location header missing in successful creation response.'
233             );
234         }
235
236         header('Content-type: application/json');
237         echo json_encode(
238             [
239                 'code'     => intval($code),
240                 'location' => $location,
241                 'message'  => 'Post created',
242             ]
243         );
244         exit();
245     }
246
247     protected function authorizeAction($args = [])
248     {
249         if (count($args)) {
250             $url = array_shift($args);
251         } else if (isset($_POST['url'])) {
252             $url = $_POST['url'];
253         }
254         if (!filter_var($url, FILTER_VALIDATE_URL)) {
255             return $this->errorOut('Invalid URL');
256         }
257
258         //step 1: micropub discovery
259         $links = $this->getLinks($url);
260
261         if (!count($links)) {
262             return $this->errorOut('No links found');
263         }
264         if (!isset($links['micropub'])) {
265             return $this->errorOut('No micropub endpoint found');
266         }
267         if (!isset($links['token_endpoint'])) {
268             return $this->errorOut('No token endpoint found');
269         }
270         if (!isset($links['authorization_endpoint'])) {
271             return $this->errorOut('No authorization endpoint found');
272         }
273
274         $redirUrl = get_self_url_prefix() . '/backend.php'
275             . '?op=micropub&method=action&mode=authreturn';
276         $authUrl = $links['authorization_endpoint']
277             . '?me=' . $url
278             . '&redirect_uri=' . urlencode($redirUrl)
279             . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
280             //. '&state=' . 'FIXME'
281             . '&scope=create'
282             . '&response_type=code';
283         header('Location: ' . $authUrl);
284         echo $authUrl . "\n";
285         exit();
286     }
287
288     /**
289      * Return from authorization
290      */
291     public function authreturnAction()
292     {
293         if (!isset($_GET['me'])) {
294             return $this->errorOut('"me" parameter missing');
295         }
296         if (!isset($_GET['code'])) {
297             return $this->errorOut('"code" parameter missing');
298         }
299
300         $links = $this->getLinks($_GET['me']);
301         if (!isset($links['token_endpoint'])) {
302             return $this->errorOut('No token endpoint found');
303         }
304
305         //obtain access token from the code
306         $redirUrl = get_self_url_prefix() . '/backend.php'
307             . '?op=micropub&method=action&mode=authreturn';
308         $res = fetch_file_contents(
309             [
310                 //FIXME: add accept header once this is fixed:
311                 // https://discourse.tt-rss.org/t//207
312                 'url'        => $links['token_endpoint'],
313                 'post_query' => [
314                     'grant_type'   => 'authorization_code',
315                     'me'           => $_GET['me'],
316                     'code'         => $_GET['code'],
317                     'redirect_uri' => $redirUrl,
318                     'client_id'    => get_self_url_prefix()
319                 ]
320             ]
321         );
322
323         //we have no way to get the content type :/
324         if ($res{0} == '{') {
325             //json
326             $data = json_decode($res);
327         } else {
328             parse_str($res, $data);
329         }
330         if (!isset($data['access_token'])) {
331             return $this->errorOut('access token missing');
332         }
333         if (!isset($data['me'])) {
334             return $this->errorOut('access token missing');
335         }
336         if (!isset($data['scope'])) {
337             return $this->errorOut('scope token missing');
338         }
339
340         $host = PluginHost::getInstance();
341         $accounts = $host->get($this, 'accounts', []);
342         $accounts[$data['me']] = [
343             'access_token' => $data['access_token'],
344             'scope'        => $data['scope'],
345         ];
346         $host->set($this, 'accounts', $accounts);
347
348         //all fine now.
349         header('Location: prefs.php');
350     }
351
352     /**
353      * Send an error message.
354      * Automatically in the correct format (plain text or json)
355      *
356      * @param string $msg Error message
357      *
358      * @return void
359      */
360     protected function errorOut($msg)
361     {
362         header('HTTP/1.0 400 Bad Request');
363
364         //this does not take "q"uality values into account, I know.
365         if (isset($_SERVER['HTTP_ACCEPT'])
366             && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false
367         ) {
368             //send json error
369             header('Content-type: application/json');
370             echo json_encode(
371                 [
372                     'error' => $msg,
373                 ]
374             );
375         } else {
376             header('Content-type: text/plain');
377             echo $msg . "\n";
378         }
379         exit(1);
380     }
381
382     /**
383      * Extract link relations from a given URL
384      */
385     protected function getLinks($url)
386     {
387         //FIXME: HTTP Link header support with HTTP2
388         $html = fetch_file_contents(
389             [
390                 'url' => $url,
391             ]
392         );
393         //Loading invalid HTML is tedious.
394         // quick hack with regex. yay!
395         preg_match_all('#<link[^>]+?>#', $html, $matches);
396         $links = [];
397         foreach ($matches[0] as $match) {
398             if (substr($match, -2) != '/>') {
399                 //make it valid xml...
400                 $match = substr($match, 0, -1) . '/>';
401             }
402             $sx = simplexml_load_string($match);
403             if (isset($sx['rel']) && isset($sx['href'])
404                 && !isset($links[(string) $sx['rel']])
405             ) {
406                 $links[(string) $sx['rel']] = (string) $sx['href'];
407             }
408         }
409         return $links;
410     }
411
412     function csrf_ignore($method)
413     {
414         return true;
415     }
416
417     /**
418      * Check which method is allowed via HTTP
419      */
420     function before($method)
421     {
422         if ($method == 'action') {
423             return true;
424         }
425         return false;
426     }
427
428     function after()
429     {
430         return true;
431     }
432 }
433 ?>