From 2be9ea60d7f171ee3375258b774191f4cf911752 Mon Sep 17 00:00:00 2001 From: Nguyễn Gia Phong Date: Thu, 23 Feb 2023 01:28:29 +0900 Subject: Handle requests to both ICANN and OpenNIC hosts --- .gitignore | 3 +- http.cr | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ hybring.cr | 109 ---------------------------------------------------- sqlite.cr | 17 +++++++-- xhtml.cr | 14 ++++++- 5 files changed, 153 insertions(+), 117 deletions(-) create mode 100644 http.cr delete mode 100644 hybring.cr diff --git a/.gitignore b/.gitignore index 66533b2..272ac67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -static/ -*.db +data/ *.ini diff --git a/http.cr b/http.cr new file mode 100644 index 0000000..c1de4dc --- /dev/null +++ b/http.cr @@ -0,0 +1,127 @@ +# HTTP server +# Copyright (C) 2023 Nguyễn Gia Phong +# +# This file if part of hybring. +# +# Hybring is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Hybring is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with hybring. If not, see . + +require "http/server" +require "ini" +require "uri" + +require "./sqlite" +require "./xhtml" + +MAX_CONTENT_LENGTH = 4096 +MAX_NICK_LENGTH = 32 +OPENNIC_TLD = Set{".bbs", ".chan", ".cyb", ".dyn", ".epic", + ".geek", ".gopher", ".indy", ".libre", ".neo", + ".null", ".o", ".oss", ".oz", ".parody", ".pirate"} + +def http_error(context, status, message = nil) + context.response.respond_with_status status, message +end + +if ARGV.empty? + puts "usage: #{PROGRAM_NAME} config.ini" + exit 1 +end +cfg = INI.parse File.read ARGV[0] +opennic_host = URI.parse(cfg["opennic"]["remote"]).host + +Dir.mkdir_p Path[cfg["general"]["db"]].parent +db = Database.new cfg["general"]["db"], + cfg["opennic"]["remote"], cfg["icann"]["remote"] +members = db.members + +opennic_page = Page.new cfg["opennic"]["local"], cfg["opennic"]["remote"], + cfg["general"]["api"] +opennic_page.write members +icann_page = Page.new cfg["icann"]["local"], cfg["icann"]["remote"], + cfg["general"]["api"] +icann_page.write members + +server = HTTP::Server.new do |context| + # Manually crafted request + next http_error context, 405 if context.request.method != "POST" + content_length = context.request.content_length + next http_error context, 411 unless content_length + next http_error context, 413 if content_length > MAX_CONTENT_LENGTH + + errors = {} of String => String + params = {} of String => String + invalid_param = false + body = context.request.body.try &.gets_to_end || "" + URI::Params.parse body do |key, value| + params[key] = value + case key + when "nick" + if value.size > MAX_NICK_LENGTH + next errors["nick"] = "Must be within ${MAX_NICK_LENGTH} characters" + end + if /^[0-9a-z]+$/ !~ value + next errors["nick"] = "Must be ASCII lowercase alphanumeric" + end + when "opennic" + uri = URI.parse value + next errors["opennic"] = "Must be absolute URL" unless uri.absolute? + if uri.scheme != "http" && uri.scheme != "https" + next errors["opennic"] = "Must be HTTP/S" + end + host = uri.host + unless OPENNIC_TLD.includes? Path[host].extension + next errors["opennic"] = "Must be under OpenNIC domain" + end if host + when "icann" + uri = URI.parse value + next errors["icann"] = "Must be absolute URL" unless uri.absolute? + next errors["icann"] = "Must be HTTPS" unless uri.scheme == "https" + host = uri.host # impractical to check for ICANN TLD + if OPENNIC_TLD.includes? Path[host].extension + next errors["icann"] = "Must not be under OpenNIC domain" + end if host + when "host" + else + break invalid_param = true + end + end + + # Manually crafted request + next http_error context, 400, "Invalid Parameter" if invalid_param + next http_error context, 400, "Missing Parameter" unless params.size == 4 + + members.each do |nick, opennic, icann| + errors["nick"] = "Must be unique" if nick == params["nick"] + errors["opennic"] = "Must be unique" if opennic == params["opennic"] + errors["icann"] = "Must be unique" if icann == params["icann"] + end + + if errors.empty? + # TODO: write feed + else + context.response.status_code = 400 unless errors.empty? + end + context.response.content_type = "application/xhtml+xml" + if params["host"] == opennic_host + context.response.print opennic_page.build members, errors, params + else + context.response.print icann_page.build members, errors, params + end + # TODO: schedule dynamic check +end + +# TODO: support Unix socket +address = server.bind_tcp cfg["general"]["port"].to_i +puts "Listening on http://#{address}" +server.listen diff --git a/hybring.cr b/hybring.cr deleted file mode 100644 index 13ba893..0000000 --- a/hybring.cr +++ /dev/null @@ -1,109 +0,0 @@ -# Hybrid web ring server -# Copyright (C) 2023 Nguyễn Gia Phong -# -# This file if part of hybring. -# -# Hybring is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Hybring is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with hybring. If not, see . - -require "http/server" -require "ini" -require "uri" - -require "./sqlite" -require "./xhtml" - -MAX_CONTENT_LENGTH = 4096 -OPENNIC_TLD = Set{".bbs", ".chan", ".cyb", ".dyn", ".epic", - ".geek", ".gopher", ".indy", ".libre", ".neo", - ".null", ".o", ".oss", ".oz", ".parody", ".pirate"} - -def http_error(context, status, message = nil) - context.response.respond_with_status status, message -end - -if ARGV.empty? - puts "usage: #{PROGRAM_NAME} config.ini" - exit 1 -end -cfg = INI.parse File.read ARGV[0] -db = Database.new cfg["path"]["db"] -members = db.members -page = Page.new cfg["url"]["static"], cfg["url"]["api"] -File.write Path[cfg["path"]["static"]] / "index.xhtml", page.build members - -server = HTTP::Server.new do |context| - # Manually crafted request - next http_error context, 405 if context.request.method != "POST" - content_length = context.request.content_length - next http_error context, 411 unless content_length - next http_error context, 413 if content_length > MAX_CONTENT_LENGTH - - body = context.request.body.try &.gets_to_end || "" - errors = {} of String => String - params = {} of String => String - invalid_param = false - URI::Params.parse body do |key, value| - params[key] = value - case key - when "nick" - if /^[0-9a-z]+$/ !~ value - next errors["nick"] = "Must be ASCII lowercase alphanumeric" - end - when "opennic" - uri = URI.parse value - next errors["opennic"] = "Must be absolute URL" unless uri.absolute? - if uri.scheme != "http" && uri.scheme != "https" - next errors["opennic"] = "Must be HTTP/S" - end - host = uri.host - unless OPENNIC_TLD.includes? Path[host].extension - next errors["opennic"] = "Must be under OpenNIC domain" - end if host - when "icann" - uri = URI.parse value - next errors["icann"] = "Must be absolute URL" unless uri.absolute? - next errors["icann"] = "Must be HTTPS" unless uri.scheme == "https" - host = uri.host # impractical to check for ICANN TLD - if OPENNIC_TLD.includes? Path[host].extension - next errors["icann"] = "Must not be under OpenNIC domain" - end if host - else - break invalid_param = true - end - end - - # Manually crafted request - next http_error context, 400, "Invalid Parameter" if invalid_param - next http_error context, 400, "Missing Parameter" unless params.size == 3 - - members.each do |nick, opennic, icann| - errors["nick"] = "Must be unique" if nick == params["nick"] - errors["opennic"] = "Must be unique" if opennic == params["opennic"] - errors["icann"] = "Must be unique" if icann == params["icann"] - end - - if errors.empty? - # TODO: write feed - else - context.response.status_code = 400 unless errors.empty? - end - context.response.content_type = "application/xhtml+xml" - context.response.print page.build members, errors, params - # TODO: schedule dynamic check -end - -# TODO: support Unix socket -address = server.bind_tcp cfg["listen"]["port"].to_i -puts "Listening on http://#{address}" -server.listen diff --git a/sqlite.cr b/sqlite.cr index 4cca647..5dfbe4d 100644 --- a/sqlite.cr +++ b/sqlite.cr @@ -26,6 +26,9 @@ lib SQLite type Statement = Void* fun errstr = sqlite3_errstr(rc : LibC::Int) : LibC::Char* + fun mprintf = sqlite3_mprintf(format : LibC::Char*, ...) : LibC::Char* + fun free = sqlite3_free(format : Void*, ...) + fun open = sqlite3_open(filename : LibC::Char*, db : Database*) : LibC::Int fun prepare = sqlite3_prepare(db : Database, query : LibC::Char*, length : LibC::Int, stmt : Statement*, @@ -45,7 +48,9 @@ class Database id INTEGER PRIMARY KEY, nick TEXT NOT NULL UNIQUE, opennic TEXT NOT NULL UNIQUE, - icann TEXT NOT NULL UNIQUE)"; + icann TEXT NOT NULL UNIQUE)" + INSERT_MEMBER = "INSERT INTO member (nick, opennic, icann) + VALUES (%Q, %Q, %Q)" class Statement def initialize(db, query) @@ -92,7 +97,7 @@ class Database end end - def initialize(path : String) + def initialize(path : String, opennic, icann) Database.check SQLite.open path, out @ref self.exec "PRAGMA user_version" do |row| version = row[0].int @@ -101,6 +106,8 @@ class Database if version == 0 self.exec SCHEMA do end self.exec "PRAGMA user_version = #{MIGRATIONS.size}" do end + # Avoid out-of-bound when looking for neighbors. + self.exec INSERT_MEMBER, "self", opennic, icann do end end rescue ex self.finalize @@ -108,8 +115,10 @@ class Database end end - def exec(query : String) - stmt = Statement.new @ref, query + def exec(query : String, *values) + sql = SQLite.mprintf query, *values + stmt = Statement.new @ref, String.new sql + SQLite.free sql loop do rc = stmt.step case rc diff --git a/xhtml.cr b/xhtml.cr index 7e21309..4749af1 100644 --- a/xhtml.cr +++ b/xhtml.cr @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Affero General Public License # along with hybring. If not, see . +require "uri" require "xml" CSS = " @@ -35,8 +36,11 @@ CSS = " " class Page - def initialize(static_url : String, api_url : String) + def initialize(static_dir : String, static_url : String, api_url : String) + Dir.mkdir_p static_dir + @static_file = Path[static_dir] / "index.xhtml" @static_url = static_url + @static_host = URI.parse(static_url).host @api_url = api_url end @@ -87,7 +91,9 @@ class Page errors.fetch("opennic", nil), params.fetch("opennic", nil) input xml, "icann", "ICANN URL", "e.g. https://example.net", errors.fetch("icann", nil), params.fetch("icann", nil) - xml.element "span" + xml.element "span" do + xml.element "input", type: "hidden", name: "host", value: @static_host + end xml.element "input", type: "submit", value: "Let me in!" xml.element "br" end @@ -172,4 +178,8 @@ class Page end end end + + def write(members) + File.write @static_file, self.build members + end end -- cgit 1.4.1