原料
nginx --with-debug或openresty
背景
项目中有用户图片库需求,允许用户自定义文件夹,然后上传图片到该文件夹。 当用户自定义的文件夹为中文或者访问url中包含中文时,资源无法访问,返回404的状态码
分析过程
排除系统编码问题。
[root@slave2 ~]# locale
LANG=en_US.UTF-8
LC_CTYPE="en_US.UTF-8"
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_PAPER="en_US.UTF-8"
LC_NAME="en_US.UTF-8"
LC_ADDRESS="en_US.UTF-8"
LC_TELEPHONE="en_US.UTF-8"
LC_MEASUREMENT="en_US.UTF-8"
LC_IDENTIFICATION="en_US.UTF-8"
LC_ALL=
[root@slave2 ~]# env|grep LANG
LANG=en_US.UTF-8
nginx编码 当我们在网页中不指定编码类型时,默认为gbk编码.可以通过在nginx中添加默认的编码方式
Syntax: charset charset | off;
Default: charset off;
Context: http, server, location, if in location
此处设置的charset会在response header中Content-Type:text/html;charset=utf-8来体现 或者在网页中指定编码类型:<meta http-equiv="content-type" content="text/html;charset=utf-8">
注:网上大多教程为页面中文乱码问题。并不是此处描述的url中文404,请注意区分。
首先来看nginx 配置
server{
listen 8089;
server_name 172.19.23.208;
if ($uri ~* ^/(.+?)/(.*) ) {
set $domain $1;
set $user_uri /$2;
rewrite ^(.*)$ $user_uri last;
}
location / {
proxy_set_header Host $domain;
proxy_pass http://127.0.0.1:8087;
}
}
开启nginx debug模式,源码包安装时加上--with-debug参数,或者使用oenresty-debug包。 构造访问url:http://172.19.23.208:8089/www.test.com/猴哥/a.html 发起访问,同时我们侦测nginx debug日志。 这里选取主要的日志如下
1 2017/06/22 17:16:41 [debug] 8389#0: *44 http request line: "GET /www.test.com/%E7%8C%B4%E5%93%A5/a.html HTTP/1.1"
2 2017/06/22 17:16:41 [debug] 8389#0: *44 http uri: "/www.test.com/猴哥/a.html"
3 2017/06/22 17:16:41 [debug] 8389#0: *44 http script var: "/www.test.com/猴哥/a.html"
4 2017/06/22 17:16:41 [debug] 8389#0: *44 http script regex: "^/(.+?)/(.*)"
5 2017/06/22 17:16:41 [notice] 8389#0: *44 "^/(.+?)/(.*)" matches "/www.test.com/猴哥/a.html", client: 172.19.23.33, server: 172.19.23.208, request: "GET /www.test.com/%E7%8C%B4%E5%93%A5/a.html HTTP/1.1", host: "172.19.23.208:8089"
6 2017/06/22 17:16:41 [debug] 8389#0: *44 http script if
7 2017/06/22 17:16:41 [debug] 8389#0: *44 http script capture: "www.test.com"
8 2017/06/22 17:16:41 [debug] 8389#0: *44 http script set $domain
9 2017/06/22 17:16:41 [debug] 8389#0: *44 http script copy: "/"
10 2017/06/22 17:16:41 [debug] 8389#0: *44 http script capture: "%E7%8C%B4%E5%93%A5/a.html"
11 2017/06/22 17:16:41 [debug] 8389#0: *44 http script set $user_uri
12 2017/06/22 17:16:41 [debug] 8389#0: *44 http script regex: "^(.*)$"
13 2017/06/22 17:16:41 [notice] 8389#0: *44 "^(.*)$" matches "/www.test.com/猴哥/a.html", client: 172.19.23.33, server: 172.19.23.208, request: "GET /www.test.com/%E7%8C%B4%E5%93%A5/a.html HTTP/1.1", host: "172.19.23.208:8089"
14 2017/06/22 17:16:41 [debug] 8389#0: *44 http script var: "/%E7%8C%B4%E5%93%A5/a.html"
15 2017/06/22 17:16:41 [debug] 8389#0: *44 http script regex end
16 2017/06/22 17:16:41 [notice] 8389#0: *44 rewritten data: "/%E7%8C%B4%E5%93%A5/a.html", args: "", client: 172.19.23.33, server: 172.19.23.208, request: "GET /www.test.com/%E7%8C%B4%E5%93%A5/a.html HTTP/1.1", host: "172.19.23.208:8089"
1 原始请求
2 对应nginx变量$uri,可以看出$uri为解码过后的值
3 http脚本变量值"/www.test.com/猴哥/a.html" 此处为$uri
4 对应nginx配置文件中if后的正则表达式
5 测试if条件,结果为matches
6 进入if块
7,8,9,10,11 nginx变量捕获到值并进行set,注意在'https://github.com/nginx/nginx/blob/master/src/http/ngx_http_script.c', 函数ngx_http_script_copy_capture_code中
if ((e->is_args || e->quote) && (e->request->quoted_uri || e->request->plus_in_uri))
{
e->pos = (u_char *) ngx_escape_uri(pos, &p[cap[n]],
cap[n + 1] - cap[n],
NGX_ESCAPE_ARGS);
} else {
e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]);
}
确定了哪些条件会ngx_escape_uri,很明显中文编码在这些条件中。
注意此处我们得到了编码过后的$user_uri="%E7%8C%B4%E5%93%A5/a.html"
12,13,14,15,16
rewrite指令匹配然后使用已捕获的变量进行rewrite
梳理下:用户在浏览器中访问http://172.19.23.208:8089/www.test.com/猴哥/a.html ,由于url中有中文,浏览器自动将其就行urlencode(用户可见与真实url已经存在差异),这是第一次,然后 nginx得到用户请求后开始uri解析,$request_uri="/www.test.com/%E7%8C%B4%E5%93%A5/a.html" 该变量是不会变化的。$uri="/www.test.com/猴哥/a.html" 该变量表示当前请求的uri,在发生内部跳转,或者索引文件时可以被改写。 然后通过if的匹配,就行变量的捕获这些这都正常。但是set指令对应逻辑中有ngx_escape_uri操作,也就是上面说到的7-11步。最后进行rewrite操作,注意此处rewrite的是$user_uri(/%E7%8C%B4%E5%93%A5/a.html)的值。此处有两次内部跳转,一次为rewrite,第二次为proxy_pass 第二次proxy_pass可以理解为正常用户通过浏览器访问(进行一次urlencode("%E7%8C%B4%E5%93%A5"-->'%25E7%258C%25B4%25E5%2593%25A5')),nginx收到请求为 "GET /%25E7%258C%25B4%25E5%2593%25A5/a.html?b=2 HTTP/1.0", 后面的逻辑便和之前相同。如果该资源为本地文件,nginx使用$uri(/%E7%8C%B4%E5%93%A5/a.html)进行文件查找,结果便为404。其他proxy_pass逻辑也会出现404问题。
(需要说明nginx中$request_uri和$uri,前者代表包含参数且未进过解码的链接后者为解码后未包含参数的链接,顺便提一点,如果我们在日志中打印nginx变量$request_uri和$uri会的到相同的值,这是因为在nginx的log模块中也进行了escape操作。两者的值可以通过nginx debug日志看出差异。)
解决方案
既然此种需求问题在set指令时对url就行了ngx_escape_uri,我们只需在其之后就行unescape_uri便可。
server{
listen 8089;
server_name 172.19.23.208;
if ($uri ~* ^/(.+?)/(.*) ) {
set $domain $1;
set $user_uri /$2;
set_by_lua_block $new_url {
return ngx.unescape_uri(ngx.var.user_uri)
}
rewrite ^(.*)$ $new_url last;
}
location / {
proxy_set_header Host $domain;
proxy_pass http://127.0.0.1:8087;
}
}
这里使用openresty的ngx.unescape_uri方法实现 或者使用第三方模块ngx_set_misc 的set_unescape_uri。
set_unescape_uri $new_url $user_uri;