aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/autolink.php335
-rw-r--r--lib/database.php56
-rw-r--r--lib/functions.php258
-rw-r--r--lib/rsd.xml.php15
-rw-r--r--lib/twitter_api.php410
-rw-r--r--lib/xmlrpc.php428
6 files changed, 1502 insertions, 0 deletions
diff --git a/lib/autolink.php b/lib/autolink.php
new file mode 100644
index 0000000..da39c03
--- /dev/null
+++ b/lib/autolink.php
@@ -0,0 +1,335 @@
+<?php
+ #
+ # A PHP auto-linking library
+ #
+ # https://github.com/iamcal/lib_autolink
+ #
+ # By Cal Henderson <cal@iamcal.com>
+ # This code is licensed under the MIT license
+ #
+
+ ####################################################################
+
+ #
+ # These are global options. You can set them before calling the autolinking
+ # functions to change the output.
+ #
+
+ $GLOBALS['autolink_options'] = array(
+
+ # Should http:// be visibly stripped from the front
+ # of URLs?
+ 'strip_protocols' => true,
+
+ );
+
+ ####################################################################
+
+ function autolink($text, $limit=30, $tagfill='', $auto_title = true){
+
+ $text = autolink_do($text, '![a-z][a-z-]+://!i', $limit, $tagfill, $auto_title);
+ $text = autolink_do($text, '!(mailto|skype):!i', $limit, $tagfill, $auto_title);
+ $text = autolink_do($text, '!www\\.!i', $limit, $tagfill, $auto_title, 'http://');
+ return $text;
+ }
+
+ ####################################################################
+
+ function autolink_do($text, $sub, $limit, $tagfill, $auto_title, $force_prefix=null){
+
+ $text_l = StrToLower($text);
+ $cursor = 0;
+ $loop = 1;
+ $buffer = '';
+
+ while (($cursor < strlen($text)) && $loop){
+
+ $ok = 1;
+ $matched = preg_match($sub, $text_l, $m, PREG_OFFSET_CAPTURE, $cursor);
+
+ if (!$matched){
+
+ $loop = 0;
+ $ok = 0;
+
+ }else{
+
+ $pos = $m[0][1];
+ $sub_len = strlen($m[0][0]);
+
+ $pre_hit = substr($text, $cursor, $pos-$cursor);
+ $hit = substr($text, $pos, $sub_len);
+ $pre = substr($text, 0, $pos);
+ $post = substr($text, $pos + $sub_len);
+
+ $fail_text = $pre_hit.$hit;
+ $fail_len = strlen($fail_text);
+
+ #
+ # substring found - first check to see if we're inside a link tag already...
+ #
+
+ $bits = preg_split("!</a>!i", $pre);
+ $last_bit = array_pop($bits);
+ if (preg_match("!<a\s!i", $last_bit)){
+
+ #echo "fail 1 at $cursor<br />\n";
+
+ $ok = 0;
+ $cursor += $fail_len;
+ $buffer .= $fail_text;
+ }
+ }
+
+ #
+ # looks like a nice spot to autolink from - check the pre
+ # to see if there was whitespace before this match
+ #
+
+ if ($ok){
+
+ if ($pre){
+ if (!preg_match('![\s\(\[\{>\pZ\p{Cc}]$!s', $pre)){
+
+ #echo "fail 2 at $cursor ($pre)<br />\n";
+
+ $ok = 0;
+ $cursor += $fail_len;
+ $buffer .= $fail_text;
+ }
+ }
+ }
+
+ #
+ # we want to autolink here - find the extent of the url
+ #
+
+ if ($ok){
+ if (preg_match('/^([a-z0-9\-\.\/\-_%~!?=,:;&+*#@\(\)\$]+)/i', $post, $matches)){
+
+ $url = $hit.$matches[1];
+
+ $cursor += strlen($url) + strlen($pre_hit);
+ $buffer .= $pre_hit;
+
+ $url = html_entity_decode($url);
+
+
+ #
+ # remove trailing punctuation from url
+ #
+
+ while (preg_match('|[.,!;:?]$|', $url)){
+ $url = substr($url, 0, strlen($url)-1);
+ $cursor--;
+ }
+ foreach (array('()', '[]', '{}') as $pair){
+ $o = substr($pair, 0, 1);
+ $c = substr($pair, 1, 1);
+ if (preg_match("!^(\\$c|^)[^\\$o]+\\$c$!", $url)){
+ $url = substr($url, 0, strlen($url)-1);
+ $cursor--;
+ }
+ }
+
+
+ #
+ # nice-i-fy url here
+ #
+
+ $link_url = $url;
+ $display_url = $url;
+
+ if ($force_prefix) $link_url = $force_prefix.$link_url;
+
+ if ($GLOBALS['autolink_options']['strip_protocols']){
+ if (preg_match('!^(http|https)://!i', $display_url, $m)){
+
+ $display_url = substr($display_url, strlen($m[1])+3);
+ }
+ }
+
+ $display_url = autolink_label($display_url, $limit);
+
+
+ #
+ # add the url
+ #
+
+ $currentTagfill = $tagfill;
+ if ($display_url != $link_url && !preg_match('@title=@msi',$currentTagfill) && $auto_title) {
+
+ $display_quoted = preg_quote($display_url, '!');
+
+ if (!preg_match("!^(http|https)://{$display_quoted}$!i", $link_url)){
+
+ $currentTagfill .= ' title="'.$link_url.'"';
+ }
+ }
+
+ $link_url_enc = HtmlSpecialChars($link_url);
+ $display_url_enc = HtmlSpecialChars($display_url);
+
+ $buffer .= "<a href=\"{$link_url_enc}\"$currentTagfill>{$display_url_enc}</a>";
+
+ }else{
+ #echo "fail 3 at $cursor<br />\n";
+
+ $ok = 0;
+ $cursor += $fail_len;
+ $buffer .= $fail_text;
+ }
+ }
+
+ }
+
+ #
+ # add everything from the cursor to the end onto the buffer.
+ #
+
+ $buffer .= substr($text, $cursor);
+
+ return $buffer;
+ }
+
+ ####################################################################
+
+ function autolink_label($text, $limit){
+
+ if (!$limit){ return $text; }
+
+ if (strlen($text) > $limit){
+ return substr($text, 0, $limit-3).'...';
+ }
+
+ return $text;
+ }
+
+ ####################################################################
+
+ function autolink_email($text, $tagfill=''){
+
+ $atom = '[^()<>@,;:\\\\".\\[\\]\\x00-\\x20\\x7f]+'; # from RFC822
+
+ #die($atom);
+
+ $text_l = StrToLower($text);
+ $cursor = 0;
+ $loop = 1;
+ $buffer = '';
+
+ while(($cursor < strlen($text)) && $loop){
+
+ #
+ # find an '@' symbol
+ #
+
+ $ok = 1;
+ $pos = strpos($text_l, '@', $cursor);
+
+ if ($pos === false){
+
+ $loop = 0;
+ $ok = 0;
+
+ }else{
+
+ $pre = substr($text, $cursor, $pos-$cursor);
+ $hit = substr($text, $pos, 1);
+ $post = substr($text, $pos + 1);
+
+ $fail_text = $pre.$hit;
+ $fail_len = strlen($fail_text);
+
+ #die("$pre::$hit::$post::$fail_text");
+
+ #
+ # substring found - first check to see if we're inside a link tag already...
+ #
+
+ $bits = preg_split("!</a>!i", $pre);
+ $last_bit = array_pop($bits);
+ if (preg_match("!<a\s!i", $last_bit)){
+
+ #echo "fail 1 at $cursor<br />\n";
+
+ $ok = 0;
+ $cursor += $fail_len;
+ $buffer .= $fail_text;
+ }
+ }
+
+ #
+ # check backwards
+ #
+
+ if ($ok){
+ if (preg_match("!($atom(\.$atom)*)\$!", $pre, $matches)){
+
+ # move matched part of address into $hit
+
+ $len = strlen($matches[1]);
+ $plen = strlen($pre);
+
+ $hit = substr($pre, $plen-$len).$hit;
+ $pre = substr($pre, 0, $plen-$len);
+
+ }else{
+
+ #echo "fail 2 at $cursor ($pre)<br />\n";
+
+ $ok = 0;
+ $cursor += $fail_len;
+ $buffer .= $fail_text;
+ }
+ }
+
+ #
+ # check forwards
+ #
+
+ if ($ok){
+ if (preg_match("!^($atom(\.$atom)*)!", $post, $matches)){
+
+ # move matched part of address into $hit
+
+ $len = strlen($matches[1]);
+
+ $hit .= substr($post, 0, $len);
+ $post = substr($post, $len);
+
+ }else{
+ #echo "fail 3 at $cursor ($post)<br />\n";
+
+ $ok = 0;
+ $cursor += $fail_len;
+ $buffer .= $fail_text;
+ }
+ }
+
+ #
+ # commit
+ #
+
+ if ($ok) {
+
+ $cursor += strlen($pre) + strlen($hit);
+ $buffer .= $pre;
+ $buffer .= "<a href=\"mailto:$hit\"$tagfill>$hit</a>";
+
+ }
+
+ }
+
+ #
+ # add everything from the cursor to the end onto the buffer.
+ #
+
+ $buffer .= substr($text, $cursor);
+
+ return $buffer;
+ }
+
+ ####################################################################
+
+?>
diff --git a/lib/database.php b/lib/database.php
new file mode 100644
index 0000000..0ffdb3a
--- /dev/null
+++ b/lib/database.php
@@ -0,0 +1,56 @@
+<?php
+
+//connect or create the database
+try {
+ $db = new PDO('sqlite:'.ROOT.DS.'posts.db');
+ $config['db_version'] = $db->query("PRAGMA user_version")->fetch(PDO::FETCH_ASSOC)['user_version'];
+} catch(PDOException $e) {
+ print 'Exception : '.$e->getMessage();
+ die('cannot connect to or open the database');
+}
+
+// first time setup
+if($config['db_version'] == 0) {
+ try {
+ $db->exec("CREATE TABLE IF NOT EXISTS `posts` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `post_content` TEXT,
+ `post_timestamp` INTEGER
+ ); PRAGMA `user_version` = 1;");
+ $config['db_version'] = 1;
+ } catch(PDOException $e) {
+ print 'Exception : '.$e->getMessage();
+ die('cannot set up initial database table!');
+ }
+}
+
+// upgrade database to v2
+if($config['db_version'] == 1) {
+ try {
+ $db->exec("PRAGMA user_version = 2;
+ ALTER TABLE `posts` ADD `post_thread` INTEGER;
+ ALTER TABLE `posts` ADD `post_edited` INTEGER;
+ ALTER TABLE `posts` ADD `post_deleted` INTEGER;
+ ");
+ $config['db_version'] = 2;
+ } catch(PDOException $e) {
+ print 'Exception : '.$e->getMessage();
+ die('cannot upgrade database table to v2!');
+ }
+}
+
+// upgrade database to v3
+if($config['db_version'] == 2) {
+ try {
+ $db->exec("PRAGMA user_version = 3;
+ ALTER TABLE `posts` ADD `post_guid` TEXT;
+ ");
+ $config['db_version'] = 3;
+ } catch(PDOException $e) {
+ print 'Exception : '.$e->getMessage();
+ die('cannot upgrade database table to v3!');
+ }
+}
+
+// debug: get a list of post table columns
+// var_dump($db->query("PRAGMA table_info(`posts`)")->fetchAll(PDO::FETCH_COLUMN, 1));
diff --git a/lib/functions.php b/lib/functions.php
new file mode 100644
index 0000000..268808c
--- /dev/null
+++ b/lib/functions.php
@@ -0,0 +1,258 @@
+<?php
+
+function path($fragment=null) {
+ global $config;
+ if($fragment === null) return $config['path'];
+ return (!empty($config['path'][$fragment])) ? $config['path'][$fragment] : false;
+}
+
+function check_login() {
+ global $config;
+
+ if(isset($_COOKIE['microblog_login'])) {
+ if($_COOKIE['microblog_login'] === sha1($config['url'].$config['admin_pass'])) {
+ // correct auth data, extend cookie life
+ $domain = ($_SERVER['HTTP_HOST'] != 'localhost') ? $_SERVER['HTTP_HOST'] : false;
+ setcookie('microblog_login', sha1($config['url'].$config['admin_pass']), NOW+$config['cookie_life'], '/', $domain, false);
+
+ return true;
+ } else {
+ // invalid cookie data
+ unset($_COOKIE['microblog_login']);
+ setcookie('microblog_login', '', time()-3600, '/', $domain, false);
+ }
+ }
+
+ return false;
+}
+
+function db_insert($content, $timestamp=NOW) {
+ global $db;
+ if(empty($db)) return false;
+
+ $statement = $db->prepare('INSERT INTO posts (post_content, post_timestamp, post_guid) VALUES (:post_content, :post_timestamp, :post_guid)');
+ $statement->bindValue(':post_content', $content, PDO::PARAM_STR);
+ $statement->bindValue(':post_timestamp', $timestamp, PDO::PARAM_INT);
+ $statement->bindValue(':post_guid', uuidv4(), PDO::PARAM_STR);
+
+ $statement->execute();
+
+ return $db->lastInsertId();
+}
+
+function db_delete($post_id) {
+ global $db;
+ if(empty($db)) return false;
+ if(!is_numeric($post_id) || $post_id <= 0) return false;
+
+ /*
+ $statement = $db->prepare('DELETE FROM posts WHERE id = :id');
+ $statement->bindParam(':id', $post_id, PDO::PARAM_INT);
+ */
+
+ // mark as deleted instead (for undo?!)
+ $statement = $db->prepare('UPDATE posts SET post_deleted = :post_deleted WHERE id = :id');
+ $statement->bindValue(':id', $post_id, PDO::PARAM_INT);
+ $statement->bindValue(':post_deleted', time(), PDO::PARAM_INT);
+
+ $statement->execute();
+
+ return $statement->rowCount();
+}
+
+function db_update($post_id, $content, $timestamp=null) {
+ global $db;
+ if(empty($db)) return false;
+ if(empty($content)) return false;
+ if(!is_numeric($post_id) || $post_id <= 0) return false;
+
+ if($timestamp !== null) {
+ $statement = $db->prepare('UPDATE posts SET post_content = :post_content, post_edited = :post_edited, post_timestamp = :post_timestamp WHERE id = :id');
+ $statement->bindValue(':post_timestamp', $timestamp, PDO::PARAM_INT);
+ } else {
+ $statement = $db->prepare('UPDATE posts SET post_content = :post_content, post_edited = :post_edited WHERE id = :id');
+ }
+ $statement->bindValue(':id', $post_id, PDO::PARAM_INT);
+ $statement->bindValue(':post_content', $content, PDO::PARAM_STR);
+ $statement->bindValue(':post_edited', time(), PDO::PARAM_INT);
+
+ $statement->execute();
+
+ return $statement->rowCount();
+}
+
+function db_select_post($id=0) {
+ global $db;
+ if(empty($db)) return false;
+ if($id === 0) return false;
+
+ $statement = $db->prepare('SELECT * FROM posts WHERE id = :id LIMIT 1');
+ $statement->bindValue(':id', $id, PDO::PARAM_INT);
+ $statement->execute();
+ $row = $statement->fetch(PDO::FETCH_ASSOC);
+
+ return (!empty($row)) ? $row : false;
+}
+
+function db_select_posts($from, $amount=10, $sort='desc', $offset=0) {
+ global $db;
+ if(empty($db)) return false;
+ if(empty($from)) $from = time();
+ if($sort !== 'desc') $sort = 'asc';
+
+ $statement = $db->prepare('SELECT * FROM posts WHERE post_timestamp < :post_timestamp AND post_deleted IS NULL ORDER BY post_timestamp '.$sort.' LIMIT :limit OFFSET :page');
+ $statement->bindValue(':post_timestamp', $from, PDO::PARAM_INT);
+ $statement->bindValue(':limit', $amount, PDO::PARAM_INT);
+ $statement->bindValue(':page', $offset, PDO::PARAM_INT);
+ $statement->execute();
+ $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
+
+ return (!empty($rows)) ? $rows : false;
+}
+
+function db_posts_count() {
+ global $config;
+ global $db;
+ if(empty($db)) return false;
+
+ $statement = $db->prepare('SELECT COUNT(*) AS posts_count FROM posts');
+ $statement->execute();
+ $row = $statement->fetch(PDO::FETCH_ASSOC);
+
+ return (int) $row['posts_count'];
+}
+
+/* function that pings the official micro.blog endpoint for feed refreshes */
+function ping_microblog() {
+ global $config;
+ $ping_url = 'https://micro.blog/ping';
+ $feed_url = $config['url'].'/feed/json';
+
+ $ch = curl_init($ping_url);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, 'url='.urlencode($feed_url));
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_HEADER, true);
+ curl_setopt($ch, CURLOPT_NOBODY, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+
+ $response = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+
+ return ($status == 200) ? true : false;
+}
+
+function rebuild_feeds($amount=10) {
+
+ $posts = db_select_posts(NOW+60, $amount, 'desc');
+
+ rebuild_json_feed($posts);
+ rebuild_atom_feed($posts);
+}
+
+function rebuild_json_feed($posts=[]) {
+ global $config;
+
+ if (!file_exists(ROOT.DS.'feed')) {
+ mkdir(ROOT.DS.'feed', 0755);
+ }
+
+ $filename = ROOT.DS.'feed'.DS.'feed.json';
+
+ $feed = array(
+ 'version' => 'https://jsonfeed.org/version/1',
+ 'title' => 'status updates by '.$config['microblog_account'],
+ 'description' => '',
+ 'home_page_url' => $config['url'],
+ 'feed_url' => $config['url'].'/feed/feed.json',
+ 'user_comment' => '',
+ 'favicon' => '',
+ 'author' => array('name' => $config['microblog_account']),
+ 'items' => array()
+ );
+
+ foreach($posts as $post) {
+
+ $feed['items'][] = array(
+ 'id' => ($post['post_guid'] ? 'urn:uuid:'.$post['post_guid'] : $config['url'].'/'.$post['id']),
+ 'url' => $config['url'].'/'.$post['id'],
+ 'title' => '',
+ 'content_html' => $post['post_content'],
+ 'date_published' => gmdate('Y-m-d\TH:i:s\Z', $post['post_timestamp'])
+ );
+ }
+
+ if(file_put_contents($filename, json_encode($feed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))) {
+ return true;
+ } else return false;
+}
+
+function rebuild_atom_feed($posts=[]) {
+ global $config;
+
+ if (!file_exists(ROOT.DS.'feed')) {
+ mkdir(ROOT.DS.'feed', 0755);
+ }
+
+ $filename = ROOT.DS.'feed'.DS.'feed.xml';
+
+ $feed = '<?xml version="1.0" encoding="UTF-8" ?'.'>'.NL;
+ $feed .= '<feed xmlns="http://www.w3.org/2005/Atom">'.NL;
+ $feed .= '<author><name>'.$config['microblog_account'].'</name></author>'.NL;
+ $feed .= '<title>status updates by '.$config['microblog_account'].'</title>'.NL;
+ $feed .= '<id>'.$config['url'].'</id>'.NL;
+ $feed .= '<updated>'.gmdate('Y-m-d\TH:i:s\Z').'</updated>'.NL;
+
+ foreach($posts as $post) {
+
+ $published = gmdate('Y-m-d\TH:i:s\Z', $post['post_timestamp']);
+ $updated = ($post['post_edited'] > $post['post_timestamp']) ? gmdate('Y-m-d\TH:i:s\Z', $post['post_edited']) : $published;
+
+ $feed .= '<entry>'.NL;
+ $feed .= '<title type="text">'.date('Y-m-d H:i', $post['post_timestamp']).'</title>'.NL;
+ $feed .= '<link rel="alternate" type="text/html" href="'.$config['url'].'/'.$post['id'].'" />'.NL;
+ $feed .= '<id>'.($post['post_guid'] ? 'urn:uuid:'.$post['post_guid'] : $config['url'].'/'.$post['id']).'</id>'.NL;
+ $feed .= '<updated>'.$updated.'</updated>'.NL;
+ $feed .= '<published>'.$published.'</published>'.NL;
+ $feed .= '<content type="text">'.$post['post_content'].'</content>'.NL;
+ $feed .= '</entry>'.NL;
+ }
+
+ $feed .= '</feed>';
+
+ if(file_put_contents($filename, $feed)) {
+ return true;
+ } else return false;
+}
+
+function uuidv4($data = null) { // https://stackoverflow.com/a/15875555/3625228
+
+ $data = $data ?? openssl_random_pseudo_bytes(16);
+ assert(strlen($data) == 16);
+
+ $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0100
+ $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10
+
+ return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
+}
+
+function twitter_post_status($status='') {
+ global $config;
+ require_once(ROOT.DS.'lib'.DS.'twitter_api.php');
+
+ if(empty($status)) return array('errors' => 1);
+ if(empty($config['twitter']['oauth_access_token']) ||
+ empty($config['twitter']['oauth_access_token_secret']) ||
+ empty($config['twitter']['consumer_key']) ||
+ empty($config['twitter']['consumer_secret'])) return array('errors' => 2);
+
+ $url = 'https://api.twitter.com/1.1/statuses/update.json';
+ $postfields = array(
+ 'status' => $status,
+ 'trim_user' => 1
+ );
+
+ $twitter = new TwitterAPIExchange($config['twitter']);
+ return $twitter->buildOauth($url, 'POST')->setPostfields($postfields)->performRequest();
+}
diff --git a/lib/rsd.xml.php b/lib/rsd.xml.php
new file mode 100644
index 0000000..f0f9c12
--- /dev/null
+++ b/lib/rsd.xml.php
@@ -0,0 +1,15 @@
+<?php
+ require_once(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'config.php');
+
+ header('Content-Type: text/xml; charset=utf-8');
+?><rsd xmlns="http://archipelago.phrasewise.com/rsd" version="1.0">
+ <service>
+ <engineName>oelna/microblog</engineName>
+ <engineLink>https://github.com/oelna/microblog</engineLink>
+ <homePageLink><?= $config['url'] ?></homePageLink>
+ <apis>
+ <api name="Micro.blog" blogID="1" preferred="true" apiLink="<?= $config['url'] ?>/xmlrpc" />
+ <api name="MetaWeblog" blogID="1" preferred="false" apiLink="<?= $config['url'] ?>/xmlrpc" />
+ </apis>
+ </service>
+</rsd>
diff --git a/lib/twitter_api.php b/lib/twitter_api.php
new file mode 100644
index 0000000..d6e3a58
--- /dev/null
+++ b/lib/twitter_api.php
@@ -0,0 +1,410 @@
+<?php
+
+/**
+ * Twitter-API-PHP : Simple PHP wrapper for the v1.1 API
+ *
+ * PHP version 5.3.10
+ *
+ * @category Awesomeness
+ * @package Twitter-API-PHP
+ * @author James Mallison <me@j7mbo.co.uk>
+ * @license MIT License
+ * @version 1.0.4
+ * @link http://github.com/j7mbo/twitter-api-php
+ */
+class TwitterAPIExchange
+{
+ /**
+ * @var string
+ */
+ private $oauth_access_token;
+
+ /**
+ * @var string
+ */
+ private $oauth_access_token_secret;
+
+ /**
+ * @var string
+ */
+ private $consumer_key;
+
+ /**
+ * @var string
+ */
+ private $consumer_secret;
+
+ /**
+ * @var array
+ */
+ private $postfields;
+
+ /**
+ * @var string
+ */
+ private $getfield;
+
+ /**
+ * @var mixed
+ */
+ protected $oauth;
+
+ /**
+ * @var string
+ */
+ public $url;
+
+ /**
+ * @var string
+ */
+ public $requestMethod;
+
+ /**
+ * The HTTP status code from the previous request
+ *
+ * @var int
+ */
+ protected $httpStatusCode;
+
+ /**
+ * Create the API access object. Requires an array of settings::
+ * oauth access token, oauth access token secret, consumer key, consumer secret
+ * These are all available by creating your own application on dev.twitter.com
+ * Requires the cURL library
+ *
+ * @throws \RuntimeException When cURL isn't loaded
+ * @throws \InvalidArgumentException When incomplete settings parameters are provided
+ *
+ * @param array $settings
+ */
+ public function __construct(array $settings)
+ {
+ if (!function_exists('curl_init'))
+ {
+ throw new RuntimeException('TwitterAPIExchange requires cURL extension to be loaded, see: http://curl.haxx.se/docs/install.html');
+ }
+
+ if (!isset($settings['oauth_access_token'])
+ || !isset($settings['oauth_access_token_secret'])
+ || !isset($settings['consumer_key'])
+ || !isset($settings['consumer_secret']))
+ {
+ throw new InvalidArgumentException('Incomplete settings passed to TwitterAPIExchange');
+ }
+
+ $this->oauth_access_token = $settings['oauth_access_token'];
+ $this->oauth_access_token_secret = $settings['oauth_access_token_secret'];
+ $this->consumer_key = $settings['consumer_key'];
+ $this->consumer_secret = $settings['consumer_secret'];
+ }
+
+ /**
+ * Set postfields array, example: array('screen_name' => 'J7mbo')
+ *
+ * @param array $array Array of parameters to send to API
+ *
+ * @throws \Exception When you are trying to set both get and post fields
+ *
+ * @return TwitterAPIExchange Instance of self for method chaining
+ */
+ public function setPostfields(array $array)
+ {
+ if (!is_null($this->getGetfield()))
+ {
+ throw new Exception('You can only choose get OR post fields (post fields include put).');
+ }
+
+ if (isset($array['status']) && substr($array['status'], 0, 1) === '@')
+ {
+ $array['status'] = sprintf("\0%s", $array['status']);
+ }
+
+ foreach ($array as $key => &$value)
+ {
+ if (is_bool($value))
+ {
+ $value = ($value === true) ? 'true' : 'false';
+ }
+ }
+
+ $this->postfields = $array;
+
+ // rebuild oAuth
+ if (isset($this->oauth['oauth_signature']))
+ {
+ $this->buildOauth($this->url, $this->requestMethod);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set getfield string, example: '?screen_name=J7mbo'
+ *
+ * @param string $string Get key and value pairs as string
+ *
+ * @throws \Exception
+ *
+ * @return \TwitterAPIExchange Instance of self for method chaining
+ */
+ public function setGetfield($string)
+ {
+ if (!is_null($this->getPostfields()))
+ {
+ throw new Exception('You can only choose get OR post / post fields.');
+ }
+
+ $getfields = preg_replace('/^\?/', '', explode('&', $string));
+ $params = array();
+
+ foreach ($getfields as $field)
+ {
+ if ($field !== '')
+ {
+ list($key, $value) = explode('=', $field);
+ $params[$key] = $value;
+ }
+ }
+
+ $this->getfield = '?' . http_build_query($params, '', '&');
+
+ return $this;
+ }
+
+ /**
+ * Get getfield string (simple getter)
+ *
+ * @return string $this->getfields
+ */
+ public function getGetfield()
+ {
+ return $this->getfield;
+ }
+
+ /**
+ * Get postfields array (simple getter)
+ *
+ * @return array $this->postfields
+ */
+ public function getPostfields()
+ {
+ return $this->postfields;
+ }
+
+ /**
+ * Build the Oauth object using params set in construct and additionals
+ * passed to this method. For v1.1, see: https://dev.twitter.com/docs/api/1.1
+ *
+ * @param string $url The API url to use. Example: https://api.twitter.com/1.1/search/tweets.json
+ * @param string $requestMethod Either POST or GET
+ *
+ * @throws \Exception
+ *
+ * @return \TwitterAPIExchange Instance of self for method chaining
+ */
+ public function buildOauth($url, $requestMethod)
+ {
+ if (!in_array(strtolower($requestMethod), array('post', 'get', 'put', 'delete')))
+ {
+ throw new Exception('Request method must be either POST, GET or PUT or DELETE');
+ }
+
+ $consumer_key = $this->consumer_key;
+ $consumer_secret = $this->consumer_secret;
+ $oauth_access_token = $this->oauth_access_token;
+ $oauth_access_token_secret = $this->oauth_access_token_secret;
+
+ $oauth = array(
+ 'oauth_consumer_key' => $consumer_key,
+ 'oauth_nonce' => time(),
+ 'oauth_signature_method' => 'HMAC-SHA1',
+ 'oauth_token' => $oauth_access_token,
+ 'oauth_timestamp' => time(),
+ 'oauth_version' => '1.0'
+ );
+
+ $getfield = $this->getGetfield();
+
+ if (!is_null($getfield))
+ {
+ $getfields = str_replace('?', '', explode('&', $getfield));
+
+ foreach ($getfields as $g)
+ {
+ $split = explode('=', $g);
+
+ /** In case a null is passed through **/
+ if (isset($split[1]))
+ {
+ $oauth[$split[0]] = urldecode($split[1]);
+ }
+ }
+ }
+
+ $postfields = $this->getPostfields();
+
+ if (!is_null($postfields)) {
+ foreach ($postfields as $key => $value) {
+ $oauth[$key] = $value;
+ }
+ }
+
+ $base_info = $this->buildBaseString($url, $requestMethod, $oauth);
+ $composite_key = rawurlencode($consumer_secret) . '&' . rawurlencode($oauth_access_token_secret);
+ $oauth_signature = base64_encode(hash_hmac('sha1', $base_info, $composite_key, true));
+ $oauth['oauth_signature'] = $oauth_signature;
+
+ $this->url = $url;
+ $this->requestMethod = $requestMethod;
+ $this->oauth = $oauth;
+
+ return $this;
+ }
+
+ /**
+ * Perform the actual data retrieval from the API
+ *
+ * @param boolean $return If true, returns data. This is left in for backward compatibility reasons
+ * @param array $curlOptions Additional Curl options for this request
+ *
+ * @throws \Exception
+ *
+ * @return string json If $return param is true, returns json data.
+ */
+ public function performRequest($return = true, $curlOptions = array())
+ {
+ if (!is_bool($return))
+ {
+ throw new Exception('performRequest parameter must be true or false');
+ }
+
+ $header = array($this->buildAuthorizationHeader($this->oauth), 'Expect:');
+
+ $getfield = $this->getGetfield();
+ $postfields = $this->getPostfields();
+
+ if (in_array(strtolower($this->requestMethod), array('put', 'delete')))
+ {
+ $curlOptions[CURLOPT_CUSTOMREQUEST] = $this->requestMethod;
+ }
+
+ $options = $curlOptions + array(
+ CURLOPT_HTTPHEADER => $header,
+ CURLOPT_HEADER => false,
+ CURLOPT_URL => $this->url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ );
+
+ if (!is_null($postfields))
+ {
+ $options[CURLOPT_POSTFIELDS] = http_build_query($postfields, '', '&');
+ }
+ else
+ {
+ if ($getfield !== '')
+ {
+ $options[CURLOPT_URL] .= $getfield;
+ }
+ }
+
+ $feed = curl_init();
+ curl_setopt_array($feed, $options);
+ $json = curl_exec($feed);
+
+ $this->httpStatusCode = curl_getinfo($feed, CURLINFO_HTTP_CODE);
+
+ if (($error = curl_error($feed)) !== '')
+ {
+ curl_close($feed);
+
+ throw new \Exception($error);
+ }
+
+ curl_close($feed);
+
+ return $json;
+ }
+
+ /**
+ * Private method to generate the base string used by cURL
+ *
+ * @param string $baseURI
+ * @param string $method
+ * @param array $params
+ *
+ * @return string Built base string
+ */
+ private function buildBaseString($baseURI, $method, $params)
+ {
+ $return = array();
+ ksort($params);
+
+ foreach($params as $key => $value)
+ {
+ $return[] = rawurlencode($key) . '=' . rawurlencode($value);
+ }
+
+ return $method . "&" . rawurlencode($baseURI) . '&' . rawurlencode(implode('&', $return));
+ }
+
+ /**
+ * Private method to generate authorization header used by cURL
+ *
+ * @param array $oauth Array of oauth data generated by buildOauth()
+ *
+ * @return string $return Header used by cURL for request
+ */
+ private function buildAuthorizationHeader(array $oauth)
+ {
+ $return = 'Authorization: OAuth ';
+ $values = array();
+
+ foreach($oauth as $key => $value)
+ {
+ if (in_array($key, array('oauth_consumer_key', 'oauth_nonce', 'oauth_signature',
+ 'oauth_signature_method', 'oauth_timestamp', 'oauth_token', 'oauth_version'))) {
+ $values[] = "$key=\"" . rawurlencode($value) . "\"";
+ }
+ }
+
+ $return .= implode(', ', $values);
+ return $return;
+ }
+
+ /**
+ * Helper method to perform our request
+ *
+ * @param string $url
+ * @param string $method
+ * @param string $data
+ * @param array $curlOptions
+ *
+ * @throws \Exception
+ *
+ * @return string The json response from the server
+ */
+ public function request($url, $method = 'get', $data = null, $curlOptions = array())
+ {
+ if (strtolower($method) === 'get')
+ {
+ $this->setGetfield($data);
+ }
+ else
+ {
+ $this->setPostfields($data);
+ }
+
+ return $this->buildOauth($url, $method)->performRequest(true, $curlOptions);
+ }
+
+ /**
+ * Get the HTTP status code for the previous request
+ *
+ * @return integer
+ */
+ public function getHttpStatusCode()
+ {
+ return $this->httpStatusCode;
+ }
+}
diff --git a/lib/xmlrpc.php b/lib/xmlrpc.php
new file mode 100644
index 0000000..47948eb
--- /dev/null
+++ b/lib/xmlrpc.php
@@ -0,0 +1,428 @@
+<?php
+
+$request_xml = file_get_contents("php://input");
+
+// check prerequisites
+if(!function_exists('xmlrpc_server_create')) { exit('No XML-RPC support detected!'); }
+if(empty($request_xml)) { exit('XML-RPC server accepts POST requests only.'); }
+
+// load config
+require_once(__DIR__.DIRECTORY_SEPARATOR.'config.php');
+$logfile = __DIR__.DS.'log.txt';
+
+if(!function_exists('str_starts_with')) {
+ function str_starts_with($haystack, $needle) {
+ if(empty($needle)) return true;
+ return mb_substr($haystack, 0, mb_strlen($needle)) === $needle;
+ }
+}
+
+function check_credentials($username, $password) {
+ global $config;
+
+ $xmlrpc_auth = $config['admin_pass'];
+ if(!empty($config['app_token'])) {
+ $xmlrpc_auth = $config['app_token'];
+ }
+
+ if($username == $config['admin_user'] && $password == $xmlrpc_auth) {
+ return true;
+ } else {
+ return false;
+ }
+}
+
+function say_hello($method_name, $args) {
+ return 'Hello';
+}
+
+function make_post($post, $method='metaWeblog') {
+ global $config;
+
+ $date_created = date('Y-m-d\TH:i:s', $post['post_timestamp']).$config['local_time_offset'];
+ $date_created_gmt = gmdate('Y-m-d\TH:i:s', $post['post_timestamp']).'Z';
+ if(!empty($post['post_edited']) && !is_null($post['post_edited'])) {
+ $date_modified = date('Y-m-d\TH:i:s', $post['post_edited']).$config['local_time_offset'];
+ $date_modified_gmt = gmdate('Y-m-d\TH:i:s', $post['post_edited']).'Z';
+ } else {
+ $date_modified = date('Y-m-d\TH:i:s', 0).$config['local_time_offset'];
+ $date_modified = null;
+ $date_modified_gmt = gmdate('Y-m-d\TH:i:s', 0).'Z';
+ }
+
+ @xmlrpc_set_type($date_created, 'datetime');
+ @xmlrpc_set_type($date_created_gmt, 'datetime');
+ @xmlrpc_set_type($date_modified, 'datetime');
+ @xmlrpc_set_type($date_modified_gmt, 'datetime');
+
+ if(str_starts_with($method, 'microblog')) {
+ // convert the post format to a microblog post
+ // similar to metaWeblog.recentPosts but with offset parameter for paging,
+ // consistent field names
+ $mb_post = [
+ 'id' => (int) $post['id'],
+ 'date_created' => $date_created,
+ 'date_modified' => $date_modified,
+ 'permalink' => $config['url'].'/'.$post['id'],
+ 'title' => '',
+ 'description' => ($post['post_content']),
+ 'categories' => [],
+ 'post_status' => 'published',
+ 'author' => [
+ 'name' => $config['microblog_account'],
+ 'username' => $config['admin_user']
+ ]
+ ];
+
+ return $mb_post;
+ } else {
+ // convert the post format to a standard metaWeblog post
+ $mw_post = [
+ 'postid' => (int) $post['id'],
+ 'title' => '',
+ 'description' => ($post['post_content']), // Post content
+ 'link' => $config['url'].'/'.$post['id'], // Post URL
+ // string userid†: ID of post author.
+ 'dateCreated' => $date_created,
+ 'date_created_gmt' => $date_created_gmt,
+ 'date_modified' => $date_modified,
+ 'date_modified_gmt' => $date_modified_gmt,
+ // string wp_post_thumbnail†
+ 'permalink' => $config['url'].'/'.$post['id'], // Post URL, equivalent to link.
+ 'categories' => [], // Names of categories assigned to the post.
+ 'mt_keywords' => '', // Names of tags assigned to the post.
+ 'mt_excerpt' => '',
+ 'mt_text_more' => '', // Post "Read more" text.
+ ];
+
+ return $mw_post;
+ }
+}
+
+function mw_get_users_blogs($method_name, $args) {
+ global $config;
+
+ list($_, $username, $password) = $args;
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ $bloginfo = [
+ 'blogid' => '1',
+ 'url' => $config['url'],
+ 'blogName' => (empty($config['microblog_account']) ? "" : $config['microblog_account'] . "'s ").' microblog',
+ ];
+
+ return $bloginfo;
+}
+
+function mw_get_categories($method_name, $args) {
+
+ list($_, $username, $password) = $args;
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ // we don't support categories, so only return a fake one
+ if($method_name == 'microblog.getCategories') {
+ $categories = [
+ /*
+ [
+ 'id' => '1',
+ 'name' => 'default',
+ ]
+ */
+ ];
+ } else {
+ $categories = [
+ /*
+ [
+ 'description' => 'Default',
+ 'htmlUrl' => '',
+ 'rssUrl' => '',
+ 'title' => 'default',
+ 'categoryid' => '1',
+ ]
+ */
+ ];
+ }
+
+ return $categories;
+}
+
+function mw_get_user_info($method_name, $args) {
+ global $config;
+
+ list($_, $username, $password) = $args;
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ $userinfo = [
+ 'userid' => '1',
+ 'firstname' => '',
+ 'lastname' => '',
+ 'nickname' => $config['microblog_account'],
+ 'email' => '',
+ 'url' => $config['url'],
+ ];
+
+ return $userinfo;
+}
+
+function mw_get_recent_posts($method_name, $args) {
+
+ list($_, $username, $password, $amount) = $args;
+ $offset = 0;
+ if($method_name == 'microblog.getPosts' && !empty($args[4])) {
+ $offset = $args[4];
+ }
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ if(!$amount) $amount = 25;
+ $amount = min($amount, 200); // cap the max available posts at 200 (?)
+
+ $posts = db_select_posts(null, $amount, 'asc', $offset);
+ if(empty($posts)) return [];
+
+ // call make_post() on all items
+ $mw_posts = array_map('make_post', $posts, array_fill(0, count($posts), $method_name));
+
+ return $mw_posts;
+}
+
+function mw_get_post($method_name, $args) {
+
+ list($post_id, $username, $password) = $args;
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ $post = db_select_post($post_id);
+ if($post) {
+ if($method_name == 'microblog.getPost') {
+ $mw_post = make_post($post, $method_name);
+ } else {
+ $mw_post = make_post($post);
+ }
+
+ return $mw_post;
+ } else {
+ return [
+ 'faultCode' => 400,
+ 'faultString' => 'Could not fetch post.'
+ ];
+ }
+}
+
+function mw_new_post($method_name, $args) {
+
+ // blog_id, unknown, unknown, array of post content, unknown
+ list($blog_id, $username, $password, $content, $_) = $args;
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ if($method_name == 'microblog.newPost') {
+ $post = [
+ // 'post_title' => $content['title'],
+ 'post_content' => $content['description'],
+ 'post_timestamp' => time(),
+ // 'post_categories' => $content['categories'],
+ // 'post_status' => $content['post_status'],
+ ];
+
+ // use a specific timestamp, if provided
+ if(isset($content['date_created'])) {
+ $post['post_timestamp'] = $content['date_created']->timestamp;
+ }
+ } else {
+ $post = [
+ // 'post_hp' => $content['flNotOnHomePage'],
+ 'post_timestamp' => time(),
+ // 'post_title' => $content['title'],
+ 'post_content' => $content['description'],
+ // 'post_url' => $content['link'],
+ ];
+
+ // use a specific timestamp, if provided
+ if(isset($content['dateCreated'])) {
+ $post['post_timestamp'] = $content['dateCreated']->timestamp;
+ }
+ }
+
+ $insert_id = db_insert($post['post_content'], $post['post_timestamp']);
+ if($insert_id && $insert_id > 0) {
+ // success
+ rebuild_feeds();
+
+ return (int) $insert_id;
+ } else {
+ // insert failed
+ // error codes: https://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php
+ // more error codes? https://github.com/zendframework/zend-xmlrpc/blob/master/src/Fault.php
+ return [
+ 'faultCode' => 400,
+ 'faultString' => 'Could not create post.'
+ ];
+ }
+}
+
+function mw_edit_post($method_name, $args) {
+
+ // post_id, unknown, unknown, array of post content
+ list($post_id, $username, $password, $content, $_) = $args;
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ if($method_name == 'microblog.editPost') {
+ $post = [
+ // 'post_title' => $content['title'],
+ 'post_content' => $content['description'],
+ 'post_timestamp' => null,
+ // 'post_categories' => $content['categories'],
+ // 'post_status' => $content['post_status'],
+ ];
+
+ if(!empty($content['date_created'])) {
+ $post['post_timestamp'] = $content['date_created']->timestamp;
+ }
+ } else {
+ $post = [
+ // 'post_hp' => $content['flNotOnHomePage'],
+ // 'post_title' => $content['title'],
+ 'post_timestamp' => null,
+ 'post_content' => $content['description'],
+ // 'post_url' => $content['link'],
+ ];
+
+ if(!empty($content['dateCreated'])) {
+ $post['post_timestamp'] = $content['dateCreated']->timestamp;
+ }
+ }
+
+ $update = db_update($post_id, $post['post_content'], $post['post_timestamp']);
+ if($update && $update > 0) {
+ // success
+ rebuild_feeds();
+
+ return true;
+ } else {
+ return [
+ 'faultCode' => 400,
+ 'faultString' => 'Could not write post update.'
+ ];
+ }
+}
+
+function mw_delete_post($method_name, $args) {
+
+ if($method_name == 'microblog.deletePost') {
+ list($post_id, $username, $password) = $args;
+ } else {
+ // blogger.deletePost
+ list($_, $post_id, $username, $password, $_) = $args;
+ }
+
+ if(!check_credentials($username, $password)) {
+ return [
+ 'faultCode' => 403,
+ 'faultString' => 'Incorrect username or password.'
+ ];
+ }
+
+ $success = db_delete($post_id);
+ if($success > 0) {
+ rebuild_feeds();
+
+ return true;
+ } else {
+ return [
+ 'faultCode' => 400,
+ 'faultString' => 'Could not delete post.'
+ ];
+ }
+}
+
+// https://codex.wordpress.org/XML-RPC_MetaWeblog_API
+// https://community.devexpress.com/blogs/theprogressbar/metablog.ashx
+// idea: http://www.hixie.ch/specs/pingback/pingback#TOC3
+$server = xmlrpc_server_create();
+xmlrpc_server_register_method($server, 'demo.sayHello', 'say_hello');
+
+xmlrpc_server_register_method($server, 'blogger.getUsersBlogs', 'mw_get_users_blogs');
+xmlrpc_server_register_method($server, 'blogger.getUserInfo', 'mw_get_user_info');
+xmlrpc_server_register_method($server, 'blogger.deletePost', 'mw_delete_post');
+
+xmlrpc_server_register_method($server, 'metaWeblog.getCategories', 'mw_get_categories');
+xmlrpc_server_register_method($server, 'metaWeblog.getRecentPosts', 'mw_get_recent_posts');
+xmlrpc_server_register_method($server, 'metaWeblog.newPost', 'mw_new_post');
+xmlrpc_server_register_method($server, 'metaWeblog.editPost', 'mw_edit_post');
+xmlrpc_server_register_method($server, 'metaWeblog.getPost', 'mw_get_post');
+// xmlrpc_server_register_method($server, 'metaWeblog.newMediaObject', 'mw_new_mediaobject');
+
+// non-standard convenience?
+xmlrpc_server_register_method($server, 'metaWeblog.getPosts', 'mw_get_recent_posts');
+xmlrpc_server_register_method($server, 'metaWeblog.deletePost', 'mw_delete_post');
+
+// micro.blog API methods (currently just using the metaWeblog functions)
+// https://help.micro.blog/t/micro-blog-xml-rpc-api/108
+xmlrpc_server_register_method($server, 'microblog.getCategories', 'mw_get_categories');
+xmlrpc_server_register_method($server, 'microblog.getPosts', 'mw_get_recent_posts');
+xmlrpc_server_register_method($server, 'microblog.getPost', 'mw_get_post');
+xmlrpc_server_register_method($server, 'microblog.newPost', 'mw_new_post');
+xmlrpc_server_register_method($server, 'microblog.editPost', 'mw_edit_post');
+xmlrpc_server_register_method($server, 'microblog.deletePost', 'mw_delete_post');
+// xmlrpc_server_register_method($server, 'microblog.newMediaObject', 'mw_new_mediaobject');
+
+// micro.blog pages are not supported
+/*
+xmlrpc_server_register_method($server, 'microblog.getPages', 'say_hello');
+xmlrpc_server_register_method($server, 'microblog.getPage', 'say_hello');
+xmlrpc_server_register_method($server, 'microblog.newPage', 'say_hello');
+xmlrpc_server_register_method($server, 'microblog.editPage', 'say_hello');
+xmlrpc_server_register_method($server, 'microblog.deletePage', 'say_hello');
+*/
+
+// https://docstore.mik.ua/orelly/webprog/pcook/ch12_08.htm
+$response = xmlrpc_server_call_method($server, $request_xml, null, [
+ 'escaping' => 'markup',
+ 'encoding' => 'UTF-8'
+]);
+
+if($response) {
+ header('Content-Type: text/xml; charset=utf-8');
+ // error_log($request_xml."\n\n".$response."\n\n", 3, $logfile);
+ echo($response);
+}