从 Pointless 迁移到 Spress

由于 Pointless 「年久失修」,我将博客迁移到了 Spress。不过就目前版本而言,Spress 对多语言支持有些问题。如果你想试用一下 Spress,需要注意以下几点。

Spress 不支持中文标题,它会将标题中的非字母和数字的字符都替换为连字符「-」:

// src/Core/Utils.php
class Utils {
    // ...
    public static function slugify($text)
    {
        // replace non letter or digits by -
        $result = preg_replace('/[^\\pL\d]+/u', '-', $text);
        // ...
    }
}

Utils 类就一个 slugify 方法,Spress 的标题会被它过滤,它的第一行代码就将所有非数字和 Unicode letter 的字符替换为连字符。

// src/Core/ContentManager/PostItem.php
class PostItem extends ContentItem 
{
    // ...
    public function getTitle()
    {
        $title = $this->frontmatter->getFrontmatter()->get('title');
        return $title ?: $this->title;
    }
    // ...
    private function getTitleSlugified()
    {
        return Utils::slugify($this->getTitle());
    }
}

然后是 PostItem 类,生成文章链接时需要调用 getTitleSlugified 方法。如果我们在 Front-matter 里自定义了中文标题,自定义标题会覆盖 markdown 文件名中的标题,最终导致自定义标题中的所有中文字符都会被替换掉。解决这个问题很简单,修改一下 getTitleSlugified 方法即可:

// src/Core/ContentManager/PostItem.php
class PostItem extends ContentItem 
{
    // ...
    public function getOriginalTitle()
    {
        return $this->title;
    }
    // ...
    private function getTitleSlugified()
    {
        return Utils::slugify($this->getOriginalTitle());
    }
}

我添加了一个 getOriginalTitle 方法,它直接返回原标题。然后将 getTitleSlugified 方法中的 getTitle 替换为 getOriginalTitle 即可。当然你也可以简单粗暴的在 getTitleSlugified 里直接使用 $this->title

Spress 不支持「page/:num」分页格式。Spress 默认分页为 page:num 格式,文章数量比较多的时候就会在根目录生成一堆 page2,page3 之类的文件夹,完全不能忍。Spress 贴心的提供了 paginate_path 参数来自定义分页路径,但是如果把参数修改为 page/:num,你就会发现首页空了。这是因为 Spress 把新生成的首页放到了 page/ 目录下了:

// src/Core/ContentManager/ContentManager.php
class ContentManager 
{
    // ...
    private function getRelativePathPaginatorTemplate()
    {
        $path = $this->getRelativePathPaginator();
        return $path ? $path.'/index.html' : 'index.html';
    }

    private function getRelativePathPaginator()
    {
        $result = '';
        $template = $this->configuration->getRepository()->get('paginate_path');
        $dir = dirname($template);
        if ($dir != '.') {
            $result = ltrim($dir, '/');
        }
        return $result;
    } 
}

Spress 在生成分页的时候会调用以上两个函数,getRelativePathPaginator 方法决定了首页放置路径。因为我只是用 Spress 来写博客,暂时未想到有什么把首页放到子目录的需求,直接修改代码:

// src/Core/ContentManager/ContentManager.php
class ContentManager 
{
    // ...
    private function getRelativePathPaginator()
    {
        $result = '';
        $template = $this->configuration->getRepository()->get('paginate_path');
        $dir = dirname($template);
        if ($dir != '.' && 0 === strpos($dir, '/')) {
            $result = ltrim($dir, '/');
        }
        return $result;
    } 
}

保守起见,我保留了原有的功能,根据 paginate_path 是绝对路径还是相对路径来决定首页的放置路径。然后就可以安心的使用相对路径(例如:page/:num)来定义分页格式了。

Spress 不支持自动部署,也就是说每次 site:build 之后需要自己手工上传。我的博客托管在 Github pages 上,于是直接修改 destination 参数,指向博客的 git 目录。如果你直接放到了 spress 目录,需要将其添加到 exclude 列表,防止多次复制。Github pages 需要的文件(例如:CNAME)必须提取到 Spress 目录,不然会被清空。

