diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | Makefile | 34 | ||||
-rw-r--r-- | src/cli.cr | 118 | ||||
-rw-r--r-- | src/http.cr | 157 | ||||
-rw-r--r-- | src/sqlite.cr | 33 | ||||
-rw-r--r-- | src/xhtml.cr | 4 |
6 files changed, 247 insertions, 100 deletions
diff --git a/.gitignore b/.gitignore index 272ac67..d76228f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ data/ +hybring *.ini diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..60867aa --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# Makefile for hybring +# Copyright (C) 2023 Nguyễn Gia Phong +# +# This program 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. +# +# This program 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 this program. If not, see <https://www.gnu.org/licenses/>. + +SHELL = /bin/sh +PREFIX ?= /usr/local + +.PHONY: man all clean install uninstall + +all: bin + +bin: $(wildcard src/*.cr) + crystal build -o hybring src/cli.cr + +clean: + rm hybring + +install: all + install -Dm 755 hybring ${DESTDIR}${PREFIX}/bin/hybring + +uninstall: + rm ${DESTDIR}${PREFIX}/bin/hybring diff --git a/src/cli.cr b/src/cli.cr new file mode 100644 index 0000000..5815c88 --- /dev/null +++ b/src/cli.cr @@ -0,0 +1,118 @@ +# Command-line interface +# 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 <https://www.gnu.org/licenses/>. + +require "ini" +require "option_parser" + +require "./http" + +enum Subcommand + Serve + Usage +end + +struct Configuration + @db : Path + getter db + @api : String + getter api + @opennic_local : Path + getter opennic_local + @opennic_remote : String + getter opennic_remote + @icann_local : Path + getter icann_local + @icann_remote : String + getter icann_remote + + def initialize(path) + ini = INI.parse File.read path + @db = Path[ini["general"]["db"]] + @api = ini["general"]["api"] + @opennic_local = Path[ini["opennic"]["local"]] + @opennic_remote = ini["opennic"]["remote"] + @icann_local = Path[ini["icann"]["local"]] + @icann_remote = ini["icann"]["remote"] + end +end + +subcmd = Subcommand::Usage +usage = "" +config_path, port = nil, 8910 + +parser = OptionParser.new do |parser| + banner_prefix = "usage: #{Path[PROGRAM_NAME].basename}" + parser.banner = "#{banner_prefix} [subcommand] [arguments]" + parser.on "serve", "start the HTTP server" do + subcmd = Subcommand::Serve + parser.banner = "#{banner_prefix} serve --config=PATH [--port=N]" + usage = parser.to_s + parser.on "-c PATH", "--config=PATH", + "path to configuration file (required)" do |path| + config_path = path + end + parser.on "-p N", "--port=N", "listening port (default to 8910)" do |n| + port = n.to_i + end + end + parser.on "-h", "--help", "show this help message and exit" do + puts parser + exit + end + + parser.invalid_option do |flag| + STDERR.puts "error: invalid option: #{flag}" + subcmd = Subcommand::Usage + end + parser.missing_option do |flag| + STDERR.puts "error: missing argument for #{flag}" + subcmd = Subcommand::Usage + end + parser.unknown_args do |before_dash, after_dash| + next if before_dash.empty? && after_dash.empty? + STDERR << "error: unknown arguments:" + STDERR << " " << before_dash.join " " if !before_dash.empty? + STDERR << " -- " << after_dash.join " " if !after_dash.empty? + STDERR.puts + subcmd = Subcommand::Usage + end +end + +parser.parse +cfg = case subcmd + when .serve? + unless config_path + STDERR << usage + exit 1 + end + begin + Configuration.new config_path.not_nil! + rescue ex + STDERR.puts "error: failed to read config from #{config_path}: #{ex}" + exit 1 + end + end + +case subcmd +in .serve? + server = Server.new cfg.not_nil! + server.listen port +in .usage? + STDERR.puts parser + exit 1 +end diff --git a/src/http.cr b/src/http.cr index 63eee07..1179f73 100644 --- a/src/http.cr +++ b/src/http.cr @@ -33,96 +33,91 @@ def http_error(context, status, message = nil) context.response.respond_with_status status, message end -if ARGV.empty? - puts "usage: #{Path[PROGRAM_NAME].basename} config.ini" - exit 1 -end -cfg = INI.parse File.read ARGV[0] -opennic_host = URI.parse(cfg["opennic"]["remote"]).host +class Server + def initialize(cfg) + @opennic_host = URI.parse(cfg.opennic_remote).host + @db = Database.new cfg.db, cfg.opennic_remote, cfg.icann_remote + @opennic_page = Page.new cfg.opennic_local, cfg.opennic_remote, + cfg.api, @db + @opennic_page.write + @icann_page = Page.new cfg.icann_local, cfg.icann_remote, + cfg.api, @db + @icann_page.write -Dir.mkdir_p Path[cfg["general"]["db"]].parent -db = Database.new cfg["general"]["db"], - cfg["opennic"]["remote"], cfg["icann"]["remote"] + @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 -opennic_page = Page.new cfg["opennic"]["local"], cfg["opennic"]["remote"], - cfg["general"]["api"], db -opennic_page.write -icann_page = Page.new cfg["icann"]["local"], cfg["icann"]["remote"], - cfg["general"]["api"], db -icann_page.write + 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 -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 + # Manually crafted request + next http_error context, 400, "Invalid Parameter" if invalid_param + next http_error context, 400, "Missing Parameter" unless params.size == 4 - 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" + others = @db.members.each_value.chain @db.applicants.each_value + others.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 /^[0-9a-z]+$/ !~ value - next errors["nick"] = "Must be ASCII lowercase alphanumeric" + + if errors.empty? + @db.add_applicant params["nick"], params["opennic"], params["icann"] + # TODO: write feed + else + context.response.status_code = 400 unless errors.empty? 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" + context.response.content_type = "application/xhtml+xml" + if params["host"] == @opennic_host + context.response.print @opennic_page.build errors, params + else + context.response.print @icann_page.build errors, params 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 + # TODO: schedule dynamic check 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 - - others = db.members.each_value.chain db.applicants.each_value - others.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? - db.add_applicant params["nick"], params["opennic"], params["icann"] - # TODO: write feed - else - context.response.status_code = 400 unless errors.empty? + def listen(port) + puts "Listening on http://#{@server.bind_tcp port}" + @server.listen end - context.response.content_type = "application/xhtml+xml" - if params["host"] == opennic_host - context.response.print opennic_page.build errors, params - else - context.response.print icann_page.build 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/src/sqlite.cr b/src/sqlite.cr index 1d89dc4..2975031 100644 --- a/src/sqlite.cr +++ b/src/sqlite.cr @@ -19,12 +19,15 @@ @[Link("sqlite3")] lib SQLite OK = 0 - DELETE = 9 - INSERT = 18 - UPDATE = 23 ROW = 100 DONE = 101 + enum UpdateAction : LibC::Int + Delete = 9 + Insert = 18 + Update = 23 + end + type Database = Void* type Statement = Void* @@ -35,7 +38,7 @@ lib SQLite fun open = sqlite3_open(filename : LibC::Char*, db : Database*) : LibC::Int fun close = sqlite3_close(db : Database) : LibC::Int fun update_hook = sqlite3_update_hook(db : Database, - f : (Void*, LibC::Int, LibC::Char*, + f : (Void*, UpdateAction, LibC::Char*, LibC::Char*, Int64 ->), arg : Void*) @@ -120,8 +123,9 @@ class Database end end - def initialize(path : String, opennic, icann) - Database.check SQLite.open path, out @ref + def initialize(path, opennic, icann) + Dir.mkdir_p path.parent + Database.check SQLite.open path.to_s, out @ref @members = {} of Int64 => {String, String, String} @applicants = {} of Int64 => {String, String, String} @@ -162,18 +166,18 @@ class Database case table when "member" case action - when SQLite::DELETE + in .delete? obj.members.delete rowid - when SQLite::INSERT, SQLite::UPDATE + in .insert?, .update? obj.exec SELECT_MEMBER_ROW, rowid do |row| obj.members[rowid] = {row[0].text, row[1].text, row[2].text} end end when "applicant" case action - when SQLite::DELETE + in .delete? obj.applicants.delete rowid - when SQLite::INSERT, SQLite::UPDATE + in .insert?, .update? obj.exec SELECT_APPLICANT_ROW, rowid do |row| obj.applicants[rowid] = {row[0].text, row[1].text, row[2].text} end @@ -205,13 +209,8 @@ class Database self.exec "COMMIT" do end end - def members - @members - end - - def applicants - @applicants - end + getter members + getter applicants def add_applicant(nick, opennic, icann) self.exec INSERT_APPLICANT, nick, opennic, icann do end diff --git a/src/xhtml.cr b/src/xhtml.cr index ea4f87e..c62b23a 100644 --- a/src/xhtml.cr +++ b/src/xhtml.cr @@ -38,10 +38,10 @@ CSS = " " class Page - def initialize(static_dir : String, static_url : String, api_url : String, + def initialize(static_dir : Path, static_url : String, api_url : String, db : Database) Dir.mkdir_p static_dir - @static_file = Path[static_dir] / "index.xhtml" + @static_file = static_dir / "index.xhtml" @static_url = static_url @static_host = URI.parse(static_url).host @api_url = api_url |