续
前段时间公司有人反馈部分上传的文件无法下载,由于我们 CDN 用的是七牛云,而那个出问题的系统后端用的是 Node.js,因此想到有可能是七牛官方的 Node.js SDK 的 bug。首先查了一下那些无法下载的文件名字,一看都是文件名中包含了特殊字符 “#” 号。这样大体猜到问题可能出在 URL encode 上,于是查了下七牛的 nodejs-sdk 的源码发现问题出在了这里:
// https://github.com/qiniu/nodejs-sdk/blob/v7.1.1/qiniu/storage/rs.js#L651
BucketManager.prototype.publicDownloadUrl = function(domain, fileName) {
return domain + "/" + encodeURI(fileName);
}
encodeURI
是不处理特殊字符 “?” 和 “#” 的,所以如果文件名中包含这种特殊字符就会导致生成的下载链接是错误的,比如文件名是 “hello#world.txt”:
publicDownloadUrl('http://example.com', 'hello#world.txt')
// => http://example.com/hello#world.txt
// 正确的应该是:http://example.com/hello%23world.txt
知道了问题出在没有转义特殊字符,那能不能提前转义呢,由于 encodeURI
会转义 “%” 字符,很显然提前转义也不行。
encodeURI('hello%23world.txt')
// => hello%2523world.txt
很显然解决问题只能给七牛的 SDK 提 PR 了,但是如何修改呢?
- encodeURI 换成 encodeURIComponent —— 否决("/" 也被转义了)
- 写一个 encodePath —— 否决(fileName 也有可能带参数:“he#llo.jpg?imageView2/2/w/800”)
- 修改 API 为 publicDownloadUrl(domain, fileName, query) —— 否决(改动太大,要升级大版本)
- 将 encodeURI 修改为其他函数避免重复 encode —— 可行(改动不大,影响最小)
于是就提了这个 PR:Safe encode url
PS: 其实考虑 API 友好还是 3 比较好,或者干脆不做 encode 让使用者自己处理。不过这些都影响太大。
既然问题解决了,那是不是本文就收尾了呢,哈哈这只是一个开头,下面我们真正来聊一聊 URL Encode。
规范
上面一直用 URL 是因为它是一个具体的资源,既然谈到规范应该用 URI,具体规范详见:Uniform Resource Identifier
下面这个例子包含了 URI 规范定义的各个部分:
http://example.com:8042/over/there?name=ferret#nose
\_/ \______________/\_________/ \_________/ \__/
| | | | |
scheme authority path query fragment
| _____________________|__
/ \ / \
urn:example:animal:ferret:nose
根据规范 URI 包含 5 个部分:
-
Scheme
scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
-
Authority (包含:UserInfo, Host, Port)
authority = [ userinfo "@" ] host [ ":" port ] userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) host = IP-literal / IPv4address / reg-name port = *DIGIT
-
Path
path = path-abempty ; begins with "/" or is empty / path-absolute ; begins with "/" but not "//" / path-noscheme ; begins with a non-colon segment / path-rootless ; begins with a segment / path-empty ; zero characters
-
Query
query = *( pchar / "/" / "?" )
-
Fragment
fragment = *( pchar / "/" / "?" )
实现一个 URI Encode
根据上面的规范,让我们来实现一个 URI Encode 吧,首先定义 URI 各个部分:
const TYPE = {
SCHEME: 1,
AUTHORITY: 2,
USER_INFO: 3,
HOST_IPV4: 4,
HOST_IPV6: 5,
PORT: 6,
PATH: 7,
PATH_SEGMENT: 8,
QUERY: 9,
QUERY_PARAM: 10,
FRAGMENT: 11,
URI: 12
};
然后来实现几个判别函数:
function isAlpha (c) {
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
function isDigit (c) {
return (c >= 48 && c <= 57);
}
function isGenericDelimiter (c) {
// :/?#[]@
return [58, 47, 63, 35, 91, 93, 64].indexOf(c) >= 0;
}
function isSubDelimiter (c) {
// !$&'()*+,;=
return [33, 36, 38, 39, 40, 41, 42, 43, 44, 59, 61].indexOf(c) >= 0;
}
function isReserved (c) {
return isGenericDelimiter(c) || isSubDelimiter(c);
}
function isUnreserved (c) {
// -._~
return isAlpha(c) || isDigit(c) || [45, 46, 95, 126].indexOf(c) >= 0;
}
function isPchar (c) {
// :@
return isUnreserved(c) || isSubDelimiter(c) || c === 58 || c === 64;
}
接下来实现判断某个字符是否需要转义,isAllow(char, type) 为 true 表示不需要转义。
function isAllow (c, type) {
switch (type) {
case TYPE.SCHEME:
return isAlpha(c) || isDigit(c) || [43, 45, 46].indexOf(c) >= 0;// +-.
case TYPE.AUTHORITY:
return isUnreserved(c) || isSubDelimiter(c) || c === 58 || c === 64;// :@
case TYPE.USER_INFO:
return isUnreserved(c) || isSubDelimiter(c) || c === 58;// :
case TYPE.HOST_IPV4:
return isUnreserved(c) || isSubDelimiter(c);
case TYPE.HOST_IPV6:
return isUnreserved(c) || isSubDelimiter(c) || [91, 93, 58].indexOf(c) >= 0;// []:
case TYPE.PORT:
return isDigit(c);
case TYPE.PATH:
return isPchar(c) || c === 47;// /
case TYPE.PATH_SEGMENT:
return isPchar(c);
case TYPE.QUERY:
return isPchar(c) || c === 47 || c === 63;// /?
case TYPE.QUERY_PARAM:
return (c === 61 || c === 38)// =&
? false
: (isPchar(c) || c === 47 || c === 63);// /?
case TYPE.FRAGMENT:
return isPchar(c) || c === 47 || c === 63;// /?
case TYPE.URI:
return isUnreserved(c);
default: return false;
}
}
最后实现 encode 函数:
// 前 128 个 ASCII 码我们用查表的方式转义
const hexTable = new Array(128);
for (let i = 0; i < 128; ++i) {
hexTable[i] = '%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase();
}
// 编码前 128 个用查表方式转义,后面的用 encodeURIComponent 处理
function encode (str, type = TYPE.URI) {
if (!str) return str;
let out = '';
let last = 0;
for (let i = 0; i < str.length; ++i) {
const c = str.charCodeAt(i);
// ASCII
if (c < 0x80) {
if (last < i) {
out += encodeURIComponent(str.slice(last, i));
}
if (isAllow(c, type)) {
out += str[i];
} else {
out += hexTable[c];
}
last = i + 1;
}
}
if (last < str.length) {
out += encodeURIComponent(str.slice(last));
}
return out;
}
这样一个简单的 URI Encode 就实现了,我们可以再封装几个工具函数:
function encodeScheme (scheme) {
return encode(scheme, TYPE.SCHEME);
}
function encodeAuthority (authority) {
return encode(authority, TYPE.AUTHORITY);
}
function encodePath (path) {
return encode(path, TYPE.PATH);
}
function encodeQuery (query) {
return encode(query, TYPE.QUERY);
}
// ...
来测试下:
encodePath('/foo bar#?.js')
// => /foo%20bar%23%3F.js
最后
最后找了下 npm 貌似没有相关的工具库,于是我们将其发布到了 npm 上:uri-utils,详细的源码在 Github 上:uri-utils,包含了单元测试和 Benchmark。