3 * Simple Micropub client to post reponses
7 * @author Christian Weiske <cweiske@cweiske.de>
8 * @license AGPLv3 or later
9 * @link https://www.w3.org/TR/micropub/
11 class Micropub extends Plugin implements IHandler
14 * Dumb workaround for "private $host" in Plugin class
15 * + the re-creation of the plugin instance without calling init().
18 * @see https://discourse.tt-rss.org/t//208
19 * @see https://discourse.tt-rss.org/t//209
21 protected static $myhost;
23 public function __construct()
25 //do nothing. only here to not have micropub() called as constructor
28 public function about()
38 public function api_version()
47 public function init(/*PluginHost*/ $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
54 'micropub', 'Add Micropub identity', $this, ':', 'MODE'
59 * @param array $article Article data. Keys:
72 * - always_display_enclosures
80 public function hook_render_article($article)
82 $quillUrl = 'https://quill.p3k.io/new'
83 . '?reply=' . urlencode($article['link']);
84 // did I tell you I hate dojo/dijit?
86 $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
87 if (!count($accounts)) {
91 $accountUrls = array_keys($accounts);
92 $defaultAccount = null;
93 foreach ($accounts as $url => $account) {
94 if ($account['default']) {
95 $defaultAccount = $url;
100 include __DIR__ . '/commentform.phtml';
101 $html = ob_get_clean();
102 $article['content'] .= $html;
108 * Render our configuration page.
109 * Directly echo it out.
111 * @param string $args Preferences tab that is currently open
115 public function hook_prefs_tab($args)
117 if ($args != "prefPrefs") {
121 $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
122 if (isset($_REQUEST['accordion'])
123 && $_REQUEST['accordion'] == 'micropub'
125 $accordionActive = 'selected="true"';
127 $accordionActive = '';
130 foreach ($accounts as $url => $account) {
131 $accounts[$url]['checked'] = '';
132 if ($account['default']) {
133 $accounts[$url]['checked'] = 'checked="checked"';
137 //FIXME: default identity
138 include __DIR__ . '/settings.phtml';
141 public function get_prefs_js()
143 return file_get_contents(__DIR__ . '/settings.js');
149 public function micropub($args)
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);
161 * Also used by micropub() cli command method.
163 * /backend.php?op=pluginhandler&plugin=micropub&method=action
165 public function action($mode = null, $args = [])
167 if (isset($_POST['mode'])) {
168 $mode = $_POST['mode'];
169 } else if (isset($_GET['mode'])) {
170 $mode = $_GET['mode'];
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();
184 return $this->errorOut('Unsupported mode');
189 * Post a comment, like or bookmark via micropub
191 protected function postAction()
194 if (isset($_POST['action'])) {
195 $action = trim($_POST['action']);
197 if (array_search($action, ['bookmark', 'comment', 'like']) === false) {
198 return $this->errorOut('"action" parameter invalid');
201 if (!isset($_POST['me'])) {
202 return $this->errorOut('"me" parameter missing');
204 $me = trim($_POST['me']);
205 $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
206 if (!isset($accounts[$me])) {
207 return $this->errorOut('"me" parameter invalid');
209 $account = $accounts[$me];
211 if (!isset($_POST['postUrl'])) {
212 return $this->errorOut('"postUrl" parameter missing');
214 $postUrl = trim($_POST['postUrl']);
216 if ($action == 'comment') {
217 if (!isset($_POST['content'])) {
218 return $this->errorOut('"content" parameter missing');
220 $content = trim($_POST['content']);
221 if (!strlen($_POST['content'])) {
222 return $this->errorOut('"content" is empty');
227 $links = $this->getLinks($me);
228 if ($links === false) {
229 return $this->errorOut('Error fetching URL: ' . $me);
231 if (!count($links)) {
232 return $this->errorOut('No links found');
234 if (!isset($links['micropub'])) {
235 return $this->errorOut('No micropub endpoint found');
239 'access_token' => $account['access_token'],
243 if ($action == 'bookmark') {
244 $parameters['bookmark-of'] = $postUrl;
246 } else if ($action == 'comment') {
247 $parameters['in-reply-to'] = $postUrl;
248 $parameters['content'] = $content;
250 } else if ($action == 'like') {
251 $parameters['like-of'] = $postUrl;
255 /* unfortunately fetch_file_contents() does not return headers
256 so we have to bring our own way to POST data */
260 'header' => 'Content-type: application/x-www-form-urlencoded',
261 'content' => http_build_query($parameters),
262 'ignore_errors' => true,
266 $links['micropub'], 'r', false,
267 stream_context_create($opts)
269 $meta = stream_get_meta_data($stream);
270 $headers = $meta['wrapper_data'];
271 $content = stream_get_contents($stream);
273 //we hope there were no redirects and this is actually the only
274 // HTTP line in the headers
275 $status = array_shift($headers);
276 list($httpver, $code, $text) = explode(' ', $status, 3);
277 if ($code != 201 && $code != 202) {
278 $errData = json_decode($content);
279 if (isset($errData->error_description)
280 && $errData->error_description != ''
282 return $this->errorOut(
283 'Error creating post: '
284 . $errData->error_description
287 return $this->errorOut(
288 'Error creating post: '
289 . $code . ' ' . $text.$content
294 foreach ($headers as $header) {
295 $parts = explode(':', $header, 2);
296 if (count($parts) == 2 && strtolower($parts[0]) == 'location') {
297 $location = trim($parts[1]);
300 if ($location === null) {
301 return $this->errorOut(
302 'Location header missing in successful creation response.'
306 header('Content-type: application/json');
309 'code' => intval($code),
310 'location' => $location,
311 'message' => 'Post created',
317 protected function authorizeAction($args = [])
320 $url = array_shift($args);
321 } else if (isset($_POST['url'])) {
322 $url = $_POST['url'];
324 if (!filter_var($url, FILTER_VALIDATE_URL)) {
325 return $this->errorOut('Invalid URL');
328 //step 1: micropub discovery
329 $links = $this->getLinks($url);
331 if ($links === false) {
332 return $this->errorOut('Error fetching URL: ' . $url);
334 if (!count($links)) {
335 return $this->errorOut('No links found');
337 if (!isset($links['micropub'])) {
338 return $this->errorOut('No micropub endpoint found');
340 if (!isset($links['token_endpoint'])) {
341 return $this->errorOut('No token endpoint found');
343 if (!isset($links['authorization_endpoint'])) {
344 return $this->errorOut('No authorization endpoint found');
347 $redirUrl = get_self_url_prefix() . '/backend.php'
348 . '?op=micropub&method=action&mode=authreturn';
349 $authUrl = $links['authorization_endpoint']
351 . '&redirect_uri=' . urlencode($redirUrl)
352 . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
353 //. '&state=' . 'FIXME'
355 . '&response_type=code';
356 header('Location: ' . $authUrl);
357 echo $authUrl . "\n";
362 * Return from authorization
364 public function authreturnAction()
366 if (!isset($_GET['me'])) {
367 return $this->errorOut('"me" parameter missing');
369 if (!isset($_GET['code'])) {
370 return $this->errorOut('"code" parameter missing');
373 $links = $this->getLinks($_GET['me']);
374 if ($links === false) {
375 return $this->errorOut('Error fetching URL: ' . $_GET['me']);
377 if (!isset($links['token_endpoint'])) {
378 return $this->errorOut('No token endpoint found');
381 //obtain access token from the code
382 $redirUrl = get_self_url_prefix() . '/backend.php'
383 . '?op=micropub&method=action&mode=authreturn';
384 $res = fetch_file_contents(
386 //FIXME: add accept header once this is fixed:
387 // https://discourse.tt-rss.org/t//207
388 'url' => $links['token_endpoint'],
390 'grant_type' => 'authorization_code',
392 'code' => $_GET['code'],
393 'redirect_uri' => $redirUrl,
394 'client_id' => get_self_url_prefix()
399 //we have no way to get the content type :/
400 if ($res{0} == '{') {
402 $data = json_decode($res);
404 parse_str($res, $data);
406 if (!isset($data['access_token'])) {
407 return $this->errorOut('access token missing');
409 if (!isset($data['me'])) {
410 return $this->errorOut('access token missing');
412 if (!isset($data['scope'])) {
413 return $this->errorOut('scope token missing');
416 $host = PluginHost::getInstance();
417 $accounts = $host->get($this, 'accounts', []);
418 $accounts[$data['me']] = [
419 'access_token' => $data['access_token'],
420 'scope' => $data['scope'],
422 $accounts = $this->fixDefaultIdentity($accounts);
423 $host->set($this, 'accounts', $accounts);
426 //the accordion parameter will never work
427 // because fox has serious mental problems
428 // https://discourse.tt-rss.org/t/open-a-certain-accordion-in-preferences-by-url-parameter/234
429 header('Location: prefs.php?accordion=micropub');
433 * Backend preferences action: Remove a given account
435 protected function deleteIdentityAction()
437 if (!isset($_POST['me'])) {
438 return $this->errorOut('"me" parameter missing');
440 $me = trim($_POST['me']);
442 $host = PluginHost::getInstance();
443 $accounts = $host->get($this, 'accounts', []);
444 if (!isset($accounts[$me])) {
445 return $this->errorOut('Unknown identity');
448 unset($accounts[$me]);
449 $accounts = $this->fixDefaultIdentity($accounts);
450 $host->set($this, 'accounts', $accounts);
452 header('Content-type: application/json');
456 'message' => 'Identity removed',
463 * Backend preferences action: Make a given account the default
465 protected function setDefaultIdentityAction()
467 if (!isset($_POST['me'])) {
468 return $this->errorOut('"me" parameter missing');
470 $me = trim($_POST['me']);
472 $host = PluginHost::getInstance();
473 $accounts = $host->get($this, 'accounts', []);
474 if (!isset($accounts[$me])) {
475 return $this->errorOut('Unknown identity');
477 foreach ($accounts as $url => $data) {
478 $accounts[$url]['default'] = ($url == $me);
480 $host->set($this, 'accounts', $accounts);
482 header('Content-type: application/json');
486 'message' => 'Default account set',
493 * Set the default identity if there is none
495 * @param array $accounts Array of account data arrays
497 * @return array Array of account data arrays
499 protected function fixDefaultIdentity($accounts)
501 if (!count($accounts)) {
506 foreach ($accounts as $account) {
507 if ($account['default']) {
514 $accounts[key($accounts)]['default'] = true;
520 * Send an error message.
521 * Automatically in the correct format (plain text or json)
523 * @param string $msg Error message
527 protected function errorOut($msg)
529 header('HTTP/1.0 400 Bad Request');
531 //this does not take "q"uality values into account, I know.
532 if (isset($_SERVER['HTTP_ACCEPT'])
533 && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false
536 header('Content-type: application/json');
543 header('Content-type: text/plain');
550 * Extract link relations from a given URL
552 * @param string $url URL to extract links from
554 * @return bool|array Array of links, or false on HTTP error
556 protected function getLinks($url)
558 //FIXME: HTTP Link header support with HTTP2
559 $html = fetch_file_contents(
564 if ($html === false) {
568 //Loading invalid HTML is tedious.
569 // quick hack with regex. yay!
570 preg_match_all('#<link[^>]+?>#', $html, $matches);
572 foreach ($matches[0] as $match) {
573 if (substr($match, -2) != '/>') {
574 //make it valid xml...
575 $match = substr($match, 0, -1) . '/>';
577 $sx = simplexml_load_string($match);
578 if (isset($sx['rel']) && isset($sx['href'])
579 && !isset($links[(string) $sx['rel']])
581 $links[(string) $sx['rel']] = (string) $sx['href'];
588 * If a valid CSRF token is necessary or not
590 * @param string $method Plugin method name (here: "action")
592 * @return boolean True if an invalid CSRF token shall be ignored
594 function csrf_ignore($method)
597 if (isset($_POST['mode'])) {
598 $mode = $_POST['mode'];
599 } else if (isset($_GET['mode'])) {
600 $mode = $_GET['mode'];
603 if ($mode == 'authreturn') {
611 * Check which method is allowed via HTTP
613 function before($method)
615 if ($method == 'action') {