经过上面的修改和调整,Spress 已经能够正常使用了。如果你不想自己动手修改代码,可以直接使用我打包好的 Phar 文件,因为是基于 Spress 2.0.0 开发版打包,所以查看帮助列表的时候会看到 unstable 的提醒,忽略即可。

Memo View Component

最近在对自写的 Memo 框架做一些结构调整,准备将它的一部分组件独立出来以便分开使用,最先完成的是 Memo View Component。Memo View 是一个轻量级的使用 PHP 原生语法的模板“引擎”,你可以使用它来实现简单的模板继承功能。

Memo View 使用 PHP 原生语法,因此我们可以这样定义一个 base 模板:

// file templates/base.php
<html>
    <head>
        <meta charset="utf-8">
        <?=$this->section("title")?>
    </head>
    <body>
        <?=$this->section("content")?>
    </body>
</html>

base 模板中的 section 方法相当于一个占位符,具体内容需要子模板来填充。我们再定义一个基于 base 的 index 子模板:

// file templates/index.php
<?php $this->layout("base"); ?>

<?php $this->open("title"); ?>
    <title>Memo Framework</title>
<?php $this->close(); ?>

<?php $this->open("content"); ?>
    <h1>Memo</h1>
    <p>Memo is a PHP micro framework.</p>
    <?=$this->section("github")?>
<?php $this->close(); ?>

在 index 模板中使用 layout 方法来声明其继承于 base 模板。然后使用 open 和 close 方法来填充对应 section 的内容。Memo View 支持 layout 的嵌套,所以我们在 index 模板中同样可以使用 section 方法。

然后我们可以使用 Memo View 来载入之前定义的模板:

// file index.php
require __DIR__ . "/Memo/src/Memo/View.php";

use Memo\View;

try {
    $view = new View(array(
        "template" => "index",
        "folders" => array(__DIR__ . "/templates/")
    ));
    $view->display();
} catch (Exception $e) {
    echo $e->getMessage(), PHP_EOL;
}

Memo View 的 constructor 可以接受一个数组作为初始化参数,template 是模板名,folders 是模板目录。folders 可以同时定义多个目录,载入时会按顺序查找,并使用最先找到的模板。输出的内容大致如下:

<html>
    <head>
        <meta charset="utf-8">
        <title>Memo Framework</title>
    </head>
    <body>
        <h1>Memo</h1>
        <p>Memo is a PHP micro framework.</p>
    </body>
</html>

在模板中使用变量也非常方便,我们继续定义一个基于 index 的子模板 github:

// file templates/github.php
<?php $this->layout("index"); ?>

<?php $this->open("title"); ?>
    <title>Fork me on Github</title>
<?php $this->close(); ?>

<?php $this->open("github"); ?>
    <p>Github: <a href="<?=$repo?>"><?=$this->toUpper($repo)?></a></p>
<?php $this->close(); ?>

在 github 模板中又设置了一次 title secion 的内容,并且在 github section 中使用了 $repo 变量 以及 toUpper 方法。

// file index.php
require __DIR__ . "/Memo/src/Memo/View.php";

use Memo\View;

class Helper 
{
    public function toUpper($string)
    {
        return strtoupper($string);
    }
}

try {
    $view = new View(array(
        "template" => "github",
        "helper" => new Helper(),
        "folders" => array(__DIR__ . "/templates/")
    ));
    $view->assign("repo", "https://github.com/zither/Memo");
    // 可以通过 $view->render() 方法来获取内容
    echo $view->render();
} catch (Exception $e) {
    echo $e->getMessage(), PHP_EOL;
}

上面的代码中我们新增了一个 helper 参数,其值为 Helper 类的对象,我们需要通过它来提供 github 模板中需要的 toUpper 函数。然后使用 assign 方法设置了 repo 变量。输出内容大致如下:

