From 0bbd8b261ba8f97f0773c90bc4f0d29ae57f515b Mon Sep 17 00:00:00 2001 From: nixo Date: Fri, 23 Oct 2020 11:13:35 +0200 Subject: [PATCH] Working version with some test --- Manifest.toml | 4 ++ Project.toml | 8 +++ src/Gemenon.jl | 22 ++++++++ src/server.jl | 96 ++++++++++++++++++++++++++++++++++ src/statuses.jl | 0 src/types.jl | 136 ++++++++++++++++++++++++++++++++++++++++++++++++ test/URIs.jl | 60 +++++++++++++++++++++ 7 files changed, 326 insertions(+) create mode 100644 Manifest.toml create mode 100644 Project.toml create mode 100644 src/Gemenon.jl create mode 100644 src/server.jl create mode 100644 src/statuses.jl create mode 100644 src/types.jl create mode 100644 test/URIs.jl diff --git a/Manifest.toml b/Manifest.toml new file mode 100644 index 0000000..e6e180b --- /dev/null +++ b/Manifest.toml @@ -0,0 +1,4 @@ +# This file is machine-generated - editing it directly is not advised + +[[Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..bf0e021 --- /dev/null +++ b/Project.toml @@ -0,0 +1,8 @@ +name = "Gemenon" +uuid = "276b1fd0-8d54-4f56-89ad-750bff7bc49a" +authors = ["nixo "] +version = "0.1.0" + +[deps] +Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" +OpenSSL = "13378672-9967-48ef-8ae3-b2866786e7a0" diff --git a/src/Gemenon.jl b/src/Gemenon.jl new file mode 100644 index 0000000..9839ba5 --- /dev/null +++ b/src/Gemenon.jl @@ -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 diff --git a/src/server.jl b/src/server.jl new file mode 100644 index 0000000..32dfdf2 --- /dev/null +++ b/src/server.jl @@ -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 diff --git a/src/statuses.jl b/src/statuses.jl new file mode 100644 index 0000000..e69de29 diff --git a/src/types.jl b/src/types.jl new file mode 100644 index 0000000..ec2185d --- /dev/null +++ b/src/types.jl @@ -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[^:/]+)://)? # optional protocol + (?P[^:/]+) # required host + (:(?P[0-9]{1,5}))? # optional port (will match impossible 65000+ ports) + (/(?P[^\?]+))? # optional path + (\?(?P[^\?]+))? # 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 diff --git a/test/URIs.jl b/test/URIs.jl new file mode 100644 index 0000000..21be6c7 --- /dev/null +++ b/test/URIs.jl @@ -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