summary refs log tree commit diff homepage
path: root/src
diff options
context:
space:
mode:
authorNguyễn Gia Phong <mcsinyx@disroot.org>2023-03-04 14:09:16 +0900
committerNguyễn Gia Phong <mcsinyx@disroot.org>2023-03-04 14:09:16 +0900
commit320feb3eae1a5eb3beea38a381ff153c5e6eb190 (patch)
tree68fa69a1dea262efbf7e3673f945d67a93ac7f26 /src
parent2be9ea60d7f171ee3375258b774191f4cf911752 (diff)
downloadhybring-320feb3eae1a5eb3beea38a381ff153c5e6eb190.tar.gz
Add applicant table
Diffstat (limited to 'src')
-rw-r--r--src/http.cr128
-rw-r--r--src/sqlite.cr227
-rw-r--r--src/xhtml.cr190
3 files changed, 545 insertions, 0 deletions
diff --git a/src/http.cr b/src/http.cr
new file mode 100644
index 0000000..63eee07
--- /dev/null
+++ b/src/http.cr
@@ -0,0 +1,128 @@
+# 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 <https://www.gnu.org/licenses/>.
+
+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: #{Path[PROGRAM_NAME].basename} 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"]
+
+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
+
+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
+
+  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?
+  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
new file mode 100644
index 0000000..1d89dc4
--- /dev/null
+++ b/src/sqlite.cr
@@ -0,0 +1,227 @@
+# SQLite3 wrapper
+# 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/>.
+
+@[Link("sqlite3")]
+lib SQLite
+  OK = 0
+  DELETE = 9
+  INSERT = 18
+  UPDATE = 23
+  ROW = 100
+  DONE = 101
+
+  type Database = Void*
+  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 close = sqlite3_close(db : Database) : LibC::Int
+  fun update_hook = sqlite3_update_hook(db : Database,
+                                        f : (Void*, LibC::Int, LibC::Char*,
+                                             LibC::Char*, Int64 ->),
+                                        arg : Void*)
+
+  fun prepare = sqlite3_prepare_v2(db : Database, query : LibC::Char*,
+                                   length : LibC::Int, stmt : Statement*,
+                                   query_tail : LibC::Char**) : LibC::Int
+  fun finalize = sqlite3_finalize(stmt : Statement) : LibC::Int
+  fun step = sqlite3_step(stmt : Statement) : Int32
+
+  fun column_int = sqlite3_column_int(stmt : Statement,
+                                      col : LibC::Int) : LibC::Int
+  fun column_int64 = sqlite3_column_int64(stmt : Statement,
+                                          col : LibC::Int) : Int64
+  fun column_text = sqlite3_column_text(stmt : Statement,
+                                        col : LibC::Int) : LibC::Char*
+end
+
+class Database
+  MIGRATIONS = [
+    ["CREATE TABLE member (id INTEGER PRIMARY KEY,
+        nick TEXT NOT NULL UNIQUE,
+        opennic TEXT NOT NULL UNIQUE,
+        icann TEXT NOT NULL UNIQUE)",
+     "CREATE TABLE applicant (id INTEGER PRIMARY KEY,
+        nick TEXT NOT NULL UNIQUE,
+        opennic TEXT NOT NULL UNIQUE,
+        icann TEXT NOT NULL UNIQUE)"]]
+  SELECT_MEMBER_ROW = "SELECT nick, opennic, icann FROM member
+    WHERE rowid = %lli"
+  SELECT_APPLICANT_ROW = "SELECT nick, opennic, icann FROM applicant
+    WHERE rowid = %lli"
+  INSERT_MEMBER = "INSERT INTO member (nick, opennic, icann)
+    VALUES (%Q, %Q, %Q)"
+  INSERT_APPLICANT = "INSERT INTO applicant (nick, opennic, icann)
+    VALUES (%Q, %Q, %Q)"
+
+  class Statement
+    def initialize(db, query)
+      Database.check SQLite.prepare db, query, LibC.strlen(query),
+                                    out @ref, out _
+    end
+
+    def step : LibC::Int
+      SQLite.step @ref
+    end
+
+    def row
+      Row.new @ref
+    end
+
+    def finalize
+      Database.check SQLite.finalize @ref
+    end
+  end
+
+  class Column
+    def initialize(stmt : SQLite::Statement, i : LibC::Int)
+      @stmt = stmt
+      @i = i
+    end
+
+    def int
+      SQLite.column_int @stmt, @i
+    end
+
+    def int64
+      SQLite.column_int64 @stmt, @i
+    end
+
+    def text
+      String.new SQLite.column_text @stmt, @i
+    end
+  end
+
+  class Row
+    def initialize(stmt : SQLite::Statement)
+      @stmt = stmt
+    end
+
+    def [](i : LibC::Int)
+      Column.new @stmt, i
+    end
+  end
+
+  def initialize(path : String, opennic, icann)
+    Database.check SQLite.open path, out @ref
+    @members = {} of Int64 => {String, String, String}
+    @applicants = {} of Int64 => {String, String, String}
+
+    self.exec "PRAGMA user_version" do |row|
+      version = row[0].int
+      raise "negative schema version" if version < 0
+      raise "schema version newer than supported" if version > MIGRATIONS.size
+      self.transact do
+        MIGRATIONS[version..].each do |migration|
+          migration.each do |sql|
+            self.exec sql do end
+          end
+        end
+        self.exec "PRAGMA user_version = %u", MIGRATIONS.size do end
+        if version == 0 # avoid out-of-bound when looking for neighbors
+          self.exec INSERT_MEMBER, "self", opennic, icann do end
+        end
+      end
+    rescue ex
+      self.finalize
+      raise ex
+    end
+
+    begin
+      self.exec "SELECT rowid, nick, opennic, icann FROM member" do |row|
+        @members[row[0].int64] = {row[1].text, row[2].text, row[3].text}
+      end
+      self.exec "SELECT rowid, nick, opennic, icann FROM applicant" do |row|
+        @applicants[row[0].int64] = {row[1].text, row[2].text, row[3].text}
+      end
+    rescue ex
+      self.finalize
+      raise ex
+    end
+    SQLite.update_hook @ref, ->(arg, action, db, table, rowid) {
+      return unless db == "main"
+      obj = arg.as Database
+      case table
+      when "member"
+        case action
+        when SQLite::DELETE
+          obj.members.delete rowid
+        when SQLite::INSERT, SQLite::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
+          obj.applicants.delete rowid
+        when SQLite::INSERT, SQLite::UPDATE
+          obj.exec SELECT_APPLICANT_ROW, rowid do |row|
+            obj.applicants[rowid] = {row[0].text, row[1].text, row[2].text}
+          end
+        end
+      end
+    }, self.as Void*
+  end
+
+  def exec(query : String, *values)
+    sql = SQLite.mprintf query, *values
+    stmt = Statement.new @ref, sql
+    SQLite.free sql
+    loop do
+      rc = stmt.step
+      case rc
+      when SQLite::ROW
+        yield stmt.row
+      when SQLite::DONE
+        break
+      else
+        Database.check rc
+      end
+    end
+  end
+
+  def transact
+    self.exec "BEGIN TRANSACTION" do end
+    yield
+    self.exec "COMMIT" do end
+  end
+
+  def members
+    @members
+  end
+
+  def applicants
+    @applicants
+  end
+
+  def add_applicant(nick, opennic, icann)
+    self.exec INSERT_APPLICANT, nick, opennic, icann do end
+  end
+
+  def finalize
+    Database.check SQLite.close @ref
+  end
+end
+
+def Database.check(rc : LibC::Int)
+  raise String.new SQLite.errstr rc if rc != SQLite::OK
+end
diff --git a/src/xhtml.cr b/src/xhtml.cr
new file mode 100644
index 0000000..ea4f87e
--- /dev/null
+++ b/src/xhtml.cr
@@ -0,0 +1,190 @@
+# XHTML generation
+# 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 "uri"
+require "xml"
+
+require "./sqlite"
+
+CSS = "
+      html {
+          margin: auto;
+          max-width: 72ch;
+      }
+      body { margin-bottom: 2rem }
+      h1, h2, h3, h4, h5, h6 { margin: 1ex 0 }
+      form {
+          display: grid;
+          grid-template-columns: max-content 1fr 0;
+      }
+      form label { margin-right: 1ch }
+      form input { margin-bottom: 1ex }
+      .error { color: red }
+    "
+
+class Page
+  def initialize(static_dir : String, static_url : String, api_url : String,
+                 db : Database)
+    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
+    @db = db
+  end
+
+  def criteria(xml)
+    xml.element "h2" do xml.text "criteria" end
+    xml.element "p" do
+      xml.text "We accept pretty much any site"
+      xml.text " served under an OpenNIC domain"
+      xml.text " meeting the following requirements."
+    end
+    xml.element "ul" do
+      xml.element "li" do xml.text "Family-friendly content" end
+      xml.element "li" do xml.text "No violence incitation or doxing" end
+      xml.element "li" do xml.text "No user tracking" end
+      xml.element "li" do
+        xml.text "ICANN counterpart accessible via HTTPS"
+      end
+      xml.element "li" do
+        xml.text "Both linking to neighboring members"
+      end
+    end
+    xml.element "p" do
+      xml.text "Note that the two sites needn't be the same, as long as"
+      xml.text " they represent the same entity, e.g. person or organization."
+      xml.text "  The clear net site is for serving visitors"
+      xml.text " without OpenNIC access or preferring secure connections."
+    end
+  end
+
+  def input(xml, name, label, hint, error, value)
+    if error
+      xml.element "span"
+      xml.element "span", class: "error" do xml.text error end
+      xml.element "br"
+    end
+    xml.element "label", for: name do xml.text label end
+    xml.element "input", name: name, placeholder: hint, value: value,
+                required: "required"
+    xml.element "br"
+  end
+
+  def form(xml, errors = {} of String => String,
+           params = {} of String => String)
+    xml.element "p" do xml.text "Then, please fill out the form below." end
+    nick = params.fetch("nick", nil)
+    xml.element "form", action: @api_url, method: "POST" do
+      input xml, "nick", "Nickname", "digits or lowercase letters",
+            errors.fetch("nick", nil), nick || ""
+      input xml, "opennic", "OpenNIC URL", "e.g. http://example.null",
+            errors.fetch("opennic", nil), params.fetch("opennic", "")
+      input xml, "icann", "ICANN URL", "e.g. https://example.net",
+            errors.fetch("icann", nil), params.fetch("icann", "")
+      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
+
+    xml.element "p" do
+      xml.text "Your application is pending for approval."
+      xml.text "  Please subscribe to this "
+      xml.element "a", href: "#{@static_url}/#{nick}.atom" do
+          xml.text "web feed"
+      end
+      xml.text " for further instructions."
+    end if nick && errors.empty?
+  end
+
+  def member(xml, nick, opennic, icann)
+    xml.element "h3" do xml.text nick end
+    xml.text "OpenNIC: "
+    xml.element "a", href: opennic do xml.text opennic end
+    xml.element "br"
+    xml.text "ICANN: "
+    xml.element "a", href: icann do xml.text icann end
+    xml.element "br"
+  end
+
+  def build(errors = {} of String => String,
+            params = {} of String => String) : String
+    XML.build encoding: "UTF-8", indent: "  " do |xml|
+      xml.element "html", xmlns: "http://www.w3.org/1999/xhtml", lang: "en" do
+        xml.element "head" do
+          xml.element "meta", name: "viewport",
+                      content: "width=device-width,initial-scale=1.0"
+          xml.element "link", rel: "icon", href: "data:,"
+          xml.element "style" do xml.text CSS end
+          xml.element "title" do xml.text "le cercle libre" end
+        end
+        xml.element "body" do
+          xml.element "h1" do xml.text "le cercle libre" end
+          xml.element "p" do
+            xml.text "The Free Circle is a web ring for sites"
+            xml.text " with an OpenNIC domain."
+          end
+          criteria xml
+
+          xml.element "h2" do xml.text "members" end
+          @db.members.each_value do |nick, opennic, icann|
+            member xml, nick, opennic, icann
+          end
+
+          xml.element "h2" do xml.text "joining" end
+          xml.element "p" do
+            xml.text "First, add "
+            xml.element "a",
+                        href: "https://html.spec.whatwg.org/#the-a-element" do
+                xml.text "anchors"
+            end
+            xml.text " refering to the following neighbors to your sites"
+            xml.text " on both OpenNIC and ICANN domains."
+            xml.text "  Your neighbors will change as people come and go,"
+            xml.text " so you will be given an Atom feed with instructions"
+            xml.text " to keep the hyperlinks up to date."
+          end
+          xml.element "ul" do
+            xml.element "li" do
+              xml.text "Left: "
+              _, left, _ = @db.members.last_value
+              xml.element "a", href: left do xml.text left end
+            end
+            xml.element "li" do
+              xml.text "Right: "
+              _, right, _ = @db.members.first_value
+              xml.element "a", href: right do xml.text right end
+            end
+          end
+          form xml, errors, params
+
+          xml.element "h2" do xml.text "applicants" end
+          @db.applicants.each_value do |nick, opennic, icann|
+            member xml, nick, opennic, icann
+          end
+        end
+      end
+    end
+  end
+
+  def write
+    File.write @static_file, self.build
+  end
+end