handle partial sends
[gemini.koplugin.git] / gemini.lua
blobdc0fbbc6a2a40616affc7bf4c665d17c122aa098
1 local socket = require("socket")
2 local ssl = require("ssl")
3 local url = require("socket.url")
4 local sha256 = require("ffi/sha2").sha256
5 local _ = require("gettext")
6 local T = require("ffi/util").template
8 -- trusted_times_step: consider connections this far apart as separate enough to
9 -- reinforce TOFU-trust
10 local trusted_times_step = 3600
11 local default_port = "1965"
13 local gemini = {}
15 -- print url, stripping default port and, optionally, "gemini://"
16 function gemini.showUrl(u, strip_gemini_scheme)
17 local copy = {}
18 local purl = u
19 if type(u) == "string" then
20 purl = url.parse(u)
21 if purl == nil then
22 return u
23 end
24 end
25 for k,v in pairs(purl) do
26 copy[k] = v
27 end
28 if copy.port == "1965" then
29 copy.port = nil
30 end
31 if strip_gemini_scheme and copy.scheme == "gemini" then
32 copy.scheme = nil
33 end
34 return url.build(copy):gsub("^//","",1)
35 end
37 function gemini.upUrl(u)
38 local up = url.absolute(u, ".")
39 if up == u then
40 up = url.absolute(u, "..")
41 end
42 return up
43 end
45 -- Does any necessary percent-escaping and stripping to make a
46 -- socket.url-parsed URL compliant with RFC 3986. '%' is encoded only where it
47 -- is not part of valid percent-encoding. Modifies purl in place.
48 function gemini.escape(purl)
49 local function escape(s, unescaped)
50 if s == nil then
51 return nil
52 end
53 -- hairy encoding of '%' where not followed by two hex chars:
54 s = s:gsub("%%%%","%%25%%"):gsub("%%%%","%%25%%"):gsub("%%%f[^%%%x]","%%25"):gsub("%%(%x)%f[%X]","%%25%1")
55 return s:gsub("[^"..unescaped.."%%]", function(c)
56 return string.format("%%%x",c:byte(1))
57 end)
58 end
59 local unreserved = "%w%-._~"
60 local subdelims = "!$&'()*+,;="
61 local pchar = unreserved..subdelims..":@"
62 purl.host = escape(purl.host, unreserved..subdelims)
63 purl.path = escape(purl.path, pchar.."/")
64 purl.query = escape(purl.query, pchar.."/?")
65 purl.port = purl.port and purl.port:gsub("[^%d]","")
66 end
68 local errTexts = {
69 ["40"] = _("temporary failure"),
70 ["41"] = _("server unavailable"),
71 ["42"] = _("CGI error"),
72 ["43"] = _("proxy error"),
73 ["44"] = _("too many requests"),
74 ["50"] = _("permanent failure"),
75 ["51"] = _("resource not found"),
76 ["52"] = _("resource gone"),
77 ["53"] = _("proxy request refused"),
78 ["59"] = _("bad request"),
79 ["60"] = _("client certificate required"),
80 ["61"] = _("client certificate not authorised"),
81 ["62"] = _("client certificate not valid"),
84 local ipv6_available = socket.tcp6() ~= nil
86 -- @table trust_store Opaque table to be stored and passed to subsequent calls
88 -- @param cafile CA certificates to use for CA-based trust. nil to disable.
89 -- WARNING: as of luasec-1.3.2, CA-based trust checking seems to be thoroughly
90 -- broken and should not be used:
91 -- https://github.com/lunarmodules/luasec/issues/161
93 -- @func check_trust_cb Callback to check for trust of a public key when we've
94 -- previously TOFU-trusted another;
95 -- parameters: fullhost, digest, trusted_times, expiry, cb;
96 -- callback should call cb("always") if the new pubkey should be added to
97 -- the trust store (replacing the old one),
98 -- or cb("once") to proceed with the connection without trusting the pubkey,
99 -- or else cb() to abort connection.
101 -- @func trust_modified_cb Callback called without params when trust_store is
102 -- modified (e.g. so it can be updated on disk)
104 -- @func success_cb Callback called on 20 (success) response;
105 -- parameters: peer_qfile, purl, mimetype, params, cert_info;
106 -- peer_qfile is a quasi-file representing the body,
107 -- supporting :close, :read("l"), :read("a"), and :read(n), with read
108 -- calls returning nil,"aborted" if the connection is aborted via info_cb;
109 -- purl is the final url.parse parsed URI (after any redirects).
111 -- @func error_cb Callback called on connection error or error codes 1,4,5,6;
112 -- parameters: errText, purl, major, minor, meta;
113 -- errText describes the error; the rest are nil except on error code [1456],
114 -- then purl is the final url.parse parsed URI (after any redirects),
115 -- major and minor are the one-digit strings of the error code,
116 -- and meta is any text received in the response.
117 -- Should return nil.
119 -- @func perm_redir_cb Optional callback called on permanent redirect (31)
120 -- parameters: old_uri, new_uri
122 -- @func info_cb Optional callback to present the progress message
123 -- given as its parameter. Return false to abort the connection.
125 -- @bool confirm_new_tofu Call check_trust_cb (with trusted_times = 0)
126 -- before trusting a key for a new host.
128 -- @string proxy Optional host to proxy request via.
129 function gemini.makeRequest(uri, key, cert, cafile, trust_store, check_trust_cb, trust_modified_cb, success_cb, error_cb, perm_redir_cb, info_cb, confirm_new_tofu, proxy)
130 info_cb = info_cb or function() return true end
131 local inner
132 inner = function(u, num_redirects)
133 local purl = url.parse(u, {scheme = "gemini", port = default_port})
134 if purl.port == default_port then
135 purl.port = nil
137 if purl.scheme == "gemini" then
138 purl.fragment = nil
139 purl.userinfo = nil
140 purl.password = nil
141 if not purl.path or purl.path == "" then
142 purl.path = "/"
145 gemini.escape(purl)
146 u = url.build(purl)
148 if #u > 1024 then
149 return error_cb(T(_("URL too long: %1"), u))
152 local openssl_options = {"all", "no_tlsv1", "no_tlsv1_1"}
153 if key then
154 -- Do not allow connecting with a client certificate using TLSv1.2,
155 -- which does not encrypt the certificate.
156 -- (The gemini spec only says that we should warn the user before
157 -- doing so, but that seems not to be possible with luasec.)
158 table.insert(openssl_options, "no_tlsv1_2")
160 local context, err = ssl.newcontext({
161 mode = "client",
162 protocol = "any",
163 options = openssl_options,
164 cafile = cafile,
165 key = key,
166 certificate = cert,
168 if not context then
169 return error_cb(T(_("Error initialising TLS context: %1"), err))
172 local peer
173 if ipv6_available then
174 peer, err = socket.tcp()
175 else
176 peer, err = socket.tcp4()
178 if not peer then
179 return error_cb(T(_("Error initialising TCP: %1"), err))
182 local pretty_url = gemini.showUrl(u, true)
183 local function info(stage, fast)
184 return info_cb(T("%1\n%2...", pretty_url, stage), fast)
186 if not info("Connecting") then return end
188 local timeout = 1 -- not too short, because each call to info_cb costs 100ms
189 peer:settimeout(timeout)
191 local function with_timeouts(cb, stage, errmsg_pat, fast)
192 while true do
193 local ret, e = cb()
194 if ret then
195 return ret
197 if e == "closed" then
198 return false
199 elseif e == "timeout" or e == "wantread" or e == "wantwrite" then
200 while true do
201 if not info(stage, fast) then
202 return false
204 fast = true
205 local __, ___, sock_err = socket.select(
206 e == "wantread" and {peer} or nil,
207 (e == "timeout" or e == "wantwrite") and {peer} or nil, 1)
208 if sock_err == nil then
209 break
210 elseif sock_err ~= "timeout" then
211 return error_cb(T(error_cb, sock_err))
214 else
215 return error_cb(T(errmsg_pat, e))
219 local function get_send_cb(data)
220 local i = 0
221 return function()
222 local ret, e
223 ret, e, i = peer:send(data, i+1)
224 return ret, e
227 local function get_recv_cb(p)
228 local acc
229 return function()
230 local ret, e, partial = peer:receive(p)
231 if ret then
232 return (acc or "") .. ret
233 else
234 if partial then
235 acc = (acc or "") .. partial
237 if e == "closed" and acc then
238 return acc
240 return ret, e
245 local host, port
246 if proxy then
247 host, port = proxy:match("^([^:]*):(%d+)")
248 if not host then
249 host = proxy
250 port = default_port
252 else
253 host = purl.host
254 port = tonumber(purl.port or default_port)
256 local function get_connect_cb()
257 local done
258 return function()
259 if done then
260 return true
261 else
262 done = true
263 return peer:connect(host, port)
267 if not with_timeouts(get_connect_cb(),
268 _("Connecting"), _("Error connecting to peer: %1"), true, false, true)
269 then return end
271 peer, err = ssl.wrap(peer, context)
272 if not peer then
273 return error_cb(T(_("Error on ssl.wrap: %1"), err))
275 peer:settimeout(timeout)
276 peer:sni(host)
278 if not with_timeouts(function() return peer:dohandshake() end,
279 _("Handshaking"), _("Error on handshake: %1"))
280 then return end
282 local fullhost
283 if proxy then
284 fullhost = proxy
285 else
286 fullhost = purl.host
287 if purl.port then
288 fullhost = fullhost .. ":" .. purl.port
292 local peer_cert = peer:getpeercertificate()
293 if not peer_cert then
294 return error_cb(_("Failed to obtain peer certificate"))
296 local pk_hash = sha256(peer_cert:pubkey())
297 local digest = peer_cert:digest("sha256")
298 local expiry = peer_cert:notafter()
300 local function do_connection(trusted)
301 if not with_timeouts(get_send_cb(u.."\r\n"),
302 _("Requesting"), _("Error sending request: %1"))
303 then return end
305 local status = with_timeouts(get_recv_cb("*l"),
306 _("Receiving header"), _("Error receiving response status : %1"))
307 if not status then return end
309 local major, minor, post = status:match("^([1-6])([0-9])(.*)")
310 if not major or not minor then
311 return error_cb(T(_("Invalid response status line: %1", status:sub(1,64))))
313 local meta = post:match("^ (.*)") or ""
315 if major == "2" then
316 local token_chars = "[a-zA-Z0-9!#$%%&'*+-.^_`{|}-]" -- from RFC 2045
317 local mimetype, params_str = meta:match("^("..token_chars.."+/"..token_chars.."+)(.*)")
318 mimetype = mimetype or "text/gemini"
319 local params = {}
320 while params_str and params_str ~= "" do
321 local param, rest = params_str:match("^;%s*("..token_chars.."+=\"[^\"]*\")(.*)")
322 if not param then
323 param, rest = params_str:match("^;%s*("..token_chars.."+="..token_chars.."+)(.*)")
325 if not param then
326 -- ignore unparseable parameters
327 break
329 params_str = rest
330 table.insert(params, param)
333 local peer_qfile = {}
334 function peer_qfile:read(x)
335 x = (type(x) == "string" and x:match("^[al]$") and "*"..x)
336 or (type(x) == "number" and x)
337 return with_timeouts(get_recv_cb(x),
338 _("Receiving body"), _("Error receiving body: %1"), false, true)
340 function peer_qfile:close()
341 peer:close()
343 local cert_info = {
344 fp = digest,
345 expiry = expiry,
346 trusted_times = trusted and trusted.trusted_times or 0,
347 ca = trusted and trusted.ca
349 success_cb(peer_qfile, purl, mimetype, params, cert_info)
350 else
351 peer:close()
352 if major == "3" then
353 if num_redirects >= 5 then
354 return error_cb(_("Too many redirects."))
356 local new_uri = url.absolute(purl, meta)
357 if minor == "1" and perm_redir_cb then
358 perm_redir_cb(u, new_uri)
360 inner(new_uri, num_redirects + 1)
361 elseif tonumber(major) < 1 or tonumber(major) > 6 then
362 error_cb(_("Server returns invalid error code."))
363 else
364 local errText = errTexts[major..minor]
365 if not errText then
366 errText = errTexts[major.."0"]
368 error_cb(errText, purl, major, minor, meta)
373 local function set_trust(times)
374 trust_store[fullhost] = {
375 pk_hash = pk_hash,
376 digest = digest,
377 expiry = expiry,
378 trusted_times = 1,
379 last_trust_time = os.time(),
381 trust_modified_cb()
384 if cafile then
385 if peer:getpeerverification() then
386 local ca_cert
387 for _i, c in ipairs( peer:getpeerchain() ) do
388 ca_cert = c
390 return do_connection({ ca = ca_cert:issuer() })
394 local function trust_cb_cb(user_trust)
395 if user_trust == "always" then
396 set_trust()
397 do_connection(trust_store[fullhost])
398 elseif user_trust == "once" then
399 do_connection()
400 else
401 peer:close()
405 local trusted = trust_store[fullhost]
406 if not trusted then
407 -- Trust On First Use
408 if confirm_new_tofu then
409 check_trust_cb(fullhost, digest, 0, "", trust_cb_cb)
410 else
411 trust_cb_cb("always")
413 else
414 if trusted.pk_hash == pk_hash then
415 local now = os.time()
416 if now - trusted.last_trust_time > trusted_times_step then
417 trusted.trusted_times = trusted.trusted_times + 1
418 trusted.last_trust_time = now
419 trust_modified_cb()
421 if trusted.digest ~= digest then
422 -- Extend trust to update cert with same public key.
423 trusted.digest = digest
424 trusted.expiry = expiry
425 trust_modified_cb()
427 do_connection(trusted)
428 else
429 check_trust_cb(fullhost, digest, trusted.trusted_times, trusted.expiry, trust_cb_cb)
433 return inner(uri, 0)
435 return gemini