Working version with some test
commit
0bbd8b261b
|
@ -0,0 +1,4 @@
|
||||||
|
# This file is machine-generated - editing it directly is not advised
|
||||||
|
|
||||||
|
[[Sockets]]
|
||||||
|
uuid = "6462fe0b-24de-5631-8697-dd941f90decc"
|
|
@ -0,0 +1,8 @@
|
||||||
|
name = "Gemenon"
|
||||||
|
uuid = "276b1fd0-8d54-4f56-89ad-750bff7bc49a"
|
||||||
|
authors = ["nixo <nicolo@nixo.xyz>"]
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
|
||||||
|
OpenSSL = "13378672-9967-48ef-8ae3-b2866786e7a0"
|
|
@ -0,0 +1,22 @@
|
||||||
|
module Gemenon
|
||||||
|
|
||||||
|
using OpenSSL
|
||||||
|
using Sockets
|
||||||
|
|
||||||
|
export Connection, Request, Status, Response
|
||||||
|
include("types.jl")
|
||||||
|
|
||||||
|
include("server.jl")
|
||||||
|
|
||||||
|
import Base.write
|
||||||
|
write(c::Connection, data::Vector{UInt8}) = OpenSSL.write(c.client, data)
|
||||||
|
function write(conn::Connection, s::Status)
|
||||||
|
write(conn.client,
|
||||||
|
string(s.major, s.minor, ' ', s.meta, '\r', '\n'))
|
||||||
|
end
|
||||||
|
function write(conn::Connection, r::Response)
|
||||||
|
write(conn, r.status)
|
||||||
|
write(conn.client, r.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
end # module
|
|
@ -0,0 +1,96 @@
|
||||||
|
function listen(f,
|
||||||
|
context::SSLContext,
|
||||||
|
host::Union{IPAddr, String} = Sockets.localhost,
|
||||||
|
port::Integer = 1965
|
||||||
|
;
|
||||||
|
connection_count::Ref{Int} = Ref(0),
|
||||||
|
readtimeout::Int = 0,
|
||||||
|
verbose::Bool = false)
|
||||||
|
|
||||||
|
server = Sockets.listen(Sockets.InetAddr(host, port))
|
||||||
|
verbose && @info "Listening on: $host:$port"
|
||||||
|
|
||||||
|
return listenloop(f, server, context, connection_count, readtimeout, verbose)
|
||||||
|
end
|
||||||
|
|
||||||
|
""""
|
||||||
|
Main server loop.
|
||||||
|
Accepts new tcp connections and spawns async tasks to handle them."
|
||||||
|
"""
|
||||||
|
function listenloop(f, server, context, connection_count, readtimeout, verbose)
|
||||||
|
count = 1
|
||||||
|
while isopen(server)
|
||||||
|
try
|
||||||
|
# @info "Ready to accept new connection"
|
||||||
|
io = accept(server)
|
||||||
|
if io === nothing
|
||||||
|
verbose && @warn "unable to accept new connection"
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
connection_count[] += 1
|
||||||
|
@async try
|
||||||
|
handle_connection(f, server, context, io, verbose)
|
||||||
|
catch e
|
||||||
|
if e isa Base.IOError && e.code == -54
|
||||||
|
verbose && @warn "connection reset by peer (ECONNRESET)"
|
||||||
|
else
|
||||||
|
@error exception=(e, stacktrace(catch_backtrace()))
|
||||||
|
end
|
||||||
|
finally
|
||||||
|
connection_count[] -= 1
|
||||||
|
# handle_connection is in charge of closing the underlying io
|
||||||
|
end
|
||||||
|
catch e
|
||||||
|
close(server)
|
||||||
|
if e isa InterruptException
|
||||||
|
@warn "Interrupted: listen($server)"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
rethrow(e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
count += 1
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
function handle_connection(f, server, context, io, verbose)
|
||||||
|
client = SSLClient(context, io)
|
||||||
|
while isopen(server) && isopen(io)
|
||||||
|
try
|
||||||
|
content = UInt8[]
|
||||||
|
client.io_on_read = (x) -> append!(content, x)
|
||||||
|
(ip, client_port) = Sockets.getpeername(io)
|
||||||
|
while true
|
||||||
|
if isreadable(io) && length(client.write_buf) == 0
|
||||||
|
# verbose && println("do_read")
|
||||||
|
if OpenSSL.do_sock_read(client) == -1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if iswritable(io) && length(client.write_buf) > 0
|
||||||
|
# verbose && println("do_write")
|
||||||
|
if OpenSSL.do_sock_write(client) == -1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# verbose && println("end loop")
|
||||||
|
if OpenSSL.ssl_init_finished(client)
|
||||||
|
# verbose && println("init_finished")
|
||||||
|
# TODO: add a timeout!
|
||||||
|
while isopen(server) && isopen(io) &&
|
||||||
|
(length(content) == 0 || bytesavailable(client.sock) > 0)
|
||||||
|
# println("HERE")
|
||||||
|
OpenSSL.do_sock_read(client)
|
||||||
|
end
|
||||||
|
f(Connection(server, client), Request(String(content)))
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
catch e
|
||||||
|
rethrow(e)
|
||||||
|
finally
|
||||||
|
close(client)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,136 @@
|
||||||
|
struct Status
|
||||||
|
major::Char
|
||||||
|
minor::Char
|
||||||
|
meta::String
|
||||||
|
|
||||||
|
# FIXME: error codes
|
||||||
|
function Status(ma::Char, mi::Char, meta::String)
|
||||||
|
'1' <= ma <= '6' || throw("Invalid")
|
||||||
|
'0' <= mi <= '9' || throw("Invalid")
|
||||||
|
if length(meta) > 1024
|
||||||
|
throw("Invalid")
|
||||||
|
end
|
||||||
|
new(ma, mi, meta)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Status(code::String, meta::String) = Status(code[1], code[2], meta)
|
||||||
|
Status(code::String) = Status(code[1], code[2], "")
|
||||||
|
Status(code::Int, meta::String) = Status(string(code), meta)
|
||||||
|
Status(code::Int) = Status(string(code))
|
||||||
|
|
||||||
|
struct Response
|
||||||
|
status::Status
|
||||||
|
body::String
|
||||||
|
end
|
||||||
|
Response(s::Status, b::Vector{String}) = Response(s, join(b, "\n"))
|
||||||
|
|
||||||
|
struct Request
|
||||||
|
protocol::Union{String,Nothing}
|
||||||
|
host::Union{String,Nothing}
|
||||||
|
port::Union{Int32,Nothing}
|
||||||
|
path::Union{Array{String},Nothing}
|
||||||
|
query::Union{String,Nothing}
|
||||||
|
|
||||||
|
valid::Bool
|
||||||
|
raw::Union{String,Vector{UInt8}}
|
||||||
|
|
||||||
|
"Assemble a Request object based on raw string, according to gemini specifications
|
||||||
|
(v0.14.2, July 2nd 2020)
|
||||||
|
|
||||||
|
[protocol://]host[:port][/path][?query]
|
||||||
|
Protocol: optional, default gemini://
|
||||||
|
Port: optional, default 1965
|
||||||
|
Host: required
|
||||||
|
Path: optional
|
||||||
|
Query: optional
|
||||||
|
|
||||||
|
spaces encoded with: %20"
|
||||||
|
end
|
||||||
|
|
||||||
|
function Request(data::String)
|
||||||
|
reg = r"""
|
||||||
|
((?P<protocol>[^:/]+)://)? # optional protocol
|
||||||
|
(?P<host>[^:/]+) # required host
|
||||||
|
(:(?P<port>[0-9]{1,5}))? # optional port (will match impossible 65000+ ports)
|
||||||
|
(/(?P<path>[^\?]+))? # optional path
|
||||||
|
(\?(?P<query>[^\?]+))? # optional query
|
||||||
|
"""x
|
||||||
|
s = endswith(data, "\r\n") ? data[1:end-2] : data
|
||||||
|
m = match(reg, s)
|
||||||
|
if isnothing(m)
|
||||||
|
Request(nothing, nothing, nothing, nothing, nothing, false, data)
|
||||||
|
else
|
||||||
|
Request(something(m["protocol"], "gemini"),
|
||||||
|
m["host"], parse(Int, something(m["port"], "1965")),
|
||||||
|
# normpath prevents "./path" being different from "path", and
|
||||||
|
# resolves also "../". The only problem is that "./" is
|
||||||
|
# transformed to ".", so we filter it. the raw rapresentation
|
||||||
|
# is still there, if needed
|
||||||
|
isnothing(m["path"]) ? nothing : filter!(!=("."), split(normpath(m["path"]), "/")),
|
||||||
|
isnothing(m["query"]) ? nothing : unescape(m["query"]),
|
||||||
|
true, data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
"unescape(percent_encoded_uri)::String
|
||||||
|
|
||||||
|
Replace %xx escaped chars with their single-character equivalent.
|
||||||
|
|
||||||
|
Should never throw errors"
|
||||||
|
function unescape(str)::String
|
||||||
|
occursin("%", str) || return str
|
||||||
|
len = length(str)
|
||||||
|
# Maximum length is the input string length.
|
||||||
|
# Should be len - 3 (since we already know there's at least one percent sign)
|
||||||
|
# but what if the percent is at the end? We can save only one char
|
||||||
|
# as "string%" -> saves 1, "string%2" saves 2. But %-- occupies all three
|
||||||
|
unescaped = Vector{UInt8}(undef, len)
|
||||||
|
pos = 1
|
||||||
|
chars = 0
|
||||||
|
while pos <= len
|
||||||
|
let c = str[pos]
|
||||||
|
chars += 1
|
||||||
|
if c == '%' && pos+2 <= len &&
|
||||||
|
# Keep only alphanumeric chars
|
||||||
|
'0' <= str[pos+1] <= 'f' &&
|
||||||
|
'0' <= str[pos+2] <= 'f'
|
||||||
|
unescaped[chars] = parse(UInt8, str[pos+1:pos+2], base = 16)
|
||||||
|
pos += 3
|
||||||
|
else
|
||||||
|
unescaped[chars] = c
|
||||||
|
pos += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return String(unescaped[1:chars])
|
||||||
|
end
|
||||||
|
|
||||||
|
import Base.show
|
||||||
|
function show(io::IO, r::Request)
|
||||||
|
# Useful for debugging
|
||||||
|
r.valid || begin
|
||||||
|
println(r.raw)
|
||||||
|
print(io, "[!invalid] ", String(r.raw))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
printstyled(io, r.protocol, color = :red)
|
||||||
|
print(io, "://")
|
||||||
|
printstyled(io, r.host, color = :black, bold = :true)
|
||||||
|
print(io, ":")
|
||||||
|
printstyled(io, r.port, color = :blue)
|
||||||
|
if !isnothing(r.path)
|
||||||
|
print(io, "/")
|
||||||
|
printstyled(io, join(r.path, "/"), color = :green)
|
||||||
|
end
|
||||||
|
if !isnothing(r.query)
|
||||||
|
print(io, "?")
|
||||||
|
printstyled(io, r.query, color = :yellow)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Connection
|
||||||
|
server::Sockets.TCPServer
|
||||||
|
client::SSLClient
|
||||||
|
end
|
|
@ -0,0 +1,60 @@
|
||||||
|
using Test
|
||||||
|
unescape_expected = [
|
||||||
|
"String without percent" => "String without percent",
|
||||||
|
"String%20with%20spaces" => "String with spaces",
|
||||||
|
"String%20with some spaces" => "String with some spaces",
|
||||||
|
"String%20with invalid end%" => "String with invalid end%",
|
||||||
|
"String%20with invalid end%a" => "String with invalid end%a",
|
||||||
|
"String%20with valid end%aa" => "String with valid end\xaa",
|
||||||
|
"String%20with invalid%-- data" => "String with invalid%-- data",
|
||||||
|
"Lech_Kaczy%C5%84ski" => "Lech_Kaczyński"
|
||||||
|
]
|
||||||
|
|
||||||
|
@testset "Unescape Query String" begin
|
||||||
|
for t in unescape_expected
|
||||||
|
@test unescape(t[1]) == t[2]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function test_request(input::String, params)
|
||||||
|
url = Request(input)
|
||||||
|
@test url.protocol == params[1]
|
||||||
|
@test url.host == params[2]
|
||||||
|
@test url.port == params[3]
|
||||||
|
@test url.path == params[4]
|
||||||
|
@test url.query == params[5]
|
||||||
|
end
|
||||||
|
|
||||||
|
@testset "Parse urls" begin
|
||||||
|
@testset "Complete url" begin
|
||||||
|
test_request("https://hostname:999/a?b",
|
||||||
|
("https", "hostname", 999, ["a"], "b"))
|
||||||
|
end
|
||||||
|
@testset "Defaults" begin
|
||||||
|
@testset "Default protocol" begin
|
||||||
|
test_request("hostname:999/a?b",
|
||||||
|
("gemini", "hostname", 999, ["a"], "b"))
|
||||||
|
end
|
||||||
|
@testset "Default port" begin
|
||||||
|
test_request("gemini://hostname/a?b",
|
||||||
|
("gemini", "hostname", 1965, ["a"], "b"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@testset "Paths" begin
|
||||||
|
test_request("hostname/a/b/c?b",
|
||||||
|
("gemini", "hostname", 1965, ["a", "b", "c"], "b"))
|
||||||
|
test_request("hostname/a/./c?b",
|
||||||
|
("gemini", "hostname", 1965, ["a", "c"], "b"))
|
||||||
|
# Not sure if this is expected or if we should remove empty...
|
||||||
|
test_request("hostname/a/b/c/..?b",
|
||||||
|
("gemini", "hostname", 1965, ["a", "b", ""], "b"))
|
||||||
|
end
|
||||||
|
@testset "Query" begin
|
||||||
|
test_request("hostname/a/b/c",
|
||||||
|
("gemini", "hostname", 1965, ["a", "b", "c"], nothing))
|
||||||
|
test_request("hostname/a/b/c?example",
|
||||||
|
("gemini", "hostname", 1965, ["a", "b", "c"], "example"))
|
||||||
|
test_request("hostname/a/b/c?percent%20encoding",
|
||||||
|
("gemini", "hostname", 1965, ["a", "b", "c"], "percent encoding"))
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue