2020-02-10 17:18:21 -05:00

398 lines
12 KiB
Lua

--[[
LuaJIT-Request
Lucien Greathouse
Wrapper for LuaJIT-cURL for easy HTTP(S) requests.
Copyright (c) 2016 Lucien Greathouse
This software is provided 'as-is', without any express
or implied warranty. In no event will the authors be held
liable for any damages arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, andto alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must
not be misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
]]
local path = (...):gsub("%.init$", ""):match("%.?(.-)$") .. "."
local ffi = require("ffi")
local curl = require(path .. "luajit-curl")
local request
local function url_encode(str)
if (str) then
str = str:gsub("\n", "\r\n")
str = str:gsub("([^%w %-%_%.%~])", function(c)
return string.format ("%%%02X", string.byte(c))
end)
str = str:gsub(" ", "%%20")
end
return str
end
local function cookie_encode(str, name)
str = str:gsub("[,;%s]", "")
if (name) then
str = str:gsub("=", "")
end
return str
end
local auth_map = {
BASIC = ffi.cast("long", curl.CURLAUTH_BASIC),
DIGEST = ffi.cast("long", curl.CURLAUTH_DIGEST),
NEGOTIATE = ffi.cast("long", curl.CURLAUTH_NEGOTIATE)
}
local errors = {
unknown = 0,
timeout = 1,
connect = 2,
resolve_host = 3
}
local code_map = {
[curl.CURLE_OPERATION_TIMEDOUT] = {
errors.timeout, "Connection timed out"
},
[curl.CURLE_COULDNT_RESOLVE_HOST] = {
errors.resolve_host, "Couldn't resolve host"
},
[curl.CURLE_COULDNT_CONNECT] = {
errors.connect, "Couldn't connect to host"
}
}
request = {
error = errors,
version = "2.4.0",
version_major = 2,
version_minor = 4,
version_patch = 0,
--[[
Send an HTTP(S) request to the URL at 'url' using the HTTP method 'method'.
Use the 'args' parameter to optionally configure the request:
- method: HTTP method to use. Defaults to "GET", but can be any HTTP verb like "POST" or "PUT"
- headers: Dictionary of additional HTTP headers to send with request
- data: Dictionary or string to send as request body
- cookies: Dictionary table of cookies to send
- timeout: How long to wait for the connection to be made before giving up
- allow_redirects: Whether or not to allow redirection. Defaults to true
- body_stream_callback: A method to call with each piece of the response body.
- header_stream_callback: A method to call with each piece of the resulting header.
- transfer_info_callback: A method to call with transfer progress data.
- auth_type: Authentication method to use. Defaults to "none", but can also be "basic", "digest" or "negotiate"
- username: A username to use with authentication. 'auth_type' must also be specified.
- password: A password to use with authentication. 'auth_type' must also be specified.
- files: A dictionary of file names to their paths on disk to upload via stream.
If both body_stream_callback and header_stream_callback are defined, a boolean true will be returned instead of the following object.
The return object is a dictionary with the following members:
- code: The HTTP status code the response gave. Will not exist if header_stream_callback is defined above.
- body: The body of the response. Will not exist if body_stream_callback is defined above.
- headers: A dictionary of headers and their values. Will not exist if header_stream_callback is defined above.
- headers_raw: A raw string containing the actual headers the server sent back. Will not exist if header_stream_callback is defined above.
- set_cookies: A dictionary of cookies given by the "Set-Cookie" header from the server. Will not exist if the server did not set any cookies.
If an error occured, false will be returned along with a curl error code and a message.
]]
send = function(url, args)
local handle = curl.curl_easy_init()
local header_chunk
local out_buffer
local headers_buffer
args = args or {}
local callbacks = {}
local gc_handles = {}
curl.curl_easy_setopt(handle, curl.CURLOPT_URL, url)
curl.curl_easy_setopt(handle, curl.CURLOPT_SSL_VERIFYPEER, 1)
curl.curl_easy_setopt(handle, curl.CURLOPT_SSL_VERIFYHOST, 2)
if (args.data and type(args.data) ~= "table") then
local default_content_type = "application/octet-stream"
if (not args.headers) then
args.headers = {
["content-type"] = default_content_type
}
else
local has_content_type = false
for header_name, _ in pairs(args.headers) do
if header_name:lower() == "content-type" then
has_content_type = true
break
end
end
if not has_content_type then
args.headers["content-type"] = default_content_type
end
end
end
if (args.method) then
local method = string.upper(tostring(args.method))
if (method == "GET") then
curl.curl_easy_setopt(handle, curl.CURLOPT_HTTPGET, 1)
elseif (method == "POST") then
curl.curl_easy_setopt(handle, curl.CURLOPT_POST, 1)
args.data = args.data or "" -- https://github.com/curl/curl/issues/1625#issuecomment-312456910
else
curl.curl_easy_setopt(handle, curl.CURLOPT_CUSTOMREQUEST, method)
end
end
if (args.headers) then
for key, value in pairs(args.headers) do
header_chunk = curl.curl_slist_append(header_chunk, tostring(key) .. ":" .. tostring(value))
end
curl.curl_easy_setopt(handle, curl.CURLOPT_HTTPHEADER, header_chunk)
end
if (args.auth_type) then
local auth = string.upper(tostring(args.auth_type))
if (auth_map[auth]) then
curl.curl_easy_setopt(handle, curl.CURLOPT_HTTPAUTH, auth_map[auth])
curl.curl_easy_setopt(handle, curl.CURLOPT_USERNAME, tostring(args.username))
curl.curl_easy_setopt(handle, curl.CURLOPT_PASSWORD, tostring(args.password or ""))
elseif (auth ~= "NONE") then
error("Unsupported authentication type '" .. auth .. "'")
end
end
if (args.body_stream_callback) then
local callback = ffi.cast("curl_callback", function(data, size, nmeb, user)
args.body_stream_callback(ffi.string(data, size * nmeb))
return size * nmeb
end)
table.insert(callbacks, callback)
curl.curl_easy_setopt(handle, curl.CURLOPT_WRITEFUNCTION, callback)
else
out_buffer = {}
local callback = ffi.cast("curl_callback", function(data, size, nmeb, user)
table.insert(out_buffer, ffi.string(data, size * nmeb))
return size * nmeb
end)
table.insert(callbacks, callback)
curl.curl_easy_setopt(handle, curl.CURLOPT_WRITEFUNCTION, callback)
end
if (args.header_stream_callback) then
local callback = ffi.cast("curl_callback", function(data, size, nmeb, user)
args.header_stream_callback(ffi.string(data, size * nmeb))
return size * nmeb
end)
table.insert(callbacks, callback)
curl.curl_easy_setopt(handle, curl.CURLOPT_HEADERFUNCTION, callback)
else
headers_buffer = {}
local callback = ffi.cast("curl_callback", function(data, size, nmeb, user)
table.insert(headers_buffer, ffi.string(data, size * nmeb))
return size * nmeb
end)
table.insert(callbacks, callback)
curl.curl_easy_setopt(handle, curl.CURLOPT_HEADERFUNCTION, callback)
end
if (args.transfer_info_callback) then
local callback = ffi.cast("curl_xferinfo_callback", function(client, dltotal, dlnow, ultotal, ulnow)
args.transfer_info_callback(tonumber(dltotal), tonumber(dlnow), tonumber(ultotal), tonumber(ulnow))
return 0
end)
table.insert(callbacks, callback)
curl.curl_easy_setopt(handle, curl.CURLOPT_NOPROGRESS, 0)
curl.curl_easy_setopt(handle, curl.CURLOPT_XFERINFOFUNCTION, callback)
end
if (args.follow_redirects == nil) then
curl.curl_easy_setopt(handle, curl.CURLOPT_FOLLOWLOCATION, true)
else
curl.curl_easy_setopt(handle, curl.CURLOPT_FOLLOWLOCATION, not not args.follow_redirects)
end
if (args.data) then
if (type(args.data) == "table") then
local buffer = {}
for key, value in pairs(args.data) do
table.insert(buffer, ("%s=%s"):format(url_encode(key), url_encode(value)))
end
curl.curl_easy_setopt(handle, curl.CURLOPT_POSTFIELDS, table.concat(buffer, "&"))
else
curl.curl_easy_setopt(handle, curl.CURLOPT_POSTFIELDS, tostring(args.data))
end
end
local post
if (args.files) then
post = ffi.new("struct curl_httppost*[1]")
local lastptr = ffi.new("struct curl_httppost*[1]")
for key, value in pairs(args.files) do
local file = ffi.new("char[?]", #value, value)
table.insert(gc_handles, file)
local res = curl.curl_formadd(
post, lastptr,
ffi.new("int", curl.CURLFORM_COPYNAME), key,
ffi.new("int", curl.CURLFORM_FILE), file,
ffi.new("int", curl.CURLFORM_END)
)
end
curl.curl_easy_setopt(handle, curl.CURLOPT_HTTPPOST, post[0])
end
-- Enable the cookie engine
curl.curl_easy_setopt(handle, curl.CURLOPT_COOKIEFILE, "")
if (args.cookies) then
local cookie_out
if (type(args.cookies) == "table") then
local buffer = {}
for key, value in pairs(args.cookies) do
table.insert(buffer, ("%s=%s"):format(cookie_encode(key, true), cookie_encode(value)))
end
cookie_out = table.concat(buffer, "; ")
else
cookie_out = tostring(args.cookies)
end
curl.curl_easy_setopt(handle, curl.CURLOPT_COOKIE, cookie_out)
end
if (tonumber(args.timeout)) then
curl.curl_easy_setopt(handle, curl.CURLOPT_CONNECTTIMEOUT, tonumber(args.timeout))
end
local code = curl.curl_easy_perform(handle)
if (code ~= curl.CURLE_OK) then
local num = tonumber(code)
if (code_map[num]) then
return false, code_map[num][1], code_map[num][2]
end
return false, request.error.unknown, "Unknown error", num
end
local out
if (out_buffer or headers_buffer) then
local headers, status, parsed_headers, raw_cookies, set_cookies
if (headers_buffer) then
-- In case we got multiple responses (e.g. 100 - Continue or 302 Redirects)
-- we want to only return the last response
local start_index = 1
for i, resp_line in ipairs(headers_buffer) do
if resp_line:match("^HTTP/(.-)%s+(%d+)%s+(.+)\r\n$") then
start_index = i
end
end
local last_request_headers = {}
for i = start_index, #headers_buffer do
table.insert(last_request_headers, headers_buffer[i])
end
headers = table.concat(last_request_headers)
status = tonumber(headers:match("%s+(%d+)%s+"))
parsed_headers = {}
for key, value in headers:gmatch("\n([^:]+): *([^\r\n]*)") do
parsed_headers[key] = value
end
end
local cookielist = ffi.new("struct curl_slist*[1]")
curl.curl_easy_getinfo(handle, curl.CURLINFO_COOKIELIST, cookielist)
if cookielist[0] ~= nil then
raw_cookies, set_cookies = {}, {}
local cookielist = ffi.gc(cookielist[0], curl.curl_slist_free_all)
local cookie = cookielist
repeat
local raw = ffi.string(cookie[0].data)
table.insert(raw_cookies, raw)
local domain, subdomains, path, secure, expiration, name, value = raw:match("^(.-)\t(.-)\t(.-)\t(.-)\t(.-)\t(.-)\t(.*)$")
set_cookies[name] = value
cookie = cookie[0].next
until cookie == nil
end
out = {
body = table.concat(out_buffer),
headers = parsed_headers,
raw_cookies = raw_cookies,
set_cookies = set_cookies,
code = status,
raw_headers = headers
}
else
out = true
end
curl.curl_easy_cleanup(handle)
curl.curl_slist_free_all(header_chunk)
if (post) then
curl.curl_formfree(post[0])
end
gc_handles = {}
for i, v in ipairs(callbacks) do
v:free()
end
return out
end,
init = function()
curl.curl_global_init(curl.CURL_GLOBAL_ALL)
end,
close = function()
curl.curl_global_cleanup()
end
}
request.init()
return request