<html>
    <head>
        <meta charset="utf-8">
        <title>Fork me on Github</title>
    </head>
    <body>
        <h1>Memo</h1>
        <p>Memo is a PHP micro framework.</p>
        <p>Github: <a href="https://github.com/zither/Memo">HTTPS://GITHUB.COM/ZITHER/MEMO</a></p>
    </body>
</html>

从输出的内容可以看到,github 模板中定义的 title 覆盖了 index 模板中设置的内容,这点需要注意。同时 index 模板中定义的 github section 也被正确填充,这说明 Helper 中的 toUpper 方法也被正确调用。

Skcoswodahs client 的简单实现(续)

考虑到之前的那篇「Skcoswodahs client 的简单实现」中使用了一些不恰当的第三方库,导致一些朋友在完善客户端的时候遇到了问题,特意写了这篇文章来说明一下。

在上一篇文章中,为了方便我直接使用了 RC4Crypt 这个简单的 RC4 实现。但是这个库是不能真的用到客户端中去:它不支持 update 操作,每次加密或解密的时候都会重置 S box,如果连续操作的话就会出现乱码。

所以我重新了实现了一个带状态的 RC4-PHP 类,用来代替之前的 RC4Crypt,使用说明如下:

// Source: https://github.com/zither/RC4-PHP
require __DIR__ . "/src/RC4.php";

$password = md5("password", true);
$plaintext = "0123456789abcdefghijklmnopqrstuvwxyz";

$encryptor = new RC4($password, RC4::ENCRYPT_MODE_UPDATE);
$ciphertext = $encryptor->encrypt($plaintext);

$decryptor = new RC4($password, RC4::ENCRYPT_MODE_UPDATE);
$subCiphertext1 = substr($ciphertext, 0, 16);
$subCiphertext2 = substr($ciphertext, 16);

printf(
    "------ UPDATE MODE ------\nsubPlaintext1: %s\nsubPlaintext2: %s\n",
    $decryptor->decrypt($subCiphertext1),
    $decryptor->decrypt($subCiphertext2)
);

// output
//------ UPDATE MODE ------
//subPlaintext1: 0123456789abcdef
//subPlaintext2: ghijklmnopqrstuvwxyz

为了验证可用性,我用 PHP 写了一个只支持 RC4 的 Skcoswodahs 客户端:Psslocal.phar,下载后可以得到一个 Phar 包,用 PHP 执行它即可:

// 客户端中使用了 mcrypt_create_iv 函数来生成 iv,运行前注意加载 MCrypt 拓展
php psslocal.phar -c ~/skcoswodahs/config.json
// 或者
sudo mv psslocal.phar /usr/local/bin/psslocal
sudo chmod u+x /usr/local/bin/psslocal
psslocal -c ~/skcoswodahs/config.json

这个客户端只是随手找的一个 SOCKS5 库改的,代码不敢恭维,测试即可,请勿当真。

其实用 PHP 来写 Skcoswodahs 客户端并不是一个很好的选择,抛开性能不说,在实现各种加密算法的时候都会遇到很多问题:PHP 官方自带的 Openssl 拓展不支持 context 设置和 update 操作; MCrypt 拓展各版本支持的算法不全,比如在新版本中移除了 RC4 支持。当然这些问题都可以通过安装第三方拓展来解决,但是这样就大大降低了客户端的通用性。

如果你只熟悉 PHP 语言,那么我推荐你改写 Skcoswodahs 服务端,在服务端可以使用各种高性能框架,加密算法的支持也可以通过拓展解决,那么写客户端时要面临的问题都不复存在了。

----- 2015年02月25日更新 -----

我试着基于 Swoole + Openssl + Openssl-incremental 拓展实现了一个 PHP 版的 Skcoswodahs 服务端,在几天的单人测试中没有出现任何问题,一直维持着极低的 CUP 和 内存使用率。由于 Swoole 拓展自身的一些特性需求,在实现多端口的动态监听时会有些麻烦,但如果只监听多个固定端口还是非常方便的。

Skcoswodahs client 的简单实现

