1 -- Copyright 2009 Steven Barth <steven@midlink.org>
2 -- Licensed to the public under the Apache License 2.0.
5 local nixio = require "nixio"
7 local ltn12 = require "luci.ltn12"
8 local util = require "luci.util"
9 local table = require "table"
10 local http = require "luci.http"
11 local date = require "luci.http.date"
12 local ip = require "luci.ip"
14 local type, pairs, ipairs, tonumber, tostring = type, pairs, ipairs, tonumber, tostring
15 local unpack, string = unpack, string
17 module "luci.httpclient"
19 function chunksource(sock, buffer)
23 local _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n")
24 while not count and #buffer <= 1024 do
25 local newblock, code = sock:recv(1024 - #buffer)
29 buffer = buffer .. newblock
30 _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n")
32 count = tonumber(count, 16)
34 return nil, -1, "invalid encoding"
35 elseif count == 0 then
37 elseif count + 2 <= #buffer - endp then
38 output = buffer:sub(endp+1, endp+count)
39 buffer = buffer:sub(endp+count+3)
42 output = buffer:sub(endp+1, endp+count)
44 if count - #output > 0 then
45 local remain, code = sock:recvall(count-#output)
49 output = output .. remain
50 count, code = sock:recvall(2)
52 count, code = sock:recvall(count+2-#buffer+endp)
63 function request_to_buffer(uri, options)
64 local source, code, msg = request_to_source(uri, options)
71 source, code = ltn12.pump.all(source, (ltn12.sink.table(output)))
77 return table.concat(output)
80 function request_to_source(uri, options)
81 local status, response, buffer, sock = request_raw(uri, options)
83 return status, response, buffer
84 elseif status ~= 200 and status ~= 206 then
85 return nil, status, buffer
88 if response.headers["Transfer-Encoding"] == "chunked" then
89 return chunksource(sock, buffer)
91 return ltn12.source.cat(ltn12.source.string(buffer), sock:blocksource())
95 function parse_url(uri)
96 local url, rest, tmp = {}, nil, nil
98 url.scheme, rest = uri:match("^(%w+)://(.+)$")
99 if not (url.scheme and rest) then
103 url.auth, tmp = rest:match("^([^@]+)@(.+)$")
104 if url.auth and tmp then
108 url.host, tmp = rest:match("^%[([0-9a-fA-F:]+)%](.*)$")
109 if url.host and tmp then
110 url.ip6addr = ip.IPv6(url.host)
111 if not url.ip6addr then
114 url.host = string.format("[%s]", url.ip6addr:string())
117 url.host, tmp = rest:match("^(%d+%.%d+%.%d+%.%d+)(.*)$")
118 if url.host and tmp then
119 url.ipaddr = ip.IPv4(url.host)
120 if not url.ipaddr then
123 url.host = url.ipaddr:string()
126 url.host, tmp = rest:match("^([0-9a-zA-Z%.%-]+)(.*)$")
127 if url.host and tmp then
135 url.port, tmp = rest:match("^:(%d+)(.*)$")
136 if url.port and tmp then
137 url.port = tonumber(url.port)
139 if url.port < 1 or url.port > 65535 then
144 if url.scheme == "http" then
145 url.port = url.port or 80
146 url.default_port = (url.port == 80)
147 elseif url.scheme == "https" then
148 url.port = url.port or 443
149 url.default_port = (url.port == 443)
164 function request_raw(uri, options)
165 options = options or {}
167 if options.params then
168 uri = uri .. '?' .. http.urlencode_params(options.params)
171 local url = parse_url(uri)
174 return nil, -1, "unable to parse URI"
177 if url.scheme ~= "http" and url.scheme ~= "https" then
178 return nil, -2, "protocol not supported"
181 options.depth = options.depth or 10
182 local headers = options.headers or {}
183 local protocol = options.protocol or "HTTP/1.1"
184 headers["User-Agent"] = headers["User-Agent"] or "LuCI httpclient 0.1"
186 if headers.Connection == nil then
187 headers.Connection = "close"
190 if url.auth and not headers.Authorization then
191 headers.Authorization = "Basic " .. nixio.bin.b64encode(url.auth)
194 local addr = tostring(url.ip6addr or url.ipaddr or url.host)
195 local sock, code, msg = nixio.connect(addr, url.port)
197 return nil, code, msg
200 sock:setsockopt("socket", "sndtimeo", options.sndtimeo or 15)
201 sock:setsockopt("socket", "rcvtimeo", options.rcvtimeo or 15)
203 if url.scheme == "https" then
204 local tls = options.tls_context or nixio.tls()
205 sock = tls:create(sock)
206 local stat, code, error = sock:connect()
208 return stat, code, error
212 -- Pre assemble fixes
213 if protocol == "HTTP/1.1" then
214 headers.Host = headers.Host or
215 (url.default_port and url.host or string.format("%s:%d", url.host, url.port))
218 if type(options.body) == "table" then
219 options.body = http.urlencode_params(options.body)
222 if type(options.body) == "string" then
223 headers["Content-Length"] = headers["Content-Length"] or #options.body
224 headers["Content-Type"] = headers["Content-Type"] or
225 "application/x-www-form-urlencoded"
226 options.method = options.method or "POST"
229 if type(options.body) == "function" then
230 options.method = options.method or "POST"
233 if options.cookies then
234 local cookiedata = {}
235 for _, c in ipairs(options.cookies) do
236 local cdo = c.flags.domain
237 local cpa = c.flags.path
238 if (cdo == url.host or cdo == "."..url.host or url.host:sub(-#cdo) == cdo)
239 and (cpa == url.path or cpa == "/" or cpa .. "/" == url.path:sub(#cpa+1))
240 and (not c.flags.secure or url.scheme == "https")
242 cookiedata[#cookiedata+1] = c.key .. "=" .. c.value
245 if headers["Cookie"] then
246 headers["Cookie"] = headers["Cookie"] .. "; " .. table.concat(cookiedata, "; ")
248 headers["Cookie"] = table.concat(cookiedata, "; ")
253 local message = {(options.method or "GET") .. " " .. url.path .. " " .. protocol}
255 for k, v in pairs(headers) do
256 if type(v) == "string" or type(v) == "number" then
257 message[#message+1] = k .. ": " .. v
258 elseif type(v) == "table" then
259 for i, j in ipairs(v) do
260 message[#message+1] = k .. ": " .. j
265 message[#message+1] = ""
266 message[#message+1] = ""
269 sock:sendall(table.concat(message, "\r\n"))
271 if type(options.body) == "string" then
272 sock:sendall(options.body)
273 elseif type(options.body) == "function" then
274 local res = {options.body(sock)}
281 -- Create source and fetch response
282 local linesrc = sock:linesource()
283 local line, code, error = linesrc()
287 return nil, code, error
290 local protocol, status, msg = line:match("^([%w./]+) ([0-9]+) (.*)")
294 return nil, -3, "invalid response magic: " .. line
298 status = line, headers = {}, code = 0, cookies = {}, uri = uri
302 while line and line ~= "" do
303 local key, val = line:match("^([%w-]+)%s?:%s?(.*)")
304 if key and key ~= "Status" then
305 if type(response.headers[key]) == "string" then
306 response.headers[key] = {response.headers[key], val}
307 elseif type(response.headers[key]) == "table" then
308 response.headers[key][#response.headers[key]+1] = val
310 response.headers[key] = val
318 return nil, -4, "protocol error"
322 if response.headers["Set-Cookie"] then
323 local cookies = response.headers["Set-Cookie"]
324 for _, c in ipairs(type(cookies) == "table" and cookies or {cookies}) do
325 local cobj = cookie_parse(c)
326 cobj.flags.path = cobj.flags.path or url.path:match("(/.*)/?[^/]*")
327 if not cobj.flags.domain or cobj.flags.domain == "" then
328 cobj.flags.domain = url.host
329 response.cookies[#response.cookies+1] = cobj
331 local hprt, cprt = {}, {}
333 -- Split hostnames and save them in reverse order
334 for part in url.host:gmatch("[^.]*") do
335 table.insert(hprt, 1, part)
337 for part in cobj.flags.domain:gmatch("[^.]*") do
338 table.insert(cprt, 1, part)
342 for i, part in ipairs(cprt) do
343 -- If parts are different and no wildcard
344 if hprt[i] ~= part and #part ~= 0 then
347 -- Wildcard on invalid position
348 elseif hprt[i] ~= part and #part == 0 then
349 if i ~= #cprt or (#hprt ~= i and #hprt+1 ~= i) then
356 if valid and #cprt > 1 and #cprt[2] > 0 then
357 response.cookies[#response.cookies+1] = cobj
364 response.code = tonumber(status)
365 if response.code and options.depth > 0 then
366 if (response.code == 301 or response.code == 302 or response.code == 307)
367 and response.headers.Location then
368 local nuri = response.headers.Location or response.headers.location
370 return nil, -5, "invalid reference"
372 if not nuri:match("^%w+://") then
373 nuri = url.default_port and string.format("%s://%s%s", url.scheme, url.host, nuri)
374 or string.format("%s://%s:%d%s", url.scheme, url.host, url.port, nuri)
377 options.depth = options.depth - 1
378 if options.headers then
379 options.headers.Host = nil
383 return request_raw(nuri, options)
387 return response.code, response, linesrc(true)..sock:readall(), sock
390 function cookie_parse(cookiestr)
391 local key, val, flags = cookiestr:match("%s?([^=;]+)=?([^;]*)(.*)")
396 local cookie = {key = key, value = val, flags = {}}
397 for fkey, fval in flags:gmatch(";%s?([^=;]+)=?([^;]*)") do
399 if fkey == "expires" then
400 fval = date.to_unix(fval:gsub("%-", " "))
402 cookie.flags[fkey] = fval
408 function cookie_create(cookie)
409 local cookiedata = {cookie.key .. "=" .. cookie.value}
411 for k, v in pairs(cookie.flags) do
412 if k == "expires" then
413 v = date.to_http(v):gsub(", (%w+) (%w+) (%w+) ", ", %1-%2-%3 ")
415 cookiedata[#cookiedata+1] = k .. ((#v > 0) and ("=" .. v) or "")
418 return table.concat(cookiedata, "; ")