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()
46 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'
60 return file_get_contents(__DIR__ . '/init.css');
64 * @param array $article Article data. Keys:
77 * - always_display_enclosures
85 public function hook_render_article($article)
87 $quillUrl = 'https://quill.p3k.io/new'
88 . '?reply=' . urlencode($article['link']);
89 // did I tell you I hate dojo/dijit?
91 $accounts = array_keys(PluginHost::getInstance()->get($this, 'accounts', []));
94 include __DIR__ . '/commentform.phtml';
95 $html = ob_get_clean();
96 $article['content'] .= $html;
102 * Render our configuration page.
103 * Directly echo it out.
105 * @param string $args Preferences tab that is currently open
109 public function hook_prefs_tab($args)
111 if ($args != "prefPrefs") {
115 $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
117 include __DIR__ . '/settings.phtml';
123 public function micropub($args)
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);
133 public function action($mode = null, $args = [])
135 if (isset($_POST['mode'])) {
136 $mode = $_POST['mode'];
137 } else if (isset($_GET['mode'])) {
138 $mode = $_GET['mode'];
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();
148 $this->errorOut('Unsupported mode');
152 protected function postAction()
154 if (!isset($_POST['me'])) {
155 return $this->errorOut('"me" parameter missing');
159 if (!isset($_POST['replyTo'])) {
160 return $this->errorOut('"replyTo" parameter missing');
162 $replyTo = $_POST['replyTo'];
164 if (!isset($_POST['content'])) {
165 return $this->errorOut('"content" parameter missing');
167 $content = $_POST['content'];
169 $accounts = PluginHost::getInstance()->get($this, 'accounts', []);
170 if (!isset($accounts[$me])) {
171 return $this->errorOut('"me" parameter invalid');
173 $account = $accounts[$me];
175 $links = $this->getLinks($me);
176 if (!count($links)) {
177 return $this->errorOut('No links found');
179 if (!isset($links['micropub'])) {
180 return $this->errorOut('No micropub endpoint found');
183 $res = fetch_file_contents(
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(
192 'access_token' => $account['access_token'],
194 'in-reply-to' => $replyTo,
195 'content' => $content,
198 'followlocation' => false,
202 if ($GLOBALS['fetch_last_error_code'] == 201) {
203 //FIXME: extract location header
204 echo "OK, comment post created\n";
208 . $GLOBALS['fetch_last_error_code']
209 . ' ' . $GLOBALS['fetch_last_error_code_content']
214 protected function authorizeAction($args = [])
217 $url = array_shift($args);
218 } else if (isset($_POST['url'])) {
219 $url = $_POST['url'];
221 if (!filter_var($url, FILTER_VALIDATE_URL)) {
222 return $this->errorOut('Invalid URL');
225 //step 1: micropub discovery
226 $links = $this->getLinks($url);
228 if (!count($links)) {
229 return $this->errorOut('No links found');
231 if (!isset($links['micropub'])) {
232 return $this->errorOut('No micropub endpoint found');
234 if (!isset($links['token_endpoint'])) {
235 return $this->errorOut('No token endpoint found');
237 if (!isset($links['authorization_endpoint'])) {
238 return $this->errorOut('No authorization endpoint found');
241 $redirUrl = get_self_url_prefix() . '/backend.php'
242 . '?op=micropub&method=action&mode=authreturn';
243 $authUrl = $links['authorization_endpoint']
245 . '&redirect_uri=' . urlencode($redirUrl)
246 . '&client_id=' . urlencode(get_self_url_prefix())//FIXME: app info
247 //. '&state=' . 'FIXME'
249 . '&response_type=code';
250 header('Location: ' . $authUrl);
251 echo $authUrl . "\n";
256 * Return from authorization
258 public function authreturnAction()
260 if (!isset($_GET['me'])) {
261 return $this->errorOut('"me" parameter missing');
263 if (!isset($_GET['code'])) {
264 return $this->errorOut('"code" parameter missing');
267 $links = $this->getLinks($_GET['me']);
268 if (!isset($links['token_endpoint'])) {
269 return $this->errorOut('No token endpoint found');
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(
277 //FIXME: add accept header once this is fixed:
278 // https://discourse.tt-rss.org/t//207
279 'url' => $links['token_endpoint'],
281 'grant_type' => 'authorization_code',
283 'code' => $_GET['code'],
284 'redirect_uri' => $redirUrl,
285 'client_id' => get_self_url_prefix()
290 //we have no way to get the content type :/
291 if ($res{0} == '{') {
293 $data = json_decode($res);
295 parse_str($res, $data);
297 if (!isset($data['access_token'])) {
298 return $this->errorOut('access token missing');
300 if (!isset($data['me'])) {
301 return $this->errorOut('access token missing');
303 if (!isset($data['scope'])) {
304 return $this->errorOut('scope token missing');
307 $host = PluginHost::getInstance();
308 $accounts = $host->get($this, 'accounts', []);
309 $accounts[$data['me']] = [
310 'access_token' => $data['access_token'],
311 'scope' => $data['scope'],
313 $host->set($this, 'accounts', $accounts);
316 header('Location: prefs.php');
319 protected function errorOut($msg)
325 protected function getLinks($url)
327 //FIXME: HTTP Link header support with HTTP2
328 $html = fetch_file_contents(
333 //Loading invalid HTML is tedious.
334 // quick hack with regex. yay!
335 preg_match_all('#<link[^>]+?>#', $html, $matches);
337 foreach ($matches[0] as $match) {
338 if (substr($match, -2) != '/>') {
339 //make it valid xml...
340 $match = substr($match, 0, -1) . '/>';
342 $sx = simplexml_load_string($match);
343 if (isset($sx['rel']) && isset($sx['href'])
344 && !isset($links[(string) $sx['rel']])
346 $links[(string) $sx['rel']] = (string) $sx['href'];
352 function csrf_ignore($method)
358 * Check which method is allowed via HTTP
360 function before($method)
362 if ($method == 'action') {