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