WIP
authorChristian Weiske <cweiske@cweiske.de>
Mon, 5 Sep 2016 22:53:33 +0000 (00:53 +0200)
committerChristian Weiske <cweiske@cweiske.de>
Mon, 5 Sep 2016 22:53:33 +0000 (00:53 +0200)
README.rst [new file with mode: 0644]
bin/shpub.php [new file with mode: 0644]
src/shpub/Autoloader.php [new file with mode: 0644]
src/shpub/Cli.php [new file with mode: 0644]
src/shpub/Command/Connect.php [new file with mode: 0644]
src/shpub/Config.php [new file with mode: 0644]
src/shpub/Config/Host.php [new file with mode: 0644]
src/shpub/Config/HostCache.php [new file with mode: 0644]
src/shpub/Log.php [new file with mode: 0644]

diff --git a/README.rst b/README.rst
new file mode 100644 (file)
index 0000000..5ba8153
--- /dev/null
@@ -0,0 +1,4 @@
+**************************************
+shpub - micropub client for your shell
+**************************************
+Command line micropub client written in PHP.
diff --git a/bin/shpub.php b/bin/shpub.php
new file mode 100644 (file)
index 0000000..7e9729b
--- /dev/null
@@ -0,0 +1,230 @@
+<?php
+namespace shpub;
+
+if (file_exists(__DIR__ . '/../src/shpub/Autoloader.php')) {
+    include_once __DIR__ . '/../src/shpub/Autoloader.php';
+    Autoloader::register();
+}
+$cli = new Cli();
+$cli->run();
+exit();
+/**
+ * @link http://micropub.net/draft/
+ * @link http://indieweb.org/authorization-endpoint
+ */
+$server = 'http://anoweco.bogo/';
+$user   = 'http://anoweco.bogo/user/3.htm';
+
+require_once 'HTTP/Request2.php';
+
+$endpoints = discoverEndpoints($server);
+list($accessToken, $userUrl) = getAuthCode($user, $endpoints);
+var_dump($endpoints, $accessToken, $userUrl);
+
+
+function getAuthCode($user, $endpoints)
+{
+    //fetch temporary authorization token
+    $redirect_uri = 'http://127.0.0.1:12345/callback';
+    $state        = time();
+    $client_id    = 'http://cweiske.de/shpub.htm';
+
+    $browserUrl = $endpoints->authorization
+        . '?me=' . urlencode($user)
+        . '&client_id=' . urlencode($client_id)
+        . '&redirect_uri=' . urlencode($redirect_uri)
+        . '&state=' . $state
+        . '&scope=post'
+        . '&response_type=code';
+    echo "To authenticate, open the following URL:\n"
+        . $browserUrl . "\n";
+
+    $authParams = startHttpServer();
+
+    if ($authParams['state'] != $state) {
+        logError('Wrong "state" parameter value');
+        exit(2);
+    }
+
+    //verify indieauth params
+    $req = new HTTP_Request2($endpoints->authorization, 'POST');
+    $req->setHeader('Content-Type: application/x-www-form-urlencoded');
+    $req->setBody(
+        http_build_query(
+            [
+                'code'         => $authParams['code'],
+                'state'        => $state,
+                'client_id'    => $client_id,
+                'redirect_uri' => $redirect_uri,
+            ]
+        )
+    );
+    $res = $req->send();
+    if ($res->getHeader('content-type') != 'application/x-www-form-urlencoded') {
+        logError('Wrong content type in auth verification response');
+        exit(2);
+    }
+    parse_str($res->getBody(), $verifiedParams);
+    if (!isset($verifiedParams['me'])
+        || $verifiedParams['me'] !== $authParams['me']
+    ) {
+        logError('Non-matching "me" values');
+        exit(2);
+    }
+
+    $userUrl = $verifiedParams['me'];
+
+
+    //fetch permanent access token
+    $req = new HTTP_Request2($endpoints->token, 'POST');
+    $req->setHeader('Content-Type: application/x-www-form-urlencoded');
+    $req->setBody(
+        http_build_query(
+            [
+                'me'           => $userUrl,
+                'code'         => $authParams['code'],
+                'redirect_uri' => $redirect_uri,
+                'client_id'    => $client_id,
+                'state'        => $state,
+            ]
+        )
+    );
+    $res = $req->send();
+    if ($res->getHeader('content-type') != 'application/x-www-form-urlencoded') {
+        logError('Wrong content type in auth verification response');
+        exit(2);
+    }
+    parse_str($res->getBody(), $tokenParams);
+    if (!isset($tokenParams['access_token'])) {
+        logError('"access_token" missing');
+        exit(2);
+    }
+
+    $accessToken = $tokenParams['access_token'];
+
+    return [$accessToken, $userUrl];
+}
+
+function startHttpServer()
+{
+    $responseOk = "HTTP/1.0 200 OK\r\n"
+        . "Content-Type: text/plain\r\n"
+        . "\r\n"
+        . "Ok. You may close this tab and return to the shell.\r\n";
+    $responseErr = "HTTP/1.0 400 Bad Request\r\n"
+        . "Content-Type: text/plain\r\n"
+        . "\r\n"
+        . "Bad Request\r\n";
+
+    //5 minutes should be enough for the user to confirm
+    ini_set('default_socket_timeout', 60 * 5);
+    $server = stream_socket_server(
+        'tcp://127.0.0.1:12345', $errno, $errstr
+    );
+    if (!$server) {
+        //TODO: log
+        return false;
+    }
+
+    do {
+        $sock = stream_socket_accept($server);
+        if (!$sock) {
+            //TODO: log
+            exit(1);
+        }
+
+        $headers = [];
+        $body    = null;
+        $content_length = 0;
+        //read request headers
+        while (false !== ($line = trim(fgets($sock)))) {
+            if ('' === $line) {
+                break;
+            }
+            if (preg_match('#^Content-Length:\s*([[:digit:]]+)\s*$#i', $line, $matches)) {
+                $content_length = (int) $matches[1];
+            }
+            $headers[] = $line;
+        }
+
+        // read content/body
+        if ($content_length > 0) {
+            $body = fread($sock, $content_length);
+        }
+
+        // send response
+        list($method, $url, $httpver) = explode(' ', $headers[0]);
+        if ($method == 'GET') {
+            $parts = parse_url($url);
+            if (isset($parts['path']) && $parts['path'] == '/callback'
+                && isset($parts['query'])
+            ) {
+                parse_str($parts['query'], $query);
+                if (isset($query['code'])
+                    && isset($query['state'])
+                    && isset($query['me'])
+                ) {
+                    fwrite($sock, $responseOk);
+                    fclose($sock);
+                    return $query;
+                }
+            }
+        }
+
+        fwrite($sock, $responseErr);
+        fclose($sock);
+    } while(true);
+}
+
+class Config_Endpoints
+{
+    public $micropub;
+    public $media;
+    public $token;
+    public $authorization;
+}
+
+function discoverEndpoints($url)
+{
+    $cfg = new Config_Endpoints();
+
+    //TODO: discovery via link headers
+
+    $sx = simplexml_load_file($url);
+    $sx->registerXPathNamespace('h', 'http://www.w3.org/1999/xhtml');
+
+    $auths = $sx->xpath(
+        '/h:html/h:head/h:link[@rel="authorization_endpoint" and @href]'
+    );
+    if (!count($auths)) {
+        logError('No authorization endpoint found');
+        exit(1);
+    }
+    $cfg->authorization = (string) $auths[0]['href'];
+
+    $tokens = $sx->xpath(
+        '/h:html/h:head/h:link[@rel="token_endpoint" and @href]'
+    );
+    if (!count($tokens)) {
+        logError('No token endpoint found');
+        exit(1);
+    }
+    $cfg->token = (string) $tokens[0]['href'];
+
+    $mps = $sx->xpath(
+        '/h:html/h:head/h:link[@rel="micropub" and @href]'
+    );
+    if (!count($mps)) {
+        logError('No micropub endpoint found');
+        exit(1);
+    }
+    $cfg->micropub = (string) $mps[0]['href'];
+
+    return $cfg;
+}
+
+function logError($msg)
+{
+    file_put_contents('php://stderr', $msg . "\n", FILE_APPEND);
+}
+?>
diff --git a/src/shpub/Autoloader.php b/src/shpub/Autoloader.php
new file mode 100644 (file)
index 0000000..5c0781b
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Part of shpub
+ *
+ * PHP version 5
+ *
+ * @category  Tools
+ * @package   shpub
+ * @author    Christian Weiske <cweiske@cweiske.de>
+ * @copyright 2014 Christian Weiske
+ * @license   http://www.gnu.org/licenses/agpl.html GNU AGPL v3
+ * @link      http://cweiske.de/shpub.htm
+ */
+namespace shpub;
+
+/**
+ * Class autoloader, PSR-0 compliant.
+ *
+ * @category  Tools
+ * @package   shpub
+ * @author    Christian Weiske <cweiske@cweiske.de>
+ * @copyright 2014 Christian Weiske
+ * @license   http://www.gnu.org/licenses/agpl.html GNU AGPL v3
+ * @version   Release: @package_version@
+ * @link      http://cweiske.de/shpub.htm
+ */
+class Autoloader
+{
+    /**
+     * Load the given class
+     *
+     * @param string $class Class name
+     *
+     * @return void
+     */
+    public function load($class)
+    {
+        $file = strtr($class, '_\\', '//') . '.php';
+        if (stream_resolve_include_path($file)) {
+            include $file;
+        }
+    }
+
+    /**
+     * Register this autoloader
+     *
+     * @return void
+     */
+    public static function register()
+    {
+        set_include_path(
+            get_include_path() . PATH_SEPARATOR . __DIR__ . '/../'
+        );
+        spl_autoload_register(array(new self(), 'load'));
+    }
+}
+?>
\ No newline at end of file
diff --git a/src/shpub/Cli.php b/src/shpub/Cli.php
new file mode 100644 (file)
index 0000000..823e297
--- /dev/null
@@ -0,0 +1,175 @@
+<?php
+namespace shpub;
+
+class Cli
+{
+    /**
+     * @var Config
+     */
+    protected $cfg;
+
+    public function run()
+    {
+        $this->cfg = new Config();
+        $this->cfg->load();
+
+        try {
+            $optParser = $this->loadOptParser();
+            $res = $this->parseParameters($optParser);
+
+            switch ($res->command_name) {
+            case 'connect':
+                $cmd = new Command_Connect($this->cfg);
+                $cmd->run(
+                    $res->command->args['server'],
+                    $res->command->args['user'],
+                    $res->command->args['key']
+                );
+                break;
+            case 'like':
+                $this->requireValidHost();
+                $cmd = new Command_Like($this->cfg->host);
+                $cmd->run($res->command->args['url']);
+                break;
+            default:
+                var_dump($this->cfg->host, $res);
+                Log::err('FIXME');
+            }
+        } catch (\Exception $e) {
+            echo 'Error: ' . $e->getMessage() . "\n";
+            exit(1);
+        }
+    }
+
+    /**
+     * Let the CLI option parser parse the options.
+     *
+     * @param object $parser Option parser
+     *
+     * @return object Parsed command line parameters
+     */
+    protected function parseParameters(\Console_CommandLine $optParser)
+    {
+        try {
+            $res  = $optParser->parse();
+            $opts = $res->options;
+
+            $this->cfg->host = new Config_Host();
+            if ($opts['server'] !== null) {
+                $key = $this->cfg->getHostByName($opts['server']);
+                if ($key === null) {
+                    $this->cfg->host->server = $opts['server'];
+                } else {
+                    $this->cfg->host = $this->cfg->hosts[$key];
+                }
+            }
+            if ($opts['user'] !== null) {
+                $this->cfg->host->user = $opts['user'];
+            }
+
+            return $res;
+        } catch (\Exception $exc) {
+            $optParser->displayError($exc->getMessage());
+        }
+    }
+
+    /**
+     * Load parameters for the CLI option parser.
+     *
+     * @return \Console_CommandLine CLI option parser
+     */
+    protected function loadOptParser()
+    {
+        $optParser = new \Console_CommandLine();
+        $optParser->description = 'shpub';
+        $optParser->version = '0.0.0';
+        $optParser->subcommand_required = true;
+
+        $optParser->addOption(
+            'server',
+            array(
+                'short_name'  => '-s',
+                'long_name'   => '--server',
+                'description' => 'Server URL',
+                'help_name'   => 'URL',
+                'action'      => 'StoreString',
+                'default'     => null,
+            )
+        );
+        $optParser->addOption(
+            'user',
+            array(
+                'short_name'  => '-u',
+                'long_name'   => '--user',
+                'description' => 'User URL',
+                'help_name'   => 'URL',
+                'action'      => 'StoreString',
+                'default'     => null,
+            )
+        );
+
+        $cmd = $optParser->addCommand('connect');
+        $cmd->addArgument(
+            'server',
+            [
+                'optional'    => false,
+                'description' => 'Server URL',
+            ]
+        );
+        $cmd->addArgument(
+            'user',
+            [
+                'optional'    => false,
+                'description' => 'User URL',
+            ]
+        );
+        $cmd->addArgument(
+            'key',
+            [
+                'optional'    => true,
+                'description' => 'Short name (key)',
+            ]
+        );
+
+        //$cmd = $optParser->addCommand('post');
+        $cmd = $optParser->addCommand('reply');
+        $cmd->addArgument(
+            'url',
+            [
+                'optional'    => false,
+                'description' => 'URL that is replied to',
+            ]
+        );
+        $cmd->addArgument(
+            'text',
+            [
+                'optional'    => false,
+                'description' => 'Reply text',
+            ]
+        );
+
+        $cmd = $optParser->addCommand('like');
+        $cmd->addArgument(
+            'url',
+            [
+                'optional'    => false,
+                'description' => 'URL that is liked',
+            ]
+        );
+
+        return $optParser;
+    }
+
+    protected function requireValidHost()
+    {
+        if ($this->cfg->host->server === null
+            || $this->cfg->host->user === null
+            || $this->cfg->host->token === null
+        ) {
+            throw new \Exception(
+                'Server data incomplete. "shpub connect" first.'
+            );
+        }
+    }
+}
+?>
diff --git a/src/shpub/Command/Connect.php b/src/shpub/Command/Connect.php
new file mode 100644 (file)
index 0000000..867bbe3
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+namespace shpub;
+
+class Command_Connect
+{
+    public function __construct(Config $cfg)
+    {
+        $this->cfg = $cfg;
+    }
+
+    public function run($server, $user, $key)
+    {
+    }
+}
+?>
diff --git a/src/shpub/Config.php b/src/shpub/Config.php
new file mode 100644 (file)
index 0000000..5da9c4a
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+namespace shpub;
+
+class Config
+{
+    public $hosts = [];
+
+    /**
+     * Currently selected host.
+     *
+     * @var Host
+     */
+    public $host;
+
+    protected function getConfigFilePath()
+    {
+        if (!isset($_SERVER['HOME'])) {
+            return false;
+        }
+
+        return $_SERVER['HOME'] . '/.config/shpub.ini';
+    }
+
+    public function load()
+    {
+        $cfgFile = $this->getConfigFilePath();
+        if ($cfgFile == false) {
+            return false;
+        }
+
+        if (!file_exists($cfgFile) || !is_readable($cfgFile)) {
+            return false;
+        }
+
+        $data = parse_ini_file($cfgFile, true);
+        foreach ($data as $key => $val) {
+            if (!is_array($val)) {
+                continue;
+            }
+            $host = new Config_Host();
+            foreach ($val as $hostProp => $hostVal) {
+                if (!property_exists($host, $hostProp)) {
+                    Log::err('Invalid config key "' . $hostProp . '"');
+                    exit(1);
+                }
+                $host->$hostProp = $hostVal;
+            }
+            $this->hosts[$key] = $host;
+        }
+    }
+
+    public function save()
+    {
+        $str = '';
+        foreach ($this->hosts as $hostName => $host) {
+            if ($str != '') {
+                $str .= "\n";
+            }
+            $str .= '[' . $hostName . "]\n";
+            foreach ($host as $hostProp => $hostVal) {
+                if ($hostProp == 'cache') {
+                    continue;
+                }
+                if ($hostVal == '') {
+                    continue;
+                }
+                $str .= $hostProp . '=' . $hostVal . "\n";
+            }
+        }
+        file_put_contents($this->getConfigFilePath(), $str);
+    }
+
+    public function getDefaultHost()
+    {
+        if (!count($this->hosts)) {
+            return null;
+        }
+        foreach ($this->hosts as $key => $host) {
+            if ($host->default) {
+                return $host;
+            }
+        }
+        
+        reset($this->hosts);
+        return key($this->hosts);
+    }
+
+    public function getHostByName($keyOrServer)
+    {
+        if (!count($this->hosts)) {
+            return null;
+        }
+        foreach ($this->hosts as $key => $host) {
+            if ($key == $keyOrServer || $host->server == $keyOrServer) {
+                return $key;
+            }
+        }
+        return null;
+    }
+}
+?>
diff --git a/src/shpub/Config/Host.php b/src/shpub/Config/Host.php
new file mode 100644 (file)
index 0000000..6ef05d9
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+namespace shpub;
+
+class Config_Host
+{
+    /**
+     * (Base) server URL
+     *
+     * @var string
+     */
+    public $server;
+
+    /**
+     * User URL
+     *
+     * @var string
+     */
+    public $user;
+
+    /**
+     * Micropub access token
+     *
+     * @var string
+     */
+    public $token;
+
+    /**
+     * Host information cache
+     *
+     * @var Config_HostCache
+     */
+    public $cache;
+
+    /**
+     * If this host is the default one
+     *
+     * @var boolean
+     */
+    public $default;
+}
+?>
diff --git a/src/shpub/Config/HostCache.php b/src/shpub/Config/HostCache.php
new file mode 100644 (file)
index 0000000..03311c3
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+namespace shpub;
+
+class Config_HostCache
+{
+    /**
+     * Micropub endpoint URL
+     *
+     * @var string
+     */
+    public $micropub;
+
+    /**
+     * Micropub media endpoint URL
+     *
+     * @var string
+     */
+    public $media;
+
+    /**
+     * Access token endpoint URL
+     *
+     * @var string
+     */
+    public $token;
+
+    /**
+     * Authorization endpoint URL
+     *
+     * @var string
+     */
+    public $auth;
+}
+?>
diff --git a/src/shpub/Log.php b/src/shpub/Log.php
new file mode 100644 (file)
index 0000000..99d9694
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+namespace shpub;
+
+class Log
+{
+    public static function err($msg)
+    {
+        file_put_contents('php://stderr', $msg . "\n", FILE_APPEND);
+    }
+}
+?>