--[[ 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