# 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" HTML_HEADINGS = Set{"applicants", "criteria", "joining", "members"} MAX_CONTENT_LENGTH = 4096 MAX_NICK_LENGTH = 32 NICK_PATTERN = "[0-9a-z]{1,#{MAX_NICK_LENGTH}}" 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 class Server def initialize(cfg) @db = Database.new cfg.db, cfg.opennic_remote, cfg.icann_remote @opennic_host = URI.parse(cfg.opennic_remote).host @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 @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 content_type = context.request.headers["Content-Type"]? unless content_type && content_type == "application/x-www-form-urlencoded" next http_error context, 415 end 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 /^#{NICK_PATTERN}$/ !~ value # Manually crafted request or non-mainstream browser engine next errors["nick"] = "Must match #{NICK_PATTERN}" 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 unless OPENNIC_TLD.includes? Path[uri.host.not_nil!].extension next errors["opennic"] = "Must be under OpenNIC domain" end 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" # impractical to check for ICANN TLD if OPENNIC_TLD.includes? Path[uri.host.not_nil!].extension next errors["icann"] = "Must not be under OpenNIC domain" end 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 @db.exec "SELECT count(CASE WHEN nick = %Q THEN 1 END), count(CASE WHEN opennic = %Q THEN 1 END), count(CASE WHEN icann = %Q THEN 1 END) FROM member", params["nick"], params["opennic"], params["icann"] do |row| errors["nick"] = "Must be unique" if row[0].int > 0 errors["opennic"] = "Must be unique" if row[1].int > 0 errors["icann"] = "Must be unique" if row[2].int > 0 end if HTML_HEADINGS.includes? params["nick"] errors["nick"] = "Reserved names: #{HTML_HEADINGS.join ", "}" end if errors.empty? @db.add_applicant params["nick"], params["opennic"], params["icann"] # TODO: write feed else context.response.status_code = 400 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 end getter opennic_page getter icann_page def listen(port = 0) @db.update_hook ->(arg : Void*, action : Database::UpdateAction, db : LibC::Char*, table : LibC::Char*, rowid : Int64) { return unless db == "main" obj = arg.as Server case table when "member" # FIXME: query in a different conn obj.opennic_page.write obj.icann_page.write end }, self.as Void* yield @server.bind_tcp port @server.listen end def listening? @server.listening? end def close @server.close end end