From 320feb3eae1a5eb3beea38a381ff153c5e6eb190 Mon Sep 17 00:00:00 2001 From: Nguyễn Gia Phong Date: Sat, 4 Mar 2023 14:09:16 +0900 Subject: Add applicant table --- http.cr | 127 -------------------------------- sqlite.cr | 150 -------------------------------------- src/http.cr | 128 +++++++++++++++++++++++++++++++++ src/sqlite.cr | 227 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/xhtml.cr | 190 ++++++++++++++++++++++++++++++++++++++++++++++++ xhtml.cr | 185 ----------------------------------------------- 6 files changed, 545 insertions(+), 462 deletions(-) delete mode 100644 http.cr delete mode 100644 sqlite.cr create mode 100644 src/http.cr create mode 100644 src/sqlite.cr create mode 100644 src/xhtml.cr delete mode 100644 xhtml.cr diff --git a/http.cr b/http.cr deleted file mode 100644 index c1de4dc..0000000 --- a/http.cr +++ /dev/null @@ -1,127 +0,0 @@ -# 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/sqlite.cr b/sqlite.cr deleted file mode 100644 index 5dfbe4d..0000000 --- a/sqlite.cr +++ /dev/null @@ -1,150 +0,0 @@ -# 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 . - -@[Link("sqlite3")] -lib SQLite - OK = 0 - 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 prepare = sqlite3_prepare(db : Database, query : LibC::Char*, - length : LibC::Int, stmt : Statement*, - query_tail : LibC::Char**) : LibC::Int - fun step = sqlite3_step(stmt : Statement) : Int32 - fun column_text = sqlite3_column_text(stmt : Statement, - col : LibC::Int) : LibC::Char* - fun column_int = sqlite3_column_int(stmt : Statement, - col : LibC::Int) : LibC::Int - fun finalize = sqlite3_finalize(stmt : Statement) : LibC::Int - fun close = sqlite3_close(db : Database) : LibC::Int -end - -class Database - MIGRATIONS = [""] # migration #0 is reserved for schema initialization - SCHEMA = "CREATE TABLE member ( - id INTEGER PRIMARY KEY, - nick TEXT NOT NULL UNIQUE, - opennic 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) - bytes = query.to_slice - Database.check SQLite.prepare db, bytes, bytes.size, - out @ref, out @tail - 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 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 - 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 - 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 - raise ex - end - end - - 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 - when SQLite::ROW - yield stmt.row - when SQLite::DONE - break - else - Database.check rc - end - end - end - - def members - result = [] of Tuple(String, String, String) - self.exec "SELECT nick, opennic, icann FROM member" do |row| - result << {row[0].text, row[1].text, row[2].text} - end - result - 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/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 . + +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 . + +@[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 . + +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 diff --git a/xhtml.cr b/xhtml.cr deleted file mode 100644 index 4749af1..0000000 --- a/xhtml.cr +++ /dev/null @@ -1,185 +0,0 @@ -# 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 . - -require "uri" -require "xml" - -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) - 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 - - 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 - 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 - xml.element "form", action: @api_url, method: "POST" do - input xml, "nick", "Nickname", "digits and lowercase letters", - errors.fetch("nick", nil), params.fetch("nick", nil) - input xml, "opennic", "OpenNIC URL", "e.g. http://example.null", - 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" 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 - end - - def member(xml, nick, opennic, icann, feed? = false) - xml.element "p" do - xml.text "The following membership 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 feed? - 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(members, 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 "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, _ = members[-1] - xml.element "a", href: left do xml.text left end - end - xml.element "li" do - xml.text "Right: " - _, right, _ = members[0] - xml.element "a", href: right do xml.text right end - end - end - - if params.empty? # static page - form xml - elsif errors.empty? - member xml, params["nick"], params["opennic"], params["icann"], true - else - form xml, errors, params - end - - xml.element "h2" do xml.text "members" end - members.each do |nick, opennic, icann| - member xml, nick, opennic, icann - end - end - end - end - end - - def write(members) - File.write @static_file, self.build members - end -end -- cgit 1.4.1