summary refs log tree commit diff homepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Makefile34
-rw-r--r--src/cli.cr118
-rw-r--r--src/http.cr157
-rw-r--r--src/sqlite.cr33
-rw-r--r--src/xhtml.cr4
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