Working version with some test
This commit is contained in:
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