986266d740851276c65d0e1f6a461aa118913c15
[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     function get_css()
59     {
60         return file_get_contents(__DIR__ . '/init.css');
61     }
62
63     /**
64      * @param array $article Article data. Keys:
65      *                       - id
66      *                       - title
67      *                       - link
68      *                       - content
69      *                       - feed_id
70      *                       - comments
71      *                       - int_id
72      *                       - lang
73      *                       - updated
74      *                       - site_url
75      *                       - feed_title
76      *                       - hide_images
77      *                       - always_display_enclosures
78      *                       - num_comments
79      *                       - author
80      *                       - guid
81      *                       - orig_feed_id
82      *                       - note
83      *                       - tags
84      */
85     public function hook_render_article($article)
86     {
87         $quillUrl = 'https://quill.p3k.io/new'
88             . '?reply=' . urlencode($article['link']);
89         // did I tell you I hate dojo/dijit?
90
91         $accounts = array_keys(PluginHost::getInstance()->get($this, 'accounts', []));
92
93         ob_start();
94         include __DIR__ . '/commentform.phtml';
95         $html = ob_get_clean();
96         $article['content'] .= $html;
97
98         return $article;
99     }
100
101     /**
102      * Render our configuration page.
103      * Directly echo it out.
104      *
105      * @param string $args Preferences tab that is currently open
106      *
107      * @return void
108      */
109     public function hook_prefs_tab($args)
110     {
111         if ($args != "prefPrefs") {
112             return;
113         }
114
115         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
116
117         include __DIR__ . '/settings.phtml';
118     }
119
120     /**
121      * CLI command
122      */
123     public function micropub($args)
124     {
125         //we do not get all arguments passed here, to we work around
126         $args = $GLOBALS['argv'];
127         array_shift($args);//update.php
128         array_shift($args);//--micropub
129         $mode = array_shift($args);
130         return $this->action($mode, $args);
131     }
132
133     public function action($mode = null, $args = [])
134     {
135         if (isset($_POST['mode'])) {
136             $mode = $_POST['mode'];
137         } else if (isset($_GET['mode'])) {
138             $mode = $_GET['mode'];
139         }
140
141         if ($mode == 'authorize') {
142             return $this->authorizeAction($args);
143         } else if ($mode == 'authreturn') {
144             return $this->authreturnAction();
145         } else if ($mode == 'post') {
146             return $this->postAction();
147         } else {
148             $this->errorOut('Unsupported mode');
149         }
150     }
151
152     protected function postAction()
153     {
154         if (!isset($_POST['me'])) {
155             return $this->errorOut('"me" parameter missing');
156         }
157         $me = $_POST['me'];
158
159         if (!isset($_POST['replyTo'])) {
160             return $this->errorOut('"replyTo" parameter missing');
161         }
162         $replyTo = $_POST['replyTo'];
163
164         if (!isset($_POST['content'])) {
165             return $this->errorOut('"content" parameter missing');
166         }
167         $content = $_POST['content'];
168
169         $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
170         if (!isset($accounts[$me])) {
171             return $this->errorOut('"me" parameter invalid');
172         }
173         $account = $accounts[$me];
174
175         $links = $this->getLinks($me);
176         if (!count($links)) {
177             return $this->errorOut('No links found');
178         }
179         if (!isset($links['micropub'])) {
180             return $this->errorOut('No micropub endpoint found');
181         }
182
183         $res = fetch_file_contents(
184             [
185                 //FIXME: add content-type header once this is fixed:
186                 // https://discourse.tt-rss.org/t//207
187                 'url'        => $links['micropub'],
188                 //we use http_build_query to force cURL
189                 // to use x-www-form-urlencoded
190                 'post_query' => http_build_query(
191                     [
192                         'access_token' => $account['access_token'],
193                         'h'            => 'entry',
194                         'in-reply-to'  => $replyTo,
195                         'content'      => $content,
196                     ]
197                 ),
198                 'followlocation' => false,
199             ]
200         );
201
202         if ($GLOBALS['fetch_last_error_code'] == 201) {
203             //FIXME: extract location header
204             echo "OK, comment post created\n";
205         } else {
206             $this->errorOut(
207                 'An error occured: '
208                 . $GLOBALS['fetch_last_error_code']
209                 . ' ' . $GLOBALS['fetch_last_error_code_content']
210             );
211         }
212     }
213
214     protected function authorizeAction($args = [])
215     {
216         if (count($args)) {
217             $url = array_shift($args);
218         } else if (isset($_POST['url'])) {
219             $url = $_POST['url'];
220         }
221         if (!filter_var($url, FILTER_VALIDATE_URL)) {
222             return $this->errorOut('Invalid URL');
223         }
224
225         //step 1: micropub discovery
226         $links = $this->getLinks($url);
227
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         if (!isset($links['token_endpoint'])) {
235             return $this->errorOut('No token endpoint found');
236         }
237         if (!isset($links['authorization_endpoint'])) {
238             return $this->errorOut('No authorization endpoint found');
239         }
240
241         $redirUrl = get_self_url_prefix() . '/backend.php'
242             . '?op=micropub&method=action&mode=authreturn';
243         $authUrl = $links['authorization_endpoint']
244             . '?me=' . $url
245             . '&redirect_uri=' . urlencode($redirUrl)
246             . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
247             //. '&state=' . 'FIXME'
248             . '&scope=create'
249             . '&response_type=code';
250         header('Location: ' . $authUrl);
251         echo $authUrl . "\n";
252         exit();
253     }
254
255     /**
256      * Return from authorization
257      */
258     public function authreturnAction()
259     {
260         if (!isset($_GET['me'])) {
261             return $this->errorOut('"me" parameter missing');
262         }
263         if (!isset($_GET['code'])) {
264             return $this->errorOut('"code" parameter missing');
265         }
266
267         $links = $this->getLinks($_GET['me']);
268         if (!isset($links['token_endpoint'])) {
269             return $this->errorOut('No token endpoint found');
270         }
271
272         //obtain access token from the code
273         $redirUrl = get_self_url_prefix() . '/backend.php'
274             . '?op=micropub&method=action&mode=authreturn';
275         $res = fetch_file_contents(
276             [
277                 //FIXME: add accept header once this is fixed:
278                 // https://discourse.tt-rss.org/t//207
279                 'url'        => $links['token_endpoint'],
280                 'post_query' => [
281                     'grant_type'   => 'authorization_code',
282                     'me'           => $_GET['me'],
283                     'code'         => $_GET['code'],
284                     'redirect_uri' => $redirUrl,
285                     'client_id'    => get_self_url_prefix()
286                 ]
287             ]
288         );
289
290         //we have no way to get the content type :/
291         if ($res{0} == '{') {
292             //json
293             $data = json_decode($res);
294         } else {
295             parse_str($res, $data);
296         }
297         if (!isset($data['access_token'])) {
298             return $this->errorOut('access token missing');
299         }
300         if (!isset($data['me'])) {
301             return $this->errorOut('access token missing');
302         }
303         if (!isset($data['scope'])) {
304             return $this->errorOut('scope token missing');
305         }
306
307         $host = PluginHost::getInstance();
308         $accounts = $host->get($this, 'accounts', []);
309         $accounts[$data['me']] = [
310             'access_token' => $data['access_token'],
311             'scope'        => $data['scope'],
312         ];
313         $host->set($this, 'accounts', $accounts);
314
315         //all fine now.
316         header('Location: prefs.php');
317     }
318
319     protected function errorOut($msg)
320     {
321         echo $msg . "\n";
322         exit(1);
323     }
324
325     protected function getLinks($url)
326     {
327         //FIXME: HTTP Link header support with HTTP2
328         $html = fetch_file_contents(
329             [
330                 'url' => $url,
331             ]
332         );
333         //Loading invalid HTML is tedious.
334         // quick hack with regex. yay!
335         preg_match_all('#<link[^>]+?>#', $html, $matches);
336         $links = [];
337         foreach ($matches[0] as $match) {
338             if (substr($match, -2) != '/>') {
339                 //make it valid xml...
340                 $match = substr($match, 0, -1) . '/>';
341             }
342             $sx = simplexml_load_string($match);
343             if (isset($sx['rel']) && isset($sx['href'])
344                 && !isset($links[(string) $sx['rel']])
345             ) {
346                 $links[(string) $sx['rel']] = (string) $sx['href'];
347             }
348         }
349         return $links;
350     }
351
352     function csrf_ignore($method)
353     {
354         return true;
355     }
356
357     /**
358      * Check which method is allowed via HTTP
359      */
360     function before($method)
361     {
362         if ($method == 'action') {
363             return true;
364         }
365         return false;
366     }
367
368     function after()
369     {
370         return true;
371     }
372 }
373 ?>