由于 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 框架做一些结构调整,准备将它的一部分组件独立出来以便分开使用,最先完成的是 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 的简单实现」中使用了一些不恰当的第三方库,导致一些朋友在完善客户端的时候遇到了问题,特意写了这篇文章来说明一下。
在上一篇文章中,为了方便我直接使用了 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 莫属,当初作者发布的时候只是一个非常简单的小工具,但是没想到现在基本达到了全平台覆盖。可能有不少朋友对它的原理感兴趣,这里就让我们使用 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&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 只能用于小文件上传。下面是一个简单的使用示例:
// 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 增加对应的功能支持。