Extract final post URL after creation
[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         array_shift($accounts);
91         ob_start();
92         include __DIR__ . '/commentform.phtml';
93         $html = ob_get_clean();
94         $article['content'] .= $html;
95
96         return $article;
97     }
98
99     /**
100      * Render our configuration page.
101      * Directly echo it out.
102      *
103      * @param string $args Preferences tab that is currently open
104      *
105      * @return void
106      */
107     public function hook_prefs_tab($args)
108     {
109         if ($args != "prefPrefs") {
110             return;
111         }
112
113         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
114
115         //FIXME: default identity
116         include __DIR__ . '/settings.phtml';
117     }
118
119     /**
120      * CLI command
121      */
122     public function micropub($args)
123     {
124         //we do not get all arguments passed here, to we work around
125         $args = $GLOBALS['argv'];
126         array_shift($args);//update.php
127         array_shift($args);//--micropub
128         $mode = array_shift($args);
129         return $this->action($mode, $args);
130     }
131
132     /**
133      * HTTP command.
134      * Also used by micropub() cli command method.
135      *
136      * /backend.php?op=pluginhandler&plugin=micropub&method=action
137      */
138     public function action($mode = null, $args = [])
139     {
140         if (isset($_POST['mode'])) {
141             $mode = $_POST['mode'];
142         } else if (isset($_GET['mode'])) {
143             $mode = $_GET['mode'];
144         }
145
146         if ($mode == 'authorize') {
147             return $this->authorizeAction($args);
148         } else if ($mode == 'authreturn') {
149             return $this->authreturnAction();
150         } else if ($mode == 'post') {
151             return $this->postAction();
152         } else {
153             return $this->errorOut('Unsupported mode');
154         }
155     }
156
157     protected function postAction()
158     {
159         if (!isset($_POST['me'])) {
160             return $this->errorOut('"me" parameter missing');
161         }
162         $me = $_POST['me'];
163
164         if (!isset($_POST['replyTo'])) {
165             return $this->errorOut('"replyTo" parameter missing');
166         }
167         $replyTo = $_POST['replyTo'];
168
169         if (!isset($_POST['content'])) {
170             return $this->errorOut('"content" parameter missing');
171         }
172         $content = $_POST['content'];
173
174         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
175         if (!isset($accounts[$me])) {
176             return $this->errorOut('"me" parameter invalid');
177         }
178         $account = $accounts[$me];
179
180         $links = $this->getLinks($me);
181         if (!count($links)) {
182             return $this->errorOut('No links found');
183         }
184         if (!isset($links['micropub'])) {
185             return $this->errorOut('No micropub endpoint found');
186         }
187
188         /* unfortunately fetch_file_contents() does not return headers
189            so we have to bring our own way to POST data */
190         $opts = [
191             'http' => [
192                 'method'  => 'POST',
193                 'header'  => 'Content-type: application/x-www-form-urlencoded',
194                 'content' => http_build_query(
195                     [
196                         'access_token' => $account['access_token'],
197                         'h'            => 'entry',
198                         'in-reply-to'  => $replyTo,
199                         'content'      => $content,
200                     ]
201                 ),
202                 'ignore_errors' => true,
203             ]
204         ];
205         $stream = fopen(
206             $links['micropub'], 'r', false,
207             stream_context_create($opts)
208         );
209         $meta    = stream_get_meta_data($stream);
210         $headers = $meta['wrapper_data'];
211         $content = stream_get_contents($stream);
212
213         //we hope there were no redirects and this is actually the only
214         // HTTP line in the headers
215         $status = array_shift($headers);
216         list($httpver, $code, $text) = explode(' ', $status, 3);
217         if ($code != 201 && $code != 202) {
218             return $this->errorOut(
219                 'An error occured: '
220                 . $code . ' ' . $text
221             );
222         }
223
224         $location = null;
225         foreach ($headers as $header) {
226             $parts = explode(':', $header, 2);
227             if (count($parts) == 2 && strtolower($parts[0]) == 'location') {
228                 $location = trim($parts[1]);
229             }
230         }
231         if ($location === null) {
232             return $this->errorOut(
233                 'Location header missing in successful creation response.'
234             );
235         }
236
237         header('Content-type: application/json');
238         echo json_encode(
239             [
240                 'code'     => intval($code),
241                 'location' => $location,
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 ?>