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 = array_keys(PluginHost::getInstance()->get($this, 'accounts', []));
87 if (!count($accounts)) {
91 include __DIR__ . '/commentform.phtml';
92 $html = ob_get_clean();
93 $article['content'] .= $html;
99 * Render our configuration page.
100 * Directly echo it out.
102 * @param string $args Preferences tab that is currently open
106 public function hook_prefs_tab($args)
108 if ($args != "prefPrefs") {
112 $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
113 if (isset($_REQUEST['accordion'])
114 && $_REQUEST['accordion'] == 'micropub'
116 $accordionActive = 'selected="true"';
118 $accordionActive = '';
121 //FIXME: default identity
122 include __DIR__ . '/settings.phtml';
125 public function get_prefs_js()
127 return file_get_contents(__DIR__ . '/settings.js');
133 public function micropub($args)
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);
145 * Also used by micropub() cli command method.
147 * /backend.php?op=pluginhandler&plugin=micropub&method=action
149 public function action($mode = null, $args = [])
151 if (isset($_POST['mode'])) {
152 $mode = $_POST['mode'];
153 } else if (isset($_GET['mode'])) {
154 $mode = $_GET['mode'];
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();
166 return $this->errorOut('Unsupported mode');
170 protected function postAction()
172 if (!isset($_POST['me'])) {
173 return $this->errorOut('"me" parameter missing');
175 $me = trim($_POST['me']);
177 if (!isset($_POST['replyTo'])) {
178 return $this->errorOut('"replyTo" parameter missing');
180 $replyTo = trim($_POST['replyTo']);
182 if (!isset($_POST['content'])) {
183 return $this->errorOut('"content" parameter missing');
185 $content = trim($_POST['content']);
187 $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
188 if (!isset($accounts[$me])) {
189 return $this->errorOut('"me" parameter invalid');
191 $account = $accounts[$me];
193 $links = $this->getLinks($me);
194 if (!count($links)) {
195 return $this->errorOut('No links found');
197 if (!isset($links['micropub'])) {
198 return $this->errorOut('No micropub endpoint found');
201 /* unfortunately fetch_file_contents() does not return headers
202 so we have to bring our own way to POST data */
206 'header' => 'Content-type: application/x-www-form-urlencoded',
207 'content' => http_build_query(
209 'access_token' => $account['access_token'],
211 'in-reply-to' => $replyTo,
212 'content' => $content,
215 'ignore_errors' => true,
219 $links['micropub'], 'r', false,
220 stream_context_create($opts)
222 $meta = stream_get_meta_data($stream);
223 $headers = $meta['wrapper_data'];
224 $content = stream_get_contents($stream);
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(
233 . $code . ' ' . $text
238 foreach ($headers as $header) {
239 $parts = explode(':', $header, 2);
240 if (count($parts) == 2 && strtolower($parts[0]) == 'location') {
241 $location = trim($parts[1]);
244 if ($location === null) {
245 return $this->errorOut(
246 'Location header missing in successful creation response.'
250 header('Content-type: application/json');
253 'code' => intval($code),
254 'location' => $location,
255 'message' => 'Post created',
261 protected function authorizeAction($args = [])
264 $url = array_shift($args);
265 } else if (isset($_POST['url'])) {
266 $url = $_POST['url'];
268 if (!filter_var($url, FILTER_VALIDATE_URL)) {
269 return $this->errorOut('Invalid URL');
272 //step 1: micropub discovery
273 $links = $this->getLinks($url);
275 if (!count($links)) {
276 return $this->errorOut('No links found');
278 if (!isset($links['micropub'])) {
279 return $this->errorOut('No micropub endpoint found');
281 if (!isset($links['token_endpoint'])) {
282 return $this->errorOut('No token endpoint found');
284 if (!isset($links['authorization_endpoint'])) {
285 return $this->errorOut('No authorization endpoint found');
288 $redirUrl = get_self_url_prefix() . '/backend.php'
289 . '?op=micropub&method=action&mode=authreturn';
290 $authUrl = $links['authorization_endpoint']
292 . '&redirect_uri=' . urlencode($redirUrl)
293 . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
294 //. '&state=' . 'FIXME'
296 . '&response_type=code';
297 header('Location: ' . $authUrl);
298 echo $authUrl . "\n";
303 * Return from authorization
305 public function authreturnAction()
307 if (!isset($_GET['me'])) {
308 return $this->errorOut('"me" parameter missing');
310 if (!isset($_GET['code'])) {
311 return $this->errorOut('"code" parameter missing');
314 $links = $this->getLinks($_GET['me']);
315 if (!isset($links['token_endpoint'])) {
316 return $this->errorOut('No token endpoint found');
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(
324 //FIXME: add accept header once this is fixed:
325 // https://discourse.tt-rss.org/t//207
326 'url' => $links['token_endpoint'],
328 'grant_type' => 'authorization_code',
330 'code' => $_GET['code'],
331 'redirect_uri' => $redirUrl,
332 'client_id' => get_self_url_prefix()
337 //we have no way to get the content type :/
338 if ($res{0} == '{') {
340 $data = json_decode($res);
342 parse_str($res, $data);
344 if (!isset($data['access_token'])) {
345 return $this->errorOut('access token missing');
347 if (!isset($data['me'])) {
348 return $this->errorOut('access token missing');
350 if (!isset($data['scope'])) {
351 return $this->errorOut('scope token missing');
354 $host = PluginHost::getInstance();
355 $accounts = $host->get($this, 'accounts', []);
356 $accounts[$data['me']] = [
357 'access_token' => $data['access_token'],
358 'scope' => $data['scope'],
360 $host->set($this, 'accounts', $accounts);
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');
370 * Backend preferences action: Remove a given account
372 protected function deleteIdentityAction()
374 if (!isset($_POST['me'])) {
375 return $this->errorOut('"me" parameter missing');
377 $me = trim($_POST['me']);
379 $host = PluginHost::getInstance();
380 $accounts = $host->get($this, 'accounts', []);
381 if (!isset($accounts[$me])) {
382 return $this->errorOut('Unknown identity');
385 unset($accounts[$me]);
386 $host->set($this, 'accounts', $accounts);
387 header('Content-type: application/json');
392 'message' => 'Identity removed',
399 * Send an error message.
400 * Automatically in the correct format (plain text or json)
402 * @param string $msg Error message
406 protected function errorOut($msg)
408 header('HTTP/1.0 400 Bad Request');
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
415 header('Content-type: application/json');
422 header('Content-type: text/plain');
429 * Extract link relations from a given URL
431 protected function getLinks($url)
433 //FIXME: HTTP Link header support with HTTP2
434 $html = fetch_file_contents(
439 //Loading invalid HTML is tedious.
440 // quick hack with regex. yay!
441 preg_match_all('#<link[^>]+?>#', $html, $matches);
443 foreach ($matches[0] as $match) {
444 if (substr($match, -2) != '/>') {
445 //make it valid xml...
446 $match = substr($match, 0, -1) . '/>';
448 $sx = simplexml_load_string($match);
449 if (isset($sx['rel']) && isset($sx['href'])
450 && !isset($links[(string) $sx['rel']])
452 $links[(string) $sx['rel']] = (string) $sx['href'];
458 function csrf_ignore($method)
464 * Check which method is allowed via HTTP
466 function before($method)
468 if ($method == 'action') {