现在最热门的上网姿势非 Skcoswodahs 莫属,当初作者发布的时候只是一个非常简单的小工具,但是没想到现在基本达到了全平台覆盖。可能有不少朋友对它的原理感兴趣,这里就让我们使用 PHP 来实现一个简单的 Skcoswodahs 客户端。

首先我们需要知道 Skcoswodahs 在传输数据的时候是分为3个部分,这里我们使用浏览器访问网站的例子来说明:

+--------------------------------------------------------------+
|             1                2                 3             |
+  browser ------- ss-local ------- ss-server -------  Google  +
|           SOCKS                  Skcoswodahs                 |
+--------------------------------------------------------------+

从上面我们可以看出,shadowsock 将一个 SOCKS 代理拆分为 ss-local 以及 ss-server 两个代理,阶段1和阶段3传递的都是原始数据。而 ss-local 与 ss-server 之间(2)传输的则是经过加密的数据,我们暂且称其为 Skcoswodahs 协议。

在这篇文章中我们要实现的就是 ss-local,它负责接收浏览器发出的 HTTP 请求,然后将请求内容加密后转发到 ss-server。不过为了方便这里我们跳过了 ss-local 与浏览器 SOCKS 握手的 method-dependent 阶段,直接纯手工构建需要传输的数据。如果你并不了解 SOCKS 协议,那么需要先阅读 SOCKS 协议规范

在浏览器与 ss-local 之间的 method-dependent 协商完成后,浏览器会将需要访问的服务器地址和端口发送给 ss-local,其格式如下:

 +----+-----+-------+------+----------+----------+
 |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
 +----+-----+-------+------+----------+----------+
 | 1  |  1  | X'00' |  1   | Variable |    2     |
 +----+-----+-------+------+----------+----------+

ATYP,DST.ADDR 和 DST.PORT 这三个字段(Header)是需要通过我们的客户端转发到 ss-server 的。在我们与 ss-server 连接成功后可以完成与浏览器 SOCKS 协商的剩下部分。浏览器再将 HTTP 请求发送到我们的客户端。我们可以直接将之前的 Header 和 HTTP 请求内容拼接在一起进行加密,然后通过之前建立的连接发送的 ss-server。加密数据的格式如下:

 +------+----------+----------+----------+
 | ATYP | DST.ADDR | DST.PORT |   HTTP   | 
 +------+----------+----------+----------+
 |  1   | Variable |    2     | Variable |
 +------+----------+----------+----------+

需要注意的是,header 部分只需要在建立通道的时候发送一次,通道建立之后直接传递加密的请求数据即可。ss-server 在收到数据后会直接解密,读取 ATYP 字段,根据其类型解析 header 部分,如果数据的整个长度超过 header 的长度,剩下的部分就会作为请求的内容。ss-server 会将从目标服务器获取的数据加密后通过通道转发回我们的客户端,我们需要将数据解密后返回给浏览器。

从整个通信过程来说,我们的客户端其实就是带有加密解密功能的 SOCKS server。除了 SOCKS server 最基本的功能以外,需要我们动手就是加密解密功能了。现在我们开始写代码:

// Source code: https://gist.github.com/zither/e35888cbb98e56f59dd4
require "Encryptor.php";
require "Cipher.php";
// Update:http://blog.shouhuiben.net/article/2015/02/20/a-simple-implementation-of-skcoswodahs-client-2/
require "Rc4crypt.php";

$domain = "www.google.com";
$data = pack("C2", 0x03, strlen($domain));
$data .= $domain . pack("n", 0x50);
$data .= sprintf("GET / HTTP/1.1\r\nHost:%s\r\nAccept:text/html\r\n\r\n", $domain);

我们的客户端非常简单,简单到只支持一直加密方式:RC4。Encryptor.php,Cipher.php 和 Rc4crypt.php 这三个文件功能比较简单。Rc4crypt 提供了 RC4 加密方法的原生实现,Cipher 只是对 Rc4crypt 包裹了一下,实现了 Skcoswodahs 使用的 RC4-md5 加密,其实就是每个请求使用不同的 key 而已。Encryptor 的主要作用有两个:一是通过 password 生成加密需要的 key 和 随机 iv 字符串,二是调用 Cipher 对数据进行加密和解密。

我直接跳过了与浏览器协商的部分,我们假设浏览器发出的请求是访问 Google 首页,为了摆出科学的姿势,我们不能在本地对 Domain 进行 DNS 解析,所以 Header 的内容是这样的:

 +------+------+----------------+----------+
 | ATYP | ALEN |    DST.ADDR    | DST.PORT | 
 +------+------+----------------+----------+
 | 0x03 | 0x0E | www.google.com |   0x50   |
 +------+------+----------------+----------+

上面表示的是 ATYP 为完整域名(0x03)时的格式,ALEN 用一个字节指定域名的长度,DST.PORT 用两个字节指定端口。SOCKS 还支持 IP4(0x01)和 IP6(0x04),对应的格式可以直接在 SOCKS 协议规范中了解。为了避免 ss-server 在解析 Header 时出现错误,ATYP,ALEN 以及 DST.PORT 这几个重要的部分我们通过 pack 函数以二进制的形式包装到字符串中。最后我们在 Header 后面附加一个简单的 HTTP 请求,这样我们就完成了一个虚拟请求的数据构成。

现在我们可以将数据进行加密了:

$encryptor = new Encryptor("password", "RC4");
$encryptedData = $encryptor->encrypt($data);

这里的 password 就是 Skcoswodahs 的配置文件中约定的密码,RC4 是加密方法。这里有一个地方需要注意,在第一次加密数据的时候 Encryptor 会将随机字符串 iv 与我们的数据拼接在一起:

class Encryptor 
{
    //...
    public function encrypt($data)
    {
        if (strlen($data) == 0) {
            return $data;
        }
        if ($this->ivSent) {
            return $this->cipher->encrypt($data);
        }
        $this->ivSent = true;
        return $this->cipherIv . $this->cipher->encrypt($data);
    }
    //...
}

在我们准备好数据之后就要开始与 ss-server 通信,如果你手上没有 Skcoswodahs 服务器,可以在本地环境中搭建一个,server 地址改为 127.0.0.1 就可以了:

{
    "server": "127.0.0.1",
    "server_port": "50560",
    "local_address": "127.0.0.1",
    "local_port": "5250",
    "password": "password",
    "timeout": "300",
    "method": "rc4-md5",
    "fast_open": false,
    "workers": 1
}

如果你使用的是本地服务器,请将前面代码中的 domain 换为国内能够访问的地址,比如 www.bing.com。数据加密之后我们需要与 ss-server 建立一个通道:

$remote = stream_socket_client("tcp://example.ss-server.com:50565", $errno, $errstr);
if (!$remote) {
    throw new Exception($errstr, $errno);
}
$send = fwrite($remote, $encryptedData);
printf("Forward %d bytes data to remote.\n", $send);

在 PHP 5.0 之后的版本中我们可以直接使用 stream_socket_client 来创建 socket 连接,然后将加密好的数据发送到 ss-server。ss-server 会解密数据再将其转发到目标服务器。我们只需等待 ss-server 的回应即可:

$encryptedResponse = "";
stream_set_timeout($remote, 1);
while(true) {
    // 在阻塞模式中不能使用 stream_socket_recvfrom,stream_set_timeout 的设置对其无效
    // chunk size 一般为 8192
    $buffer = fread($remote, 8192);
    if ("" === $buffer || false === $buffer) {
        break;
    }
    $encryptedResponse .= $buffer;
}
$response = $encryptor->decrypt($encryptedResponse);
printf(
    "Receive %d bytes data from remote.\nResponse content:\n\n %s\n", 
    strlen($response), 
    $response
);

考虑到数据被分片的情况,我们使用无限循环保证读取到所有数据,再使用 Encryptor 解密数据,你应该可以看回应的内容大致为:

Forward 91 bytes data to remote.
Receive 526 bytes data from remote.
Response content:

HTTP/1.1 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: http://www.google.co.jp/?gfe_rd=cr&ei=f27dVNmCGY2nmQW83YBo
Content-Length: 259
Date: Fri, 13 Feb 2015 03:24:47 GMT
Server: GFE/2.0
Alternate-Protocol: 80:quic,p=0.08

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&amp;ei=f27dVNmCGY2nmQW83YBo">here</A>.
</BODY></HTML>

这表明我们已经成功将请求发送到了 Google 的服务器。由于我的 ss-server 服务器在日本,所以 Google 回应了一个 302 跳转。在请求完成之后就可以关闭通道了:

stream_socket_shutdown($remote, STREAM_SHUT_RDWR);
fclose($remote);

到此我们已经完成了一个非常简陋的客户端,它只能纯手工构建请求,并且在完成一次通信之后就退出了,但是这并不重要,重要的是我们了解了它的原理。如果你想要完善这个客户端,可以自己为它补上 SOCKS server 的功能,也可以使用其他开源实现,比如:https://github.com/clue/php-socks-react

Simple Qiniu SDK 使用说明

为了解决「手绘本」的头像托管问题,前几天重写了一遍 Simple Qiniu SDK,剔除了上传以外的其他功能。这是吸取了之前的教训:只维护自己用得到的代码。因此现在的 Simple Qiniu SDK 只能用于小文件上传。下面是一个简单的使用示例:

// Autoload 源码:https://github.com/zither/simple-qiniu-sdk/blob/master/example/Autoload.php
require __DIR__ . "/Autoload.php";
Autoload::addNamespace("Qiniu", dirname(__DIR__) . "/src/Qiniu");
Autoload::register();

$accessKey = "accessKey";
$secretKey = "secretKey";
$qiniu = new \Qiniu\Qiniu($accessKey, $secretKey);

$bucket = $qiniu->getBucket("sketch");
$response = $bucket->put($_FILES["file"]["tmp_name"]);
echo $response->getContent();

这次重写我剔除了 Simple Qiniu SDK 中的自动加载函数,所以在使用前你需要自己解决自动加载问题(推荐使用 Composer)。这个示例使用的是默认设置,如果你需要修改七牛的上传策略,可以使用 setPolicy 方法:

$bucket = $qiuniu->getBucket("sketch");
// 更多策略参数请参考:http://developer.qiniu.com/docs/v6/api/reference/security/put-policy.html
$bucket->setPolicy(array(
    "saveKey" => sprintf("%s.jpg", time()),
    "returnBody" => '{"key": $(key),"name": $(fname)}',
    "expires" => 3600
));

你不仅可以指定文件保存的名称,还可以设置 \Qiniu\Bucket::EXTR_OVERWRITE 参数来启用 put(更新) 模式:

$bucket->put($_FILES["file"]["tmp_name"], "avatar.png", \Qiniu\Bucket::EXTR_OVERWRITE);

你可以自定义一些魔术变量,然后以数组的形式传递给 put 方法:

$bucket->setPolicy(array(
    // 通过 returnBody 的形式返回魔术变量
    "returnBody" => '{"key": $(key), "user": $(x:user)}',                  
));
$uploadParams = array(
    // 文件保存名称
    "key" => "avatar.png",
    // 自定义魔术变量
    "x:user" => "Simple Qiniu SDK"
);
$bucket->put($_FILES["file"]["tmp_name"], $uploadParams);

如果你希望采用表单上传模式,你可以使用 getUpToken 方法来获取上传令牌:

<form action="http://upload.qiniu.com/" method="post" enctype="multipart/form-data">
    <input name="file" type="file" />
    <input name="x:user" type="hidden" value="Simple Qiniu SDK">
    <input name="token" type="hidden" value="<?=$bucket->getUpToken()?>">
    <button id="upload" type="submit">上传到七牛</button>
</form>

以上就是重构后 Simple Qiniu SDK 所有功能的使用说明,如果你还需要「删除文件」等其他功能,可以自行修改或者使用官方 SDK。当然,如果我也有了对应的使用需求,也会给 Simple Qiniu SDK 增加对应的功能支持。