c866be6855cc59dc1e7409dec42fa54e10a8db92
[project/luci.git] / libs / httpclient / luasrc / httpclient.lua
1 --[[
2 LuCI - Lua Development Framework
3
4 Copyright 2009 Steven Barth <steven@midlink.org>
5
6 Licensed under the Apache License, Version 2.0 (the "License");
7 you may not use this file except in compliance with the License.
8 You may obtain a copy of the License at
9
10         http://www.apache.org/licenses/LICENSE-2.0
11
12 $Id$
13 ]]--
14
15 require "nixio.util"
16 local nixio = require "nixio"
17
18 local ltn12 = require "luci.ltn12"
19 local util = require "luci.util"
20 local table = require "table"
21 local http = require "luci.http.protocol"
22 local date = require "luci.http.protocol.date"
23
24 local type, pairs, ipairs, tonumber = type, pairs, ipairs, tonumber
25 local unpack = unpack
26
27 module "luci.httpclient"
28
29 function chunksource(sock, buffer)
30         buffer = buffer or ""
31         return function()
32                 local output
33                 local _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n")
34                 while not count and #buffer <= 1024 do
35                         local newblock, code = sock:recv(1024 - #buffer)
36                         if not newblock then
37                                 return nil, code
38                         end
39                         buffer = buffer .. newblock  
40                         _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n")
41                 end
42                 count = tonumber(count, 16)
43                 if not count then
44                         return nil, -1, "invalid encoding"
45                 elseif count == 0 then
46                         return nil
47                 elseif count + 2 <= #buffer - endp then
48                         output = buffer:sub(endp+1, endp+count)
49                         buffer = buffer:sub(endp+count+3)
50                         return output
51                 else
52                         output = buffer:sub(endp+1, endp+count)
53                         buffer = ""
54                         if count - #output > 0 then
55                                 local remain, code = sock:recvall(count-#output)
56                                 if not remain then
57                                         return nil, code
58                                 end
59                                 output = output .. remain
60                                 count, code = sock:recvall(2)
61                         else
62                                 count, code = sock:recvall(count+2-#buffer+endp)
63                         end
64                         if not count then
65                                 return nil, code
66                         end
67                         return output
68                 end
69         end
70 end
71
72
73 function request_to_buffer(uri, options)
74         local source, code, msg = request_to_source(uri, options)
75         local output = {}
76         
77         if not source then
78                 return nil, code, msg
79         end
80         
81         source, code = ltn12.pump.all(source, (ltn12.sink.table(output)))
82         
83         if not source then
84                 return nil, code
85         end
86         
87         return table.concat(output)
88 end
89
90 function request_to_source(uri, options)
91         local status, response, buffer, sock = request_raw(uri, options)
92         if not status then
93                 return status, response, buffer
94         elseif status ~= 200 and status ~= 206 then
95                 return nil, status, buffer
96         end
97         
98         if response.headers["Transfer-Encoding"] == "chunked" then
99                 return chunksource(sock, buffer)
100         else
101                 return ltn12.source.cat(ltn12.source.string(buffer), sock:blocksource())
102         end
103 end
104
105 --
106 -- GET HTTP-resource
107 --
108 function request_raw(uri, options)
109         options = options or {}
110         local pr, auth, host, port, path
111         
112         if uri:find("%[") then
113                 if uri:find("@") then
114                         pr, auth, host, port, path = uri:match("(%w+)://(.+)@(%b[]):?([0-9]*)(.*)")
115                         host = host:sub(2,-2)
116                 else
117                         pr, host, port, path = uri:match("(%w+)://(%b[]):?([0-9]*)(.*)")
118                         host = host:sub(2,-2)
119                 end
120         else
121                 if uri:find("@") then
122                         pr, auth, host, port, path =
123                                 uri:match("(%w+)://(.+)@([%w-.]+):?([0-9]*)(.*)")
124                 else
125                         pr, host, port, path = uri:match("(%w+)://([%w-.]+):?([0-9]*)(.*)")
126                 end
127         end
128
129         if not host then
130                 return nil, -1, "unable to parse URI"
131         end
132         
133         if pr ~= "http" and pr ~= "https" then
134                 return nil, -2, "protocol not supported"
135         end
136         
137         port = #port > 0 and port or (pr == "https" and 443 or 80)
138         path = #path > 0 and path or "/"
139         
140         options.depth = options.depth or 10
141         local headers = options.headers or {}
142         local protocol = options.protocol or "HTTP/1.1"
143         headers["User-Agent"] = headers["User-Agent"] or "LuCI httpclient 0.1"
144         
145         if headers.Connection == nil then
146                 headers.Connection = "close"
147         end
148         
149         if auth and not headers.Authorization then
150                 headers.Authorization = "Basic " .. nixio.bin.b64encode(auth)
151         end
152
153         local sock, code, msg = nixio.connect(host, port)
154         if not sock then
155                 return nil, code, msg
156         end
157         
158         sock:setsockopt("socket", "sndtimeo", options.sndtimeo or 15)
159         sock:setsockopt("socket", "rcvtimeo", options.rcvtimeo or 15)
160         
161         if pr == "https" then
162                 local tls = options.tls_context or nixio.tls()
163                 sock = tls:create(sock)
164                 local stat, code, error = sock:connect()
165                 if not stat then
166                         return stat, code, error
167                 end
168         end
169
170         -- Pre assemble fixes   
171         if protocol == "HTTP/1.1" then
172                 headers.Host = headers.Host or host
173         end
174         
175         if type(options.body) == "table" then
176                 options.body = http.urlencode_params(options.body)
177         end
178
179         if type(options.body) == "string" then
180                 headers["Content-Length"] = headers["Content-Length"] or #options.body
181                 headers["Content-Type"] = headers["Content-Type"] or
182                         "application/x-www-form-urlencoded"
183                 options.method = options.method or "POST"
184         end
185         
186         if type(options.body) == "function" then
187                 options.method = options.method or "POST"
188         end
189
190         -- Assemble message
191         local message = {(options.method or "GET") .. " " .. path .. " " .. protocol}
192         
193         for k, v in pairs(headers) do
194                 if type(v) == "string" or type(v) == "number" then
195                         message[#message+1] = k .. ": " .. v
196                 elseif type(v) == "table" then
197                         for i, j in ipairs(v) do
198                                 message[#message+1] = k .. ": " .. j
199                         end
200                 end
201         end
202         
203         if options.cookies then
204                 for _, c in ipairs(options.cookies) do
205                         local cdo = c.flags.domain
206                         local cpa = c.flags.path
207                         if   (cdo == host or cdo == "."..host or host:sub(-#cdo) == cdo) 
208                          and (cpa == path or cpa == "/" or cpa .. "/" == path:sub(#cpa+1))
209                          and (not c.flags.secure or pr == "https")
210                         then
211                                 message[#message+1] = "Cookie: " .. c.key .. "=" .. c.value
212                         end 
213                 end
214         end
215         
216         message[#message+1] = ""
217         message[#message+1] = ""
218         
219         -- Send request
220         sock:sendall(table.concat(message, "\r\n"))
221         
222         if type(options.body) == "string" then
223                 sock:sendall(options.body)
224         elseif type(options.body) == "function" then
225                 local res = {options.body(sock)}
226                 if not res[1] then
227                         sock:close()
228                         return unpack(res)
229                 end
230         end
231         
232         -- Create source and fetch response
233         local linesrc = sock:linesource()
234         local line, code, error = linesrc()
235         
236         if not line then
237                 sock:close()
238                 return nil, code, error
239         end
240         
241         local protocol, status, msg = line:match("^([%w./]+) ([0-9]+) (.*)")
242         
243         if not protocol then
244                 sock:close()
245                 return nil, -3, "invalid response magic: " .. line
246         end
247         
248         local response = {
249                 status = line, headers = {}, code = 0, cookies = {}, uri = uri
250         }
251         
252         line = linesrc()
253         while line and line ~= "" do
254                 local key, val = line:match("^([%w-]+)%s?:%s?(.*)")
255                 if key and key ~= "Status" then
256                         if type(response.headers[key]) == "string" then
257                                 response.headers[key] = {response.headers[key], val}
258                         elseif type(response.headers[key]) == "table" then
259                                 response.headers[key][#response.headers[key]+1] = val
260                         else
261                                 response.headers[key] = val
262                         end
263                 end
264                 line = linesrc()
265         end
266         
267         if not line then
268                 sock:close()
269                 return nil, -4, "protocol error"
270         end
271         
272         -- Parse cookies
273         if response.headers["Set-Cookie"] then
274                 local cookies = response.headers["Set-Cookie"]
275                 for _, c in ipairs(type(cookies) == "table" and cookies or {cookies}) do
276                         local cobj = cookie_parse(c)
277                         cobj.flags.path = cobj.flags.path or path:match("(/.*)/?[^/]*")
278                         if not cobj.flags.domain or cobj.flags.domain == "" then
279                                 cobj.flags.domain = host
280                                 response.cookies[#response.cookies+1] = cobj
281                         else
282                                 local hprt, cprt = {}, {}
283                                 
284                                 -- Split hostnames and save them in reverse order
285                                 for part in host:gmatch("[^.]*") do
286                                         table.insert(hprt, 1, part)
287                                 end
288                                 for part in cobj.flags.domain:gmatch("[^.]*") do
289                                         table.insert(cprt, 1, part)
290                                 end
291                                 
292                                 local valid = true
293                                 for i, part in ipairs(cprt) do
294                                         -- If parts are different and no wildcard
295                                         if hprt[i] ~= part and #part ~= 0 then
296                                                 valid = false
297                                                 break
298                                         -- Wildcard on invalid position
299                                         elseif hprt[i] ~= part and #part == 0 then
300                                                 if i ~= #cprt or (#hprt ~= i and #hprt+1 ~= i) then
301                                                         valid = false
302                                                         break
303                                                 end
304                                         end
305                                 end
306                                 -- No TLD cookies
307                                 if valid and #cprt > 1 and #cprt[2] > 0 then
308                                         response.cookies[#response.cookies+1] = cobj
309                                 end
310                         end
311                 end
312         end
313         
314         -- Follow 
315         response.code = tonumber(status)
316         if response.code and options.depth > 0 then
317                 if response.code == 301 or response.code == 302 or response.code == 307
318                  and response.headers.Location then
319                         local nuri = response.headers.Location or response.headers.location
320                         if not nuri then
321                                 return nil, -5, "invalid reference"
322                         end
323                         if not nuri:find("https?://") then
324                                 nuri = pr .. "://" .. host .. ":" .. port .. nuri
325                         end
326                         
327                         options.depth = options.depth - 1
328                         if options.headers then
329                                 options.headers.Host = nil
330                         end
331                         sock:close()
332                         
333                         return request_raw(nuri, options)
334                 end
335         end
336         
337         return response.code, response, linesrc(true), sock
338 end
339
340 function cookie_parse(cookiestr)
341         local key, val, flags = cookiestr:match("%s?([^=;]+)=?([^;]*)(.*)")
342         if not key then
343                 return nil
344         end
345
346         local cookie = {key = key, value = val, flags = {}}
347         for fkey, fval in flags:gmatch(";%s?([^=;]+)=?([^;]*)") do
348                 fkey = fkey:lower()
349                 if fkey == "expires" then
350                         fval = date.to_unix(fval:gsub("%-", " "))
351                 end
352                 cookie.flags[fkey] = fval
353         end
354
355         return cookie
356 end
357
358 function cookie_create(cookie)
359         local cookiedata = {cookie.key .. "=" .. cookie.value}
360
361         for k, v in pairs(cookie.flags) do
362                 if k == "expires" then
363                         v = date.to_http(v):gsub(", (%w+) (%w+) (%w+) ", ", %1-%2-%3 ")
364                 end
365                 cookiedata[#cookiedata+1] = k .. ((#v > 0) and ("=" .. v) or "")
366         end
367
368         return table.concat(cookiedata, "; ")
369 end