一.背景
最近用户提出需求:某个系统A下载的excel文档需要进行权限控制,比如只能下载文档的用户(即文档owner)查看或者编辑,其他人想要查看或者编辑,需要文档owner进行手动设置,当然也可以手动取消权限控制,如下图所示。
该功能的核心逻辑就是对 windows的 office文件实现域控,这个一般是购买第三方的服务,比如WPS等产品。下载文档的系统A是供应商提供的,在公司已有域控服务的前提下,有2个方案。一是供应商做二开,在应用程序里面调用我们的域控接口,二是自研,在nginx上修改下载请求逻辑。如果要求供应商二开,后续的需求变更就会比较麻烦,过程也比较冗长。因此基于nginx+lua的自研方案就比较合适,并且以后可以对接任意系统,稍微改几行代码,就可以实现对任意应用系统(基于http协议)下载文档的域控功能。
二.方案设计
我们需要实现拦截器的功能,可以在openresty的 access_by_lua_block阶段,拦截用户文档下载请求/download,使用ngx.socket.tcp主动发起/download请求,将返回的结果作为域控请求/upload的参数,再次使用ngx.socket.tcp发起域控请求,然后将结果返回给浏览器即可。
需要注意的是,如果文档很大或者下载并发比较高的话,可能会占用nginx服务器的内存,因此我们使用ngx.socket.tcp实现流式传输,内存里面只存很小一部分数据,对nginx服务器造成的压力很小。
三.代码demo
下载接口(/download)代码demo
@GetMapping("/download")public void download(HttpServletResponse response) throws UnsupportedEncodingException {File file = new File("E:\\test\\test-excel.xlsx");if (file.exists()) { //判断文件父目录是否存在response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");response.setCharacterEncoding("UTF-8");response.setHeader("Content-Disposition", "attachment;fileName="+ java.net.URLEncoder.encode("test-excel.xlsx", "UTF-8"));byte[] buffer = new byte[1024];FileInputStream bis = null; //文件输入流
// BufferedInputStream bis = null;OutputStream os; //输出流try {os = response.getOutputStream();bis = new FileInputStream(file);
// bis = new BufferedInputStream(fis);int i = bis.read(buffer);while (i != -1) {os.write(buffer);i = bis.read(buffer);}} catch (Exception e) {e.printStackTrace();}try {bis.close();
// fis.close();} catch (IOException e) {e.printStackTrace();}}System.out.println("下载结束了!");}
域控接口(/upload)代码demo
@PostMapping("/upload") public void doUpload(@RequestParam("file") MultipartFile file , HttpServletResponse response, @RequestParam("rights") String rights) {String fileName = "test-excel.xlsx";System.out.println("rights = " + rights);response.setHeader("Connection", "keep-alive");
// response.setContentType("application/vnd.ms-excel");
// response.setHeader("Content-disposition", "attachment;filename=" + fileName);FileInputStream fis;try (OutputStream os = response.getOutputStream()) {fis = (FileInputStream) file.getInputStream();byte[] buffer = new byte[1024];int bytesRead;while ((bytesRead = fis.read(buffer)) != -1) {os.write(buffer, 0, bytesRead);}os.flush();} catch (IOException e) {e.printStackTrace();}}
nginx路由配置
location /file-test {access_by_lua_block {local resp_header_param = {"content_type", "content_disposition"}local file_download = require "resty.file_download"local resp_header = file_download.finebi_file_download(resp_header_param)}
}
这里需要注意的是,如果重新发起请求的url和初始url一样,比如都是http://abc.com/download,那么仅仅使用上面的配置会进入死循环,需要额外再加配置,比如下面,这时候主动发起的url是:http://abc.com/or-file-export/download。
# /or-file-export 是主动发起下载文档请求的路径前缀,
# 需要和正常的下载路径区分开,否则会进入死循环
location /or-file-export/ {rewrite /or-file-export(.*) $1 break;proxy_pass xxx;...
}
lua文档下载脚本
local util = require "resty.lserver.util"
local http_client = require "resty.lserver.http_client"
local cjson = require 'cjson'
local _M = {}
local exclude_header = {}
local file_export_path = "/or-file-export"local function is_include(t, value)for k, v in pairs(t) doif v == value thenreturn trueendendreturn false
endlocal function parse_bi_jwt_token(token)if token thenlocal json = cjson.decode(ngx.decode_base64(string.sub(token, ngx.re.find(token, [[(?<=\.)[^\.]+]], "jo"))))if json and json.sub thenreturn json.subendelse return nil, "用户cookie: f_auth_token不存在!"endreturn nil, "解析用户名称出错!f_auth_token = " .. token
endfunction _M.bi_file_download(resp_header_param)local session_id_name = "f_auth_token"local username = parse_bi_jwt_token(ngx.var["cookie_" .. session_id_name])if not username thenngx.log(ngx.ERR, "发生错误:下载加域权限的excel文件时,bi-user信息解析失败!")ngx.say("发生错误:下载加域权限的excel文件时,bi-user信息解析失败!")ngx.exit(500)endngx.log(ngx.ERR, "\r\nusername = " .. username .. "\r\n")local schema = "https"local url = schema .. "://" .. ngx.var.host .. file_export_path .. ngx.var.request_urilocal req_header = ngx.req.get_headers()local real_header = {}for k,v in pairs(req_header) doif not is_include(exclude_header, k) thenreal_header[k] = vendendlocal origin_client = http_client.get(url, real_header)local resp_header = origin_client["resp_header"]--[[该代码段使用原始请求的响应头,因为该请求是下载文件,因此不必要使用原始的响应头,只需要指定:Content-Type和Content-Dispositionfor key, value in pairs(resp_header) dongx.header[key] = resp_header[key]end
--]]if resp_header_param and type(resp_header_param) == "table" thenfor key, value in pairs(resp_header_param) dongx.header[value] = resp_header[value]endendlocal rights_param = "[{\"user\":\"" .. username .. "\",\"right\":\"OWNER\"}]"local map = { rights = rights_param, file = {client = origin_client, name = "file", filename = "新建仪表板1.xlsx",content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}}local header_map = {}header_map["Transfer-Encoding"] = "chunked"local final_client = http_client.form_data("http://xx.xx.xx.xx:8082/upload", map, header_map)local response, err = http_client.receive_foreach(final_client, function (chunked)ngx.print(chunked)end)ngx.exit(200)
endreturn _M
httpclient lua脚本
local ngx_re_match = ngx.re.match
local ngx_re_find = ngx.re.find
local str_sub = string.sub
local cjson = require 'cjson'
local bit = require 'bit'
local ffi = require 'ffi'
local resty_base = require "resty.core.base"local class_name = "lserver-http-client-2.0";
local _M = {_VERSION = 0.2, _CLASS_NAME = class_name}
local mt = { __index = _M }
local is_debug = true
local method_get = 'GET'
local method_post = 'POST'local char_underline = string.byte('_')
local char_minus = string.byte('-')
local function http_upper(word, ty)local strlen = #wordlocal buffer= resty_base.get_string_buf(strlen+1)ffi.copy(buffer, word, strlen)buffer[0] = bit.band(buffer[0], -33)for i = 1, strlen-1 doif buffer[i] == char_underline or buffer[i] == char_minus thenbuffer[i] = char_minusbuffer[i + 1] = bit.band(buffer[i + 1], -33)endendlocal res = ffi.string(buffer, strlen)return res
endlocal function is_include(t, value)for k, v in pairs(t) doif v == value thenreturn trueendendreturn false
endlocal header_meta = {__newindex = function (t, k, v)if type(k) == "string" then --and (not is_include(header_whitelist, k))k = http_upper(k)endrawset(t, k, v)end,__index = function (t, k)local value = rawget(t, k)if value == nil and type(k) == "string" thenk = http_upper(k)elsereturn valueendreturn rawget(t, k)end
}local function read_head(self)local header = setmetatable({}, header_meta)local header_line_arrif is_debug thenheader_line_arr = {}endrepeatlocal header_line = self.sock:receive()if is_debug thentable.insert(header_line_arr, header_line)endif not header_line thenreturn headerendlocal i, i2 = ngx.re.find(header_line, [[\:\s*]])if i and i2 thenlocal header_name = str_sub(header_line, 1, i-1)local value = str_sub(header_line, i2 + 1)if header_name thenheader[header_name] = valueendenduntil ngx_re_find(header_line, [[^\s*$]], "jo")if is_debug thenngx.log(ngx.ERR, 'resp header:\n',table.concat(header_line_arr, '\n') .. "\n")endself.resp_header = header
endlocal function send_header(self, header_map)local head_arr = {self.method, " ", self.request_url, " HTTP/1.1\r\n"}for key, value in pairs(header_map) doself.req_header[key] = valueendlocal i = #head_arr + 1for k, v in pairs(self.req_header) dohead_arr[i] = k .. ": " .. tostring(v) .. '\r\n'i = i + 1endhead_arr[i] = '\r\n'if is_debug thenngx.log(ngx.ERR, 'reqheader\n', table.concat(head_arr))endself.sock:send(head_arr)
endlocal function read_code(self)local line = assert(self.sock:receive())if is_debug thenngx.log(ngx.ERR, "\r\n", line, "\r\n" )endlocal code = tonumber(str_sub(line, 10, 12))local reason = str_sub(line, 14)self.code = codeself.reason = reason
end---读取http响应中的一块chunk
---@param sock tcpsock
---@return nil|string chunk
---@return nil|string error
local function read_chunk(sock)local chunk_size_str, err = sock:receive()if not chunk_size_str thenreturn nil, err or 'chunk size error'endlocal chunk_size = tonumber(chunk_size_str, 16)if not chunk_size thenreturn nil, "chunk size error"endlocal chunk, err = sock:receive(chunk_size)if not chunk thenreturn nil,err or 'no chunk'endlocal chunk_end = sock:receive(2)if chunk_end ~= "\r\n" thenreturn nil, "error chunk_end"endreturn chunk
end---发送一块chunked
---@param sock tcpsock
---@return nil|string data
local function send_chunked(sock, data)data = data or ""sock:send({string.format("%x", #data), '\r\n', data, '\r\n'})
endlocal function transfer_chunk(sock, dest_sock)local chunk_size_str, err = sock:receive()if not chunk_size_str thenreturn nil, err or 'chunk size error'endlocal send_content = chunk_size_str .. "\r\n"if chunk_size_str ~= "0" thendest_sock:send(send_content)endlocal chunk_size = tonumber(chunk_size_str, 16)if not chunk_size thenif is_debug thenngx.log(ngx.ERR, "XXXXXXXXXXXXXXXXXX chunk size error XXXXXXXXXXXXXXXXXXX")endreturn nil, ""endif chunk_size == 0 thenif is_debug thenngx.log(ngx.ERR, "VVVVVVVVVVVVVVVVVV chunk receive 结束了 VVVVVVVVVVVVVVVVVV")endendlocal chunk, err = sock:receive(chunk_size)if not chunk thenif is_debug thenngx.log(ngx.ERR, "XXXXXXXXXXXXXXXXXX no chunk XXXXXXXXXXXXXXXXXXX")endreturn nil,err or 'no chunk'endif chunk ~= "" thendest_sock:send(chunk)endlocal chunk_end = sock:receive(2)if chunk_end ~= "\r\n" thenif is_debug thenngx.log(ngx.ERR, "XXXXXXXXXXXXXXXXXX error chunk_end XXXXXXXXXXXXXXXXXXX")endreturn nil, "error chunk_end"endif chunk_size ~= 0 thendest_sock:send(chunk_end)endreturn chunk
endlocal function conn_and_new(uri)local sock = ngx.socket.tcp()local schema, host, port, request_urlif string.sub(uri, 1, 5) == "unix:" thenlocal _, sock_file_end = ngx_re_find(uri, [[unix:.+?\.\w+]], 'jo')if sock_file_end thenschema = 'unix'host = str_sub(uri, 1, sock_file_end)request_url = str_sub(uri, sock_file_end+1)elseerror('unix uri error')endelselocal uriMatch = assert(ngx_re_match(uri, [[^(?:(http[s]?):)//([^:/\?]+)(?::(\d+))?(.*)]], "jo"), "http地址格式错误")schema, host, port, request_url = unpack(uriMatch)port = port and tonumber(port) or (schema=="https" and 443 or 80)endrequest_url = (request_url and request_url ~= "") and request_url or "/"if is_debug thenngx.log(ngx.ERR, "schema = " .. schema .. " host = " .. host .. " port = " .. port .. " request_url = " .. request_url .. "\r\n")endif schema == 'unix' thenassert(sock:connect(host), "sock连接错误: "..uri)elseassert(sock:connect(host, port), "网络连接错误: "..uri) endif schema == "https" thenassert(sock:sslhandshake(nil, host, false))endreturn setmetatable({uri = uri,sock = sock,request_url = request_url,host = host,method = method_get,code = 0,req_header = setmetatable({Host = host}, header_meta),resp_header = nil,} , mt)
endfunction _M.get(uri, req_header_map)local self = conn_and_new(uri)send_header(self, req_header_map)read_code(self)read_head(self)return self
endfunction _M.post(uri, form, req_header_map)local self = conn_and_new(uri)self.method = method_postlocal body = {}for key, value in pairs(form) dotable.insert(body, ngx.escape_uri(key) .. "=" .. ngx.escape_uri(value))endlocal body_str = table.concat(body, "&")self.req_header.content_type = 'application/x-www-form-urlencoded; charset=UTF-8'self.req_header.content_length = #body_strsend_header(self, req_header_map)self.sock:send(body_str)read_code(self)read_head(self)return self
endfunction _M.post_json(uri, json_obj, req_header_map)local self = conn_and_new(uri)self.method = method_postlocal body_str = cjson.encode(json_obj)self.req_header.content_type = 'application/json'send_header(self, req_header_map)self.sock:send(body_str)read_code(self)read_head(self)return self
end---循环遍历接受的值
---@param self table
---@param callback function(chunk:string)
function _M.receive_foreach(self, callback)assert(self.code == 200, "code is " .. (self.code or 999))local sock = self.sock ---@cast sock tcpsockif type(self.resp_header) == "table" and self.resp_header.transfer_encoding == 'chunked' thenrepeatlocal chunk, err = read_chunk(self.sock)if chunk == "" thenbreakelseif chunk thencallback(chunk)enduntil not chunkelselocal content_length = 0if type(self.resp_header) == "table" and self.resp_header.content_length thencontent_length = tonumber(self.resp_header.content_length) or 0endlocal receive_len = 0repeatlocal chunk = sock:receiveany(32768)if chunk thenreceive_len = receive_len + #chunkcallback(chunk)if receive_len >= content_length thenbreakendenduntil not chunkendself.sock:close()
endfunction _M.form_data(uri, fromdata_map, req_header_map)local self = conn_and_new(uri)self.method = method_postlocal boundary = "WebKitFormBoundary" .. ngx.md5(tostring(ngx.now()))self.req_header["Content-Type"] = "multipart/form-data; boundary=----" .. boundarysend_header(self, req_header_map)for key, value in pairs(fromdata_map) doif type(value) == "string" thenlocal req_param = "------" .. boundary .. "\r\n" .."Content-Disposition: form-data; name=\"".. key .."\"\r\n\r\n" .. valueif is_debug thenngx.log(ngx.ERR, "form_data_reqparam\r\n", req_param .. "\r\n")endif self.req_header["Transfer-Encoding"] == "chunked" thensend_chunked(self.sock, req_param)send_chunked(self.sock, '\r\n')else self.sock:send(req_param)self.sock:send("\r\n")endelseif type(value) == "table" and key == "file" thenlocal req_param = "------" .. boundary .. "\r\n" .. "Content-Disposition: form-data; ".. "name=\"" .. value.name .. "\"; filename=\"" .. value.filename .. "\"\r\n".. "Content-Type: " .. value.content_type .. "\r\n\r\n"if is_debug thenngx.log(ngx.ERR, "form_data_reqparam\r\n", req_param)endif self.req_header["Transfer-Encoding"] == "chunked" thensend_chunked(self.sock, req_param)local client = value.clientclient:receive_foreach(function (chunk)send_chunked(self.sock, chunk)end)send_chunked(self.sock, '\r\n')else self.sock:send(req_param)self.sock:send(value.content)self.sock:send("\r\n")endendendlocal end_boundary = "------".. boundary .."--\r\n"if self.req_header["Transfer-Encoding"] == "chunked" thensend_chunked(self.sock, end_boundary)send_chunked(self.sock)else self.sock:send(end_boundary)endread_code(self)read_head(self)return self
endfunction _M.receive_all(self)if type(self.resp_header) == "table" and self.resp_header.transfer_encoding == 'chunked' thenif is_debug thenngx.log(ngx.ERR, "###########################receive_all using chunk###########################r\n")endlocal result = {}repeatlocal chunk, err = read_chunk(self.sock)if chunk == "" thenbreakelseif chunk thentable.insert(result, chunk)enduntil not chunkself.sock:close()return table.concat(result)endlocal receive_typereceive_type = '*a'if type(self.resp_header) == "table" and self.resp_header.content_length thenreceive_type = tonumber(self.resp_header.content_length)endlocal body, err = self.sock:receive(receive_type)self.sock:close()return body, err
endfunction _M.close(self)self.sock:close()
endreturn _M