diff options
author | Arno Richter <oelna@oelna.de> | 2022-12-13 22:27:21 +0100 |
---|---|---|
committer | Arno Richter <oelna@oelna.de> | 2022-12-13 22:27:44 +0100 |
commit | 0b075f3ea2616cfde4d976199a69ba631174a336 (patch) | |
tree | bb1feef58d9c714f6c3fb0b75f1e52b0181cf2af /lib | |
parent | f0e3ff408db8ee40611f75cdf96892f90034bd60 (diff) | |
download | microblog-0b075f3ea2616cfde4d976199a69ba631174a336.tar.gz microblog-0b075f3ea2616cfde4d976199a69ba631174a336.tar.bz2 microblog-0b075f3ea2616cfde4d976199a69ba631174a336.zip |
gave up and sorted files into a directory structure. made snippets for header, nav and footer. made it easier to add additional css files as themes. prepare a little for addition of a real template engine. added a css reset file to share between themes, if warranted.
Diffstat (limited to 'lib')
-rw-r--r-- | lib/autolink.php | 335 | ||||
-rw-r--r-- | lib/database.php | 56 | ||||
-rw-r--r-- | lib/functions.php | 258 | ||||
-rw-r--r-- | lib/rsd.xml.php | 15 | ||||
-rw-r--r-- | lib/twitter_api.php | 410 | ||||
-rw-r--r-- | lib/xmlrpc.php | 428 |
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); +} |