summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorthe lemons <citrons@mondecitronne.com>2023-04-07 03:29:49 -0500
committerthe lemons <citrons@mondecitronne.com>2023-04-07 03:29:49 -0500
commit1becf46aea2ab522c82212d1c74732a77b3142a9 (patch)
treed2861a0584dc68b6271408fddf0cb03c4eefc76d
intial commit
-rw-r--r--.gitignore2
-rw-r--r--account.lua139
-rwxr-xr-xauth.cgi385
-rw-r--r--cgi.lua162
-rw-r--r--citrine.lua172
-rw-r--r--db.lua29
-rw-r--r--forms.lua115
-rw-r--r--html.lua99
-rw-r--r--md5.lua427
-rw-r--r--pbkdf2.lua35
-rw-r--r--service.lua60
-rw-r--r--static/apiopage.pngbin0 -> 8783 bytes
-rw-r--r--static/auth.dot21
-rw-r--r--static/bg.jpegbin0 -> 31989 bytes
-rw-r--r--static/bubble-tail.svg4
-rw-r--r--static/citrine.css363
-rw-r--r--static/gear.pngbin0 -> 1937 bytes
-rw-r--r--static/graph.pngbin0 -> 109407 bytes
-rw-r--r--static/graph.svg206
-rw-r--r--static/headers/default.pngbin0 -> 9610 bytes
-rw-r--r--static/headers/error.pngbin0 -> 8700 bytes
-rw-r--r--static/qmark.pngbin0 -> 5031 bytes
-rw-r--r--static/question.svg13
-rw-r--r--static/shadow.pngbin0 -> 163 bytes
24 files changed, 2232 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..28bf5ec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+debug/*
+data/*
diff --git a/account.lua b/account.lua
new file mode 100644
index 0000000..c51c3b2
--- /dev/null
+++ b/account.lua
@@ -0,0 +1,139 @@
+local digest = require 'openssl.digest'
+local rand = require 'openssl.rand'
+local pbkdf2 = require 'pbkdf2'
+local json = require 'json'
+local db = require 'db'
+
+local M = {}
+M.__index = M
+
+local function to_text(bytes)
+ return (tostring(bytes):gsub(".", function(b)
+ return ("%02x"):format(b:byte())
+ end))
+end
+
+function M.uid_of(txn, email)
+ local emails = txn:open("emails", true)
+ return emails[email]
+end
+
+function M.lookup_username(txn, username)
+ local usernames = txn:open("usernames", true)
+ return usernames[username]
+end
+
+local function account(txn, uid)
+ local udb = txn:open("users", true)
+ local user = setmetatable({txn = txn, uid = uid}, M)
+ return user
+end
+
+function M.get_user(txn, uid)
+ local user = account(txn, uid)
+ if user:get "exists" then
+ return user
+ end
+end
+
+function M.create_user(txn, email, password, username)
+ if M.uid_of(txn, email) then
+ return
+ end
+ local uid = to_text(rand.bytes(8))
+ local user = account(txn, uid)
+ assert(not user:get "exists")
+ user:set("exists", "exists")
+ user:set_email(email)
+ user:set_password(password)
+ user:set_username(username)
+ local tokens = txn:open("tokens", true)
+ tokens[uid] = uid
+ return user
+end
+
+function M.user_from_email(txn, email)
+ local uid = M.uid_of(txn, email)
+ if uid then return M.get_user(txn, uid) end
+end
+
+function M:set(property, value)
+ local users = self.txn:open("users", true)
+ users[self.uid.."."..property] = value
+ return value
+end
+
+function M:get(property)
+ local users = self.txn:open("users", true)
+ return users[self.uid.."."..property]
+end
+
+function M:set_password(password)
+ local params = {pbkdf2(password)}
+ self:set("password", json.encode {
+ hash = params[1], params = {table.unpack(params, 2)},
+ })
+end
+
+function M:check_password(password)
+ local pass = json.decode(self:get "password")
+ local hash = pbkdf2(password, table.unpack(pass.params))
+ return hash == pass.hash
+end
+
+function M:set_email(email)
+ local uid = M.uid_of(self.txn, email)
+ assert(not uid or uid == self.uid)
+ local emails = self.txn:open("emails", true)
+ if self:get "email" then
+ emails[self:get "email"] = nil
+ end
+ emails[email] = self.uid
+ self:set("email", email)
+end
+
+function M:set_username(username)
+ local uid = M.lookup_username(self.txn, username)
+ assert(not uid or uid == self.uid)
+ local usernames = self.txn:open("usernames", true)
+ if self:get "username" then
+ usernames[self:get "username"] = nil
+ end
+ usernames[username] = self.uid
+ self:set("username", username)
+end
+
+function M:issue_token(service)
+ local tokens = self.txn:open("tokens", true)
+ local token = to_text(rand.bytes(8))
+ tokens[self.uid.."."..token] = service
+ return token
+end
+
+function M:check_token(token)
+ local tokens = self.txn:open("tokens", true)
+ if tokens[self.uid.."."..token] then
+ return tokens[self.uid.."."..token]
+ end
+end
+
+function M:revoke_token(token)
+ local tokens = self.txn:open("tokens", true)
+ tokens[self.uid.."."..token] = nil
+end
+
+function M:revoke_tokens()
+ local tokens = self.txn:open("tokens", true)
+ local to_revoke = {}
+ for token in db.next, tokens, self.uid do
+ if not token:match("^"..self.uid..".") then
+ break
+ end
+ table.insert(to_revoke, token)
+ end
+ for _, v in ipairs(to_revoke) do
+ tokens[v] = nil
+ end
+end
+
+return M
diff --git a/auth.cgi b/auth.cgi
new file mode 100755
index 0000000..b6513d6
--- /dev/null
+++ b/auth.cgi
@@ -0,0 +1,385 @@
+#!/usr/bin/env lua5.3
+
+local db = require 'db'
+local account = require 'account'
+local service = require 'service'
+local cgi = require 'cgi'
+local html = require 'html'
+local citrine = require 'citrine'
+local forms = require 'forms'
+
+local get, post = {}, {}
+
+local function validate_username(txn, messages, username)
+ local good = true
+ if not forms.reasonable_string(username) then
+ good = false
+ table.insert(messages, "invalid username")
+ end
+ if account.lookup_username(txn, username) then
+ good = false
+ table.insert(messages, "this username is already in use.")
+ end
+ return good
+end
+
+local function validate_email(txn, messages, email)
+ local good = true
+ if not forms.reasonable_string(email) then
+ good = false
+ table.insert(messages, "invalid email address")
+ end
+ if account.uid_of(txn, email) then
+ good = false
+ table.insert(messages, "this email is already in use")
+ end
+ return good
+end
+
+
+local function register(form)
+ local messages = {}
+ if not form.password or not form.email or not form.username then
+ return false, {"required fields missing"}
+ end
+
+ local txn = db.txn(true)
+
+ form.email = form.email:match '^%s*(.*)%s*$'
+ form.username = form.username:match '^%s*(.*)%s*$'
+ validate_email(txn, messages, form.email)
+ validate_username(txn, messages, form.username)
+ if form.password ~= form.confirm_password then
+ table.insert(messages, "passwords do not match")
+ end
+ if form.coinstar ~= "08:00" then
+ table.insert(messages,
+ "please answer the question correctly: when's 8am?")
+ end
+
+ if #messages > 0 then
+ return false, messages
+ else
+ local user = assert(account.create_user(
+ txn, form.email, form.password, form.username))
+ local token = user:issue_token "auth.citrons.xyz"
+ txn:commit()
+ return true, user.uid, token
+ end
+end
+
+local function login(form)
+ local txn = db.txn(true)
+ local user = account.user_from_email(txn, form.email)
+ if not user then
+ return false, {"email not found"}
+ end
+ if not user:check_password(form.password) then
+ return false, {"the password entered was incorrect."}
+ end
+ local token = user:issue_token "auth.citrons.xyz"
+ txn:commit()
+ return true, user.uid, token
+end
+
+local function logged_in(uid, token, redirect)
+ local set_cookie = {
+ "token="..token.."; Path=/; Secure; HttpOnly; Max-Age=99999999999",
+ "uid="..uid.."; Path=/; Secure; HttpOnly; Max-Age=99999999999",
+ }
+ return {
+ status = "302 See Other", location = redirect, set_cookie = set_cookie,
+ content_type = "text/html"
+ }, html.render_doc(function()
+ html.a({href = redirect}, "click here to be redirected...")
+ end)
+end
+
+local function get_user(txn, cookie)
+ if cookie and cookie.uid and cookie.token then
+ local user = account.get_user(txn, cookie.uid)
+ if user and user:check_token(cookie.token) == "auth.citrons.xyz" then
+ return user
+ end
+ end
+end
+
+local function user_json(user)
+ local u = {}
+ u.uid = user.uid
+ u.username = user:get "username"
+ u.ttl = 800
+ return u
+end
+
+local function login_page(login_messages, register_messages, values)
+ return citrine.page {title = "log in", function()
+ citrine.h1 "log in to auth.citrons.xyz"
+ html.div({class = 'login-box'}, function()
+ forms.login(nil, login_messages, values)
+ forms.register(nil, register_messages, values)
+ end)
+ end}
+end
+
+local function auth_page(cookie, service_domain, meta)
+ local sv, err = service.lookup(service_domain)
+ if not sv then
+ cgi.abort(503, [[
+ unable to connect to the requested service. it may be
+ misconfigured, unavailable, or antimemetic. the error was:
+ ]]..err)
+ end
+ local txn = db.txn(true)
+ local user = get_user(txn, cookie)
+ local token = user:issue_token(service_domain)
+ local username = user:get "username"
+ txn:commit()
+ local endpoint = sv.endpoint or "login"
+ if not endpoint:match '^%w+://' then
+ endpoint = "https://"..service_domain.."/"..endpoint
+ end
+ if endpoint:match '^https?://%.*auth%.+citrons%.+xyz%.*' then
+ cgi.abort(503, "the requested service's endpoint is invalid.")
+ end
+ return citrine.page {title = "authenticate", function()
+ citrine.h1 "authenticate with auth.citrons.xyz"
+ html.div({class = 'box'}, function()
+ html.h2(function()
+ html.text "connect to "
+ html.i(sv.name or service_domain)
+ html.text "?"
+ end)
+ html.p(function()
+ html.text "you will be identified as "
+ html.b(username)
+ html.text " to "
+ html.b(service_domain)
+ html.text "."
+ end)
+ forms.connect(user.uid, token, meta, endpoint)
+ end)
+ end}
+end
+
+get["^/login$"] = function(info)
+ local txn = db.txn()
+ local user = get_user(txn, info.cookie)
+ txn:commit()
+ if not user then
+ return 'text/html', login_page()
+ elseif info.query.service then
+ return 'text/html', auth_page(
+ info.cookie, info.query.service, info.query.meta)
+ else
+ cgi.redirect(302, "/account")
+ end
+end
+
+post["^/login$"] = function(info)
+ if not info.form then cgi.abort(400) end
+ local action = info.form.action
+ local success, result, token
+ if action == 'register' then
+ success, result, token = register(info.form)
+ elseif action == 'login' then
+ success, result, token = login(info.form)
+ else
+ cgi.abort(400)
+ end
+ if not success then
+ return 'text/html', login_page(
+ action == 'login' and result,
+ action == 'register' and result,
+ info.form)
+ else
+ local redirect = "/login"
+ local service = info.query.service
+ if service then
+ redirect = redirect.."?service="..html.url_encode(service)
+ end
+ return logged_in(result, token, redirect)
+ end
+end
+
+local function account_page(user, messages)
+ return citrine.page {title = "user profile", function()
+ citrine.h1 "user profile"
+ html.div({class = 'box user-settings'}, function()
+ html.h2(user:get "username")
+ forms.user_settings(user, messages)
+ end)
+ end}
+end
+
+get["^/account$"] = function(info)
+ local txn = db.txn()
+ local user = get_user(txn, info.cookie)
+ if not user then
+ cgi.redirect(302, "/login")
+ end
+ return 'text/html', account_page(user)
+end
+
+post["^/account$"] = function(info)
+ local txn = db.txn(true)
+ local user = get_user(txn, info.cookie)
+ if not user then
+ cgi.redirect(302, "/login")
+ end
+ local form = info.form
+ if not form then
+ cgi.abort(400)
+ end
+ if form.logout then
+ if form.everywhere then
+ user:revoke_tokens()
+ else
+ user:revoke_token(info.cookie.token)
+ end
+ txn:commit()
+ cgi.redirect(302, "/login")
+ end
+ local messages = {}
+ if form.email and form.email ~= user:get "email" then
+ form.email = form.email:match '^%s*(.*)%s*$'
+ if validate_email(txn, messages, form.email) then
+ user:set_email(form.email)
+ end
+ end
+ if form.username and form.username ~= user:get "username" then
+ form.username = form.username:match '^%s*(.*)%s*$'
+ if validate_username(txn, messages, form.username) then
+ user:set_username(form.username)
+ end
+ end
+ if form.password then
+ if not user:check_password(form.password) then
+ table.insert(messages, "password supplied is incorrect")
+ end
+ if not form.new_password
+ or form.new_password ~= form.confirm_password then
+ table.insert(messages, "passwords do not match")
+ end
+ if #messages == 0 then
+ user:set_password(form.new_password)
+ txn:commit()
+ return 'text/html', citrine.page{function()
+ citrine.h1 "password change"
+ html.p "your password has been changed successfully."
+ html.p(function()
+ html.a({href = "/account"}, "return to profile page")
+ end)
+ end}
+ end
+ end
+ txn:commit()
+ user.txn = db.txn()
+ return 'text/html', account_page(user, messages)
+end
+
+get["^/api/user/(%w+)$"] = function(info, uid)
+ local txn = db.txn()
+ local user = account.get_user(txn, uid)
+ if not user then cgi.abort(404) end
+ local j = user_json(user)
+ return 'application/json', json.encode(j)
+end
+
+post["^/api/user/(%w+)/auth/([^/]+)$"] = function(info, uid, service)
+ local txn = db.txn()
+ local user = account.get_user(txn, uid)
+ if not user then cgi.abort(404) end
+ local j = {}
+ if not info.form or not info.form.token then
+ cgi.abort(400)
+ end
+ if user:check_token(info.form.token) == service then
+ j.valid = true
+ j.user = user_json(user)
+ j.ttl = 800
+ else
+ j.valid = false
+ j.ttl = 9999999
+ end
+ return 'application/json', json.encode(j)
+end
+
+post["^/api/user/(%w+)/invalidate$"] = function(info, uid)
+ local txn = db.txn(true)
+ local user = account.get_user(txn, uid)
+ if not user then cgi.abort(404) end
+ if not info.form or not info.form.token then
+ cgi.abort(400)
+ end
+ if user:check_token(info.form.token) then
+ user:revoke_token(info.form.token)
+ end
+
+ local j = user_json(user)
+ txn:commit()
+ return 'application/json', json.encode(j)
+end
+
+get["^/$"] = function()
+ return 'text/html', citrine.page {function()
+ citrine.h1 "account services at auth.citrons.xyz"
+ html.p(function()
+ html.text [[
+ experience authentication. auth.citrons.xyz provides a single
+ account for arbitrary webthings. this service is mostly
+ intended for webthings made by me
+ (]]
+ html.a({href = "https://citrons.xyz"}, "citrons")
+ html.text ")."
+ end)
+ html.p(function()
+ html.text [[
+ it is quite easy to integrate auth.citrons.xyz into your own
+ services. it is however pertinent that you understand that you
+ do so
+ ]]
+ html.em "at your own risk"
+ html.text [[!
+ I make no guarantees of the security and integrity of this
+ service. upon comprehending this, you can use the
+ ]]
+ html.a({href = "/docs"}, "API")
+ html.text [[
+ to incorporate the auth.citrons.xyz account system into your
+ webthing (or non-webthing).
+ ]]
+ end)
+ html.img {
+ class = 'the-graph', src = '/static/graph.svg',
+ alt = 'graph of world domination'
+ }
+ html.h2 "privacy policy"
+ html.p [[
+ auth.citrons.xyz retains your supplied account details, such as
+ your email and username. it also may output your IP address into a
+ random log file. no data other than this (such as usage patterns,
+ etc) is collected.
+ ]]
+ html.p [[
+ in the event that "hacker" is "able t0 get [my] database
+ credentials and extract [my] entire database and m0ve the
+ informati0n t0 an offshore server", they might be able to send you
+ mean emails. oh no.
+ ]]
+ end}
+end
+
+local function serve(handlers)
+ return function(info, path)
+ for patt, handler in pairs(handlers) do
+ local matches = {path:match(patt)}
+ if matches[1] then
+ return handler(info, table.unpack(matches))
+ end
+ end
+ cgi.abort(404)
+ end
+end
+
+cgi.serve({GET = serve(get), POST = serve(post)}, citrine.error)
diff --git a/cgi.lua b/cgi.lua
new file mode 100644
index 0000000..942b26f
--- /dev/null
+++ b/cgi.lua
@@ -0,0 +1,162 @@
+local html = require 'html'
+local md5 = require 'md5'
+
+local M = {}
+
+M.env = os.getenv
+
+local header_written
+local function header(h, v)
+ header_written = true
+ assert(not h:match "[: \n]")
+ assert(h:lower() == h)
+ assert(not (tostring(v)):match "\n")
+ print(("%s: %s"):format(h, v))
+end
+
+local function serve(headers, body)
+ assert(not header_written)
+ if type(headers) == 'string' then
+ headers = {content_type = headers}
+ end
+ headers.etag = ('"%s"'):format(md5.sumhexa(body))
+ headers.content_length = #body
+ for h, v in pairs(headers) do
+ if type(v) == 'table' then
+ for _, v in ipairs(v) do
+ header(h:gsub("_", "-"), v)
+ end
+ else
+ header(h:gsub("_", "-"), v)
+ end
+ end
+ print()
+ io.stdout:write(body)
+end
+
+function M.abort(...)
+ coroutine.yield(M.abort, ...)
+end
+
+function M.redirect(...)
+ coroutine.yield(M.redirect, ...)
+end
+
+function M.decode_path(path)
+ local p = {}
+ for location in path:gmatch '([^/]+)' do
+ table.insert(p, html.url_decode(location))
+ end
+ return p
+end
+
+function M.parse_qs(str,sep)
+ sep = sep or '&'
+
+ local values = {}
+ for key, val in str:gmatch(('([^%q=]+)(=*[^%q=]*)'):format(sep, sep)) do
+ local key = html.url_decode(key)
+
+ local keys = {}
+ key = key:gsub('%[([^%]]*)%]', function(v)
+ -- extract keys between balanced brackets
+ if string.find(v, "^-?%d+$") then
+ v = tonumber(v)
+ else
+ v = html.url_decode(v)
+ end
+ table.insert(keys, v)
+ return "="
+ end)
+ key = key:gsub('=+.*$', "")
+ key = key:gsub('%s', "_") -- remove spaces in parameter name
+ val = val:gsub('^=+', "")
+
+ if not values[key] then
+ values[key] = {}
+ end
+ if #keys > 0 and type(values[key]) ~= 'table' then
+ values[key] = {}
+ elseif #keys == 0 and type(values[key]) == 'table' then
+ values[key] = html.url_decode(val)
+ end
+
+ local t = values[key]
+ for i,k in ipairs(keys) do
+ if type(t) ~= 'table' then
+ t = {}
+ end
+ if k == "" then
+ k = #t+1
+ end
+ if not t[k] then
+ t[k] = {}
+ end
+ if i == #keys then
+ t[k] = html.url_decode(val)
+ end
+ t = t[k]
+ end
+ end
+ return values
+end
+
+local redir_statuses = {
+ [301] = "301 Moved Permanently",
+ [301] = "302 Found",
+ [302] = "302 See Other",
+ [307] = "307 Temporary Redirect",
+ [308] = "308 Permanent Redirect",
+}
+
+local function redirect_page(code, url)
+ return {
+ content = 'text/html', status = redir_statuses[code] or code,
+ location = url
+ }, html.render_doc(function()
+ html.h1 "redirect"
+ html.p(function()
+ html.a({href = url}, url)
+ end)
+ end)
+end
+
+function M.serve(methods, error_page)
+ local co = coroutine.create(function()
+ local path = M.env 'PATH_INFO'
+ local query = M.parse_qs(M.env 'QUERY_STRING')
+ local form
+ local content_type = M.env 'CONTENT_TYPE'
+ if content_type == 'application/x-www-form-urlencoded' then
+ form = M.parse_qs(io.read 'a')
+ end
+ local cookie = {}
+ if M.env 'HTTP_COOKIE' then
+ cookie = M.parse_qs(M.env 'HTTP_COOKIE', '; ')
+ end
+
+ if methods[M.env 'REQUEST_METHOD'] then
+ local info = {query = query, form = form, cookie = cookie}
+ serve(methods[M.env 'REQUEST_METHOD'](info, path))
+ else
+ M.abort(405)
+ end
+ end)
+
+ local ok, err, code, arg = coroutine.resume(co)
+ if ok and coroutine.status(co) ~= "dead" then
+ if err == M.abort then
+ serve(error_page(code, arg))
+ elseif err == M.redirect then
+ serve(redirect_page(code, arg))
+ else
+ ok = false err = "invalid yield"
+ end
+ end
+ if not ok then
+ io.stderr:write('\n'..debug.traceback(co, err)..'\n')
+ serve(error_page(500))
+ end
+end
+
+return M
diff --git a/citrine.lua b/citrine.lua
new file mode 100644
index 0000000..3f9b41a
--- /dev/null
+++ b/citrine.lua
@@ -0,0 +1,172 @@
+local h = require 'html'
+
+local M = {}
+
+local site_sections = {
+ default = "account",
+}
+
+local function navbar(links)
+ h.nav({class = "site-nav"}, function()
+ for _, link in ipairs(links) do
+ h.a({
+ href = link[2],
+ class = link[2] == links.selected and "selected" or nil
+ }, link[1])
+ h.text " "
+ end
+ end)
+end
+
+function M.page(param)
+ return h.render_doc(function()
+ h.meta {charset = "utf-8"}
+ h.title(param.title and
+ param.title .. " — auth.citrons.xyz" or "auth.citrons.xyz")
+ h.meta {
+ name = "viewport",
+ content = "width=device-width, initial-scale=0.75"
+ }
+ h.link {rel = "stylesheet", href = "/static/citrine.css"}
+ h.div({id = "page-grid"}, function()
+ h.header({class = "site-header"}, function()
+ param.section = param.section or "default"
+ h.img({
+ src = "/static/headers/"..param.section..".png",
+ alt = site_sections[param.section]
+ })
+ end)
+ h.div({class = "content"}, function()
+ h.main(param[1])
+ h.footer({class = "site-footer"}, function()
+ h.p(function()
+ h.a({href =
+ "https://citrons.xyz/git/auth.git/"
+ }, "© 2023 citrons")
+ end)
+ end)
+ end)
+ h.div({class = "header-icons"}, function()
+ h.a({class = "help", href = "/account"}, function()
+ h.img {
+ src = "/static/gear.png", alt = "account settings",
+ class = "help"
+ }
+ end)
+ h.a({class = "help", href = "/"}, function()
+ h.img {
+ src = "/static/qmark.png", alt = "about",
+ class = "help"
+ }
+ end)
+ end)
+ end)
+ end)
+end
+
+local function class_article(e)
+ return function(...)
+ h[e]({class = "article"}, ...)
+ end
+end
+
+M.h1 = class_article "h1"
+M.h2 = class_article "h2"
+M.h3 = class_article "h3"
+M.h4 = class_article "h4"
+M.h5 = class_article "h5"
+M.h6 = class_article "h6"
+
+function M.quote(quote, attrib)
+ h.figure({class = attrib and "quote attrib" or "quote"}, function()
+ h.blockquote(function()
+ h.p({}, quote)
+ end)
+ if attrib then
+ h.figcaption({}, attrib)
+ end
+ end)
+end
+
+function M.dyk(content)
+ h.aside({class = "dyk"}, function()
+ h.h2 "did you know?"
+ if type(content) == "string" then
+ html.p(content)
+ else
+ content()
+ end
+ end)
+end
+
+function M.code_block(content)
+ h.pre({class = "code-block"}, content)
+end
+
+function M.apiobutton()
+ h.a({href = "https://citrons.xyz/a/memetic-apioform-page",
+ class="apiopage"}, function()
+ h.img {src = "https://citrons.xyz/a/static/apiopage.png",
+ alt = "memetic apiopage: click to view"}
+ end)
+end
+
+function M.redact(s)
+ h.del({class = "redaction", title = "redacted text"}, function()
+ h.span({}, s:gsub(utf8.charpattern, function(c)
+ if c ~= " " then return "X" end
+ end))
+ end)
+end
+
+function M.errmsg(e)
+ h.div({class = "errmsg"}, function()
+ h.p({}, e)
+ end)
+end
+
+local statuses = {
+ [400] = "400 Bad Request",
+ [401] = "401 Unauthorized",
+ [403] = "403 Forbidden",
+ [404] = "404 Not Found",
+ [405] = "405 Method Not Allowed",
+ [410] = "410 Gone",
+ [411] = "411 Length Required",
+ [413] = "413 Payload Too Large",
+ [429] = "429 Too Many Requests",
+ [500] = "500 Internal Server Error",
+ [501] = "501 Not Implemented",
+ [502] = "502 Bad Gateway",
+ [503] = "503 Service Unavailable",
+ [504] = "504 Gateway Timeout"
+}
+
+local messages = {
+ [400] = "your software has sent an invalid request to the server.",
+ [401] = "permission denied.",
+ [403] = "permission denied.",
+ [404] = "the page you attempted to access was not found.",
+ [405] = "your software has sent a request with an invalid method.",
+ [410] = "this content is no longer available.",
+ [411] = "your software failed to provide a length for the request body.",
+ [413] = "your software sent too much data to the server.",
+ [429] = "you are doing this too often. try again later.",
+ [500] = "a temporary internal error has occurred. this is my fault.",
+ [501] = "the requested feature is not implemented.",
+}
+
+function M.error(code, message)
+ return {
+ content_type = 'text/html', status = statuses[code] or code
+ }, M.page {
+ section = "error",
+ function()
+ M.h1((statuses[code] or ("error "..code)):lower())
+ h.p(message or messages[code] or
+ messages[code // 100 * 100] or "an error has occurred")
+ end
+ }
+end
+
+return M
diff --git a/db.lua b/db.lua
new file mode 100644
index 0000000..c01d717
--- /dev/null
+++ b/db.lua
@@ -0,0 +1,29 @@
+local lmdb = require 'lmdb'
+
+local M = {}
+
+local db
+
+function M.get()
+ if not db then
+ db = assert(lmdb.open("data", {maxdbs = 256}))
+ end
+ local txn = db:txn_begin(true)
+ local meta = txn:open("meta", true)
+ if not meta.version then
+ version = 1
+ end
+ txn:open("users", true)
+ txn:open("usernames", true)
+ txn:open("emails", true)
+ txn:commit()
+ return db
+end
+
+function M.txn(write_enabled)
+ return M.get():txn_begin(write_enabled)
+end
+
+M.next = lmdb.next
+
+return M
diff --git a/forms.lua b/forms.lua
new file mode 100644
index 0000000..d4084a4
--- /dev/null
+++ b/forms.lua
@@ -0,0 +1,115 @@
+local citrine = require 'citrine'
+local html = require 'html'
+
+local M = {}
+
+local function show_messages(messages)
+ if messages then
+ for _, m in ipairs(messages) do
+ citrine.errmsg(m)
+ end
+ end
+end
+
+function M.reasonable_string(str)
+ local len = utf8.len(str)
+ return len and len < 256 and len > 0 or false
+end
+
+local function input(type, id, label, value, submit)
+ html.p({class = 'field'}, function()
+ if label and type ~= 'checkbox' then
+ html.label({["for"] = id}, label)
+ end
+ html.input {
+ type = type, id = id, name = id,
+ required = type ~= 'checkbox' and "" or nil, maxlength = 256,
+ value = value
+ }
+ if label and type == 'checkbox' then
+ html.label({["for"] = id, class = 'checkbox-label'}, label)
+ html.text " "
+ end
+ if submit then
+ html.input {type = 'submit', value = submit}
+ end
+ end)
+end
+
+local function hidden(name, value)
+ html.input {type = 'hidden', name = name, value = value}
+end
+
+function M.login(action, messages, values)
+ local attrs = {method = "POST", action = action, class = 'login-form'}
+ local values = values or {}
+ html.form(attrs, function()
+ html.h2 "existing account"
+ show_messages(messages)
+ input('email', 'email', "email: ", values.email)
+ input('password', 'password', "password: ")
+ hidden('action', 'login')
+ input('submit', nil, nil, "log in")
+ end)
+end
+
+function M.register(action, messages, values)
+ local attrs = {method = "POST", action = action, class = 'register-form'}
+ local values = values or {}
+ html.form(attrs, function()
+ html.h2 "register"
+ show_messages(messages)
+ input('email', 'email', "email: ", values.email)
+ input('password', 'password', "password: ", values.password)
+ input('password', 'confirm_password', "confirm password: ")
+ input('text', 'username',
+ "username (may be changed later): ", values.username)
+ input('time', 'coinstar', "when's 8am? ")
+ html.p [[
+ tip: you should use a (offline) password manager to generate and
+ store your passwords.
+ ]]
+ hidden('action', 'register')
+ input('submit', nil, nil, "register")
+ end)
+end
+
+function M.connect(uid, token, meta, endpoint)
+ local attrs = {method = "POST", action = endpoint, class = 'connect-form'}
+ html.form(attrs, function()
+ hidden('uid', uid)
+ hidden('token', token)
+ if meta then
+ hidden('meta', meta)
+ end
+ input('submit', nil, nil, "confirm")
+ end)
+end
+
+function M.user_settings(user, messages)
+ show_messages(messages)
+ html.form({method = "POST"}, function()
+ input('text', 'username',
+ "username: ", user:get "username", "change")
+ end)
+ html.form({method = "POST"}, function()
+ input('email', 'email',
+ "email: ", user:get "email", "change")
+ end)
+ html.p "username changes may take time to effect."
+ html.h3 "change password"
+ html.form({method = "POST"}, function()
+ input('password', 'password', "current password: ")
+ input('password', 'new_password', "new password: ")
+ input('password', 'confirm_password', "confirm password: ")
+ input('submit', nil, nil, "change")
+ end)
+ html.h3 "log out"
+ html.form({method = "POST"}, function()
+ hidden('logout', 'yes')
+ input('checkbox', 'everywhere',
+ "log out everywhere", nil, "log out")
+ end)
+end
+
+return M
diff --git a/html.lua b/html.lua
new file mode 100644
index 0000000..3b34cc3
--- /dev/null
+++ b/html.lua
@@ -0,0 +1,99 @@
+local M = {}
+
+local void = {
+ area = true, base = true, br = true, col = true, embed = true, hr = true,
+ img = true, input = true, link = true, meta = true, param = true,
+ source = true, track = true, wbr = true,
+}
+
+local char_refs = {
+ ["<"] = "&lt;",
+ [">"] = "&gt;",
+ ['"'] = "&quot;",
+ ["&"] = "&amp;",
+}
+local function escape(x)
+ return (tostring(x):gsub('.', char_refs))
+end
+
+local function ele(_, name)
+ assert(name:match '^%w+$')
+ return function(...)
+ local params, inner
+ if select('#', ...) == 2 or type((...)) == 'table' then
+ params, inner = ...
+ else inner = ... end
+
+ coroutine.yield("<", name)
+ if params then
+ coroutine.yield(" ")
+ for k, v in pairs(params) do
+ assert(not k:find '[%s"\'>/=]')
+ coroutine.yield(k, "=\"", escape(v), "\" ")
+ end
+ end
+ coroutine.yield(">")
+
+ if not void[name] then
+ if type(inner) == 'function' then
+ inner()
+ elseif inner then
+ coroutine.yield(escape(inner))
+ end
+ coroutine.yield("</", name, ">")
+ else
+ assert(not inner)
+ end
+ end
+end
+
+setmetatable(M, {__index = ele})
+
+local function document(fn)
+ return function()
+ coroutine.yield("<!doctype html>")
+ M.html(fn)
+ coroutine.yield("\n")
+ end
+end
+
+function M.render(fn, ...)
+ local output = {}
+
+ local co = coroutine.create(fn, ...)
+ local retvals
+ while true do
+ retvals = {assert(coroutine.resume(co))}
+ if coroutine.status(co) == 'dead' then break end
+ if retvals[2] == abort then
+ coroutine.yield(unpack(retvals))
+ end
+ for i = 2, #retvals do
+ table.insert(output, retvals[i])
+ end
+ end
+
+ return table.concat(output), table.unpack(retvals, 2)
+end
+
+function M.render_doc(fn, ...)
+ return M.render(document(fn), ...)
+end
+
+function M.text(str)
+ coroutine.yield(escape(str))
+end
+
+function M.url_encode(str)
+ return tostring(str):gsub('([^A-Za-z0-9%_%.%-%~])', function(v)
+ return ('%%%02x'):format(v:byte()):upper()
+ end)
+end
+
+function M.url_decode(str)
+ return tostring(str):gsub('%%(%x%x)', function(c)
+ return string.char(tonumber(c, 16))
+ end)
+end
+
+return M
diff --git a/md5.lua b/md5.lua
new file mode 100644
index 0000000..a3b87e3
--- /dev/null
+++ b/md5.lua
@@ -0,0 +1,427 @@
+local md5 = {
+ _VERSION = "md5.lua 1.1.0",
+ _DESCRIPTION = "MD5 computation in Lua (5.1-3, LuaJIT)",
+ _URL = "https://github.com/kikito/md5.lua",
+ _LICENSE = [[
+ MIT LICENSE
+
+ Copyright (c) 2013 Enrique García Cota + Adam Baldwin + hanzao + Equi 4 Software
+
+ Permission is hereby granted, free of charge, to any person obtaining a
+ copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be included
+ in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ]]
+}
+
+-- bit lib implementions
+
+local char, byte, format, rep, sub =
+ string.char, string.byte, string.format, string.rep, string.sub
+local bit_or, bit_and, bit_not, bit_xor, bit_rshift, bit_lshift
+
+local ok, bit = pcall(require, 'bit')
+local ok_ffi, ffi = pcall(require, 'ffi')
+if ok then
+ bit_or, bit_and, bit_not, bit_xor, bit_rshift, bit_lshift = bit.bor, bit.band, bit.bnot, bit.bxor, bit.rshift, bit.lshift
+else
+ ok, bit = pcall(require, 'bit32')
+
+ if ok then
+
+ bit_not = bit.bnot
+
+ local tobit = function(n)
+ return n <= 0x7fffffff and n or -(bit_not(n) + 1)
+ end
+
+ local normalize = function(f)
+ return function(a,b) return tobit(f(tobit(a), tobit(b))) end
+ end
+
+ bit_or, bit_and, bit_xor = normalize(bit.bor), normalize(bit.band), normalize(bit.bxor)
+ bit_rshift, bit_lshift = normalize(bit.rshift), normalize(bit.lshift)
+
+ else
+
+ local function tbl2number(tbl)
+ local result = 0
+ local power = 1
+ for i = 1, #tbl do
+ result = result + tbl[i] * power
+ power = power * 2
+ end
+ return result
+ end
+
+ local function expand(t1, t2)
+ local big, small = t1, t2
+ if(#big < #small) then
+ big, small = small, big
+ end
+ -- expand small
+ for i = #small + 1, #big do
+ small[i] = 0
+ end
+ end
+
+ local to_bits -- needs to be declared before bit_not
+
+ bit_not = function(n)
+ local tbl = to_bits(n)
+ local size = math.max(#tbl, 32)
+ for i = 1, size do
+ if(tbl[i] == 1) then
+ tbl[i] = 0
+ else
+ tbl[i] = 1
+ end
+ end
+ return tbl2number(tbl)
+ end
+
+ -- defined as local above
+ to_bits = function (n)
+ if(n < 0) then
+ -- negative
+ return to_bits(bit_not(math.abs(n)) + 1)
+ end
+ -- to bits table
+ local tbl = {}
+ local cnt = 1
+ local last
+ while n > 0 do
+ last = n % 2
+ tbl[cnt] = last
+ n = (n-last)/2
+ cnt = cnt + 1
+ end
+
+ return tbl
+ end
+
+ bit_or = function(m, n)
+ local tbl_m = to_bits(m)
+ local tbl_n = to_bits(n)
+ expand(tbl_m, tbl_n)
+
+ local tbl = {}
+ for i = 1, #tbl_m do
+ if(tbl_m[i]== 0 and tbl_n[i] == 0) then
+ tbl[i] = 0
+ else
+ tbl[i] = 1
+ end
+ end
+
+ return tbl2number(tbl)
+ end
+
+ bit_and = function(m, n)
+ local tbl_m = to_bits(m)
+ local tbl_n = to_bits(n)
+ expand(tbl_m, tbl_n)
+
+ local tbl = {}
+ for i = 1, #tbl_m do
+ if(tbl_m[i]== 0 or tbl_n[i] == 0) then
+ tbl[i] = 0
+ else
+ tbl[i] = 1
+ end
+ end
+
+ return tbl2number(tbl)
+ end
+
+ bit_xor = function(m, n)
+ local tbl_m = to_bits(m)
+ local tbl_n = to_bits(n)
+ expand(tbl_m, tbl_n)
+
+ local tbl = {}
+ for i = 1, #tbl_m do
+ if(tbl_m[i] ~= tbl_n[i]) then
+ tbl[i] = 1
+ else
+ tbl[i] = 0
+ end
+ end
+
+ return tbl2number(tbl)
+ end
+
+ bit_rshift = function(n, bits)
+ local high_bit = 0
+ if(n < 0) then
+ -- negative
+ n = bit_not(math.abs(n)) + 1
+ high_bit = 0x80000000
+ end
+
+ local floor = math.floor
+
+ for i=1, bits do
+ n = n/2
+ n = bit_or(floor(n), high_bit)
+ end
+ return floor(n)
+ end
+
+ bit_lshift = function(n, bits)
+ if(n < 0) then
+ -- negative
+ n = bit_not(math.abs(n)) + 1
+ end
+
+ for i=1, bits do
+ n = n*2
+ end
+ return bit_and(n, 0xFFFFFFFF)
+ end
+ end
+end
+
+-- convert little-endian 32-bit int to a 4-char string
+local lei2str
+-- function is defined this way to allow full jit compilation (removing UCLO instruction in LuaJIT)
+if ok_ffi then
+ local ct_IntType = ffi.typeof("int[1]")
+ lei2str = function(i) return ffi.string(ct_IntType(i), 4) end
+else
+ lei2str = function (i)
+ local f=function (s) return char( bit_and( bit_rshift(i, s), 255)) end
+ return f(0)..f(8)..f(16)..f(24)
+ end
+end
+
+
+
+-- convert raw string to big-endian int
+local function str2bei(s)
+ local v=0
+ for i=1, #s do
+ v = v * 256 + byte(s, i)
+ end
+ return v
+end
+
+-- convert raw string to little-endian int
+local str2lei
+
+if ok_ffi then
+ local ct_constcharptr = ffi.typeof("const char*")
+ local ct_constintptr = ffi.typeof("const int*")
+ str2lei = function(s)
+ local int = ct_constcharptr(s)
+ return ffi.cast(ct_constintptr, int)[0]
+ end
+else
+ str2lei = function(s)
+ local v=0
+ for i = #s,1,-1 do
+ v = v*256 + byte(s, i)
+ end
+ return v
+ end
+end
+
+
+-- cut up a string in little-endian ints of given size
+local function cut_le_str(s)
+ return {
+ str2lei(sub(s, 1, 4)),
+ str2lei(sub(s, 5, 8)),
+ str2lei(sub(s, 9, 12)),
+ str2lei(sub(s, 13, 16)),
+ str2lei(sub(s, 17, 20)),
+ str2lei(sub(s, 21, 24)),
+ str2lei(sub(s, 25, 28)),
+ str2lei(sub(s, 29, 32)),
+ str2lei(sub(s, 33, 36)),
+ str2lei(sub(s, 37, 40)),
+ str2lei(sub(s, 41, 44)),
+ str2lei(sub(s, 45, 48)),
+ str2lei(sub(s, 49, 52)),
+ str2lei(sub(s, 53, 56)),
+ str2lei(sub(s, 57, 60)),
+ str2lei(sub(s, 61, 64)),
+ }
+end
+
+-- An MD5 mplementation in Lua, requires bitlib (hacked to use LuaBit from above, ugh)
+-- 10/02/2001 jcw@equi4.com
+
+local CONSTS = {
+ 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
+ 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
+ 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
+ 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
+ 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
+ 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
+ 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
+ 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
+ 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
+ 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
+ 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
+ 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
+ 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
+ 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
+ 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
+ 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
+ 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476
+}
+
+local f=function (x,y,z) return bit_or(bit_and(x,y),bit_and(-x-1,z)) end
+local g=function (x,y,z) return bit_or(bit_and(x,z),bit_and(y,-z-1)) end
+local h=function (x,y,z) return bit_xor(x,bit_xor(y,z)) end
+local i=function (x,y,z) return bit_xor(y,bit_or(x,-z-1)) end
+local z=function (ff,a,b,c,d,x,s,ac)
+ a=bit_and(a+ff(b,c,d)+x+ac,0xFFFFFFFF)
+ -- be *very* careful that left shift does not cause rounding!
+ return bit_or(bit_lshift(bit_and(a,bit_rshift(0xFFFFFFFF,s)),s),bit_rshift(a,32-s))+b
+end
+
+local function transform(A,B,C,D,X)
+ local a,b,c,d=A,B,C,D
+ local t=CONSTS
+
+ a=z(f,a,b,c,d,X[ 0], 7,t[ 1])
+ d=z(f,d,a,b,c,X[ 1],12,t[ 2])
+ c=z(f,c,d,a,b,X[ 2],17,t[ 3])
+ b=z(f,b,c,d,a,X[ 3],22,t[ 4])
+ a=z(f,a,b,c,d,X[ 4], 7,t[ 5])
+ d=z(f,d,a,b,c,X[ 5],12,t[ 6])
+ c=z(f,c,d,a,b,X[ 6],17,t[ 7])
+ b=z(f,b,c,d,a,X[ 7],22,t[ 8])
+ a=z(f,a,b,c,d,X[ 8], 7,t[ 9])
+ d=z(f,d,a,b,c,X[ 9],12,t[10])
+ c=z(f,c,d,a,b,X[10],17,t[11])
+ b=z(f,b,c,d,a,X[11],22,t[12])
+ a=z(f,a,b,c,d,X[12], 7,t[13])
+ d=z(f,d,a,b,c,X[13],12,t[14])
+ c=z(f,c,d,a,b,X[14],17,t[15])
+ b=z(f,b,c,d,a,X[15],22,t[16])
+
+ a=z(g,a,b,c,d,X[ 1], 5,t[17])
+ d=z(g,d,a,b,c,X[ 6], 9,t[18])
+ c=z(g,c,d,a,b,X[11],14,t[19])
+ b=z(g,b,c,d,a,X[ 0],20,t[20])
+ a=z(g,a,b,c,d,X[ 5], 5,t[21])
+ d=z(g,d,a,b,c,X[10], 9,t[22])
+ c=z(g,c,d,a,b,X[15],14,t[23])
+ b=z(g,b,c,d,a,X[ 4],20,t[24])
+ a=z(g,a,b,c,d,X[ 9], 5,t[25])
+ d=z(g,d,a,b,c,X[14], 9,t[26])
+ c=z(g,c,d,a,b,X[ 3],14,t[27])
+ b=z(g,b,c,d,a,X[ 8],20,t[28])
+ a=z(g,a,b,c,d,X[13], 5,t[29])
+ d=z(g,d,a,b,c,X[ 2], 9,t[30])
+ c=z(g,c,d,a,b,X[ 7],14,t[31])
+ b=z(g,b,c,d,a,X[12],20,t[32])
+
+ a=z(h,a,b,c,d,X[ 5], 4,t[33])
+ d=z(h,d,a,b,c,X[ 8],11,t[34])
+ c=z(h,c,d,a,b,X[11],16,t[35])
+ b=z(h,b,c,d,a,X[14],23,t[36])
+ a=z(h,a,b,c,d,X[ 1], 4,t[37])
+ d=z(h,d,a,b,c,X[ 4],11,t[38])
+ c=z(h,c,d,a,b,X[ 7],16,t[39])
+ b=z(h,b,c,d,a,X[10],23,t[40])
+ a=z(h,a,b,c,d,X[13], 4,t[41])
+ d=z(h,d,a,b,c,X[ 0],11,t[42])
+ c=z(h,c,d,a,b,X[ 3],16,t[43])
+ b=z(h,b,c,d,a,X[ 6],23,t[44])
+ a=z(h,a,b,c,d,X[ 9], 4,t[45])
+ d=z(h,d,a,b,c,X[12],11,t[46])
+ c=z(h,c,d,a,b,X[15],16,t[47])
+ b=z(h,b,c,d,a,X[ 2],23,t[48])
+
+ a=z(i,a,b,c,d,X[ 0], 6,t[49])
+ d=z(i,d,a,b,c,X[ 7],10,t[50])
+ c=z(i,c,d,a,b,X[14],15,t[51])
+ b=z(i,b,c,d,a,X[ 5],21,t[52])
+ a=z(i,a,b,c,d,X[12], 6,t[53])
+ d=z(i,d,a,b,c,X[ 3],10,t[54])
+ c=z(i,c,d,a,b,X[10],15,t[55])
+ b=z(i,b,c,d,a,X[ 1],21,t[56])
+ a=z(i,a,b,c,d,X[ 8], 6,t[57])
+ d=z(i,d,a,b,c,X[15],10,t[58])
+ c=z(i,c,d,a,b,X[ 6],15,t[59])
+ b=z(i,b,c,d,a,X[13],21,t[60])
+ a=z(i,a,b,c,d,X[ 4], 6,t[61])
+ d=z(i,d,a,b,c,X[11],10,t[62])
+ c=z(i,c,d,a,b,X[ 2],15,t[63])
+ b=z(i,b,c,d,a,X[ 9],21,t[64])
+
+ return bit_and(A+a,0xFFFFFFFF),bit_and(B+b,0xFFFFFFFF),
+ bit_and(C+c,0xFFFFFFFF),bit_and(D+d,0xFFFFFFFF)
+end
+
+----------------------------------------------------------------
+
+local function md5_update(self, s)
+ self.pos = self.pos + #s
+ s = self.buf .. s
+ for ii = 1, #s - 63, 64 do
+ local X = cut_le_str(sub(s,ii,ii+63))
+ assert(#X == 16)
+ X[0] = table.remove(X,1) -- zero based!
+ self.a,self.b,self.c,self.d = transform(self.a,self.b,self.c,self.d,X)
+ end
+ self.buf = sub(s, math.floor(#s/64)*64 + 1, #s)
+ return self
+end
+
+local function md5_finish(self)
+ local msgLen = self.pos
+ local padLen = 56 - msgLen % 64
+
+ if msgLen % 64 > 56 then padLen = padLen + 64 end
+
+ if padLen == 0 then padLen = 64 end
+
+ local s = char(128) .. rep(char(0),padLen-1) .. lei2str(bit_and(8*msgLen, 0xFFFFFFFF)) .. lei2str(math.floor(msgLen/0x20000000))
+ md5_update(self, s)
+
+ assert(self.pos % 64 == 0)
+ return lei2str(self.a) .. lei2str(self.b) .. lei2str(self.c) .. lei2str(self.d)
+end
+
+----------------------------------------------------------------
+
+function md5.new()
+ return { a = CONSTS[65], b = CONSTS[66], c = CONSTS[67], d = CONSTS[68],
+ pos = 0,
+ buf = '',
+ update = md5_update,
+ finish = md5_finish }
+end
+
+function md5.tohex(s)
+ return format("%08x%08x%08x%08x", str2bei(sub(s, 1, 4)), str2bei(sub(s, 5, 8)), str2bei(sub(s, 9, 12)), str2bei(sub(s, 13, 16)))
+end
+
+function md5.sum(s)
+ return md5.new():update(s):finish()
+end
+
+function md5.sumhexa(s)
+ return md5.tohex(md5.sum(s))
+end
+
+return md5
diff --git a/pbkdf2.lua b/pbkdf2.lua
new file mode 100644
index 0000000..819ba9f
--- /dev/null
+++ b/pbkdf2.lua
@@ -0,0 +1,35 @@
+local rand = require 'openssl.rand'
+local hmac = require 'openssl.hmac'
+
+local function xor(a, b)
+ local x = {}
+ for i = 1, #b do
+ x[i] = string.char(a:byte(i) ~ b:byte(i))
+ end
+ return table.concat(x)
+end
+
+local function prf(algo, password, salt, iter)
+ local k
+ local prev = salt
+ for i = 1, iter do
+ local h = hmac.new(password, algo)
+ local sum = h:final(prev)
+ k = k and xor(k, sum) or sum
+ prev = sum
+ end
+ return k
+end
+
+return function(password, salt, algo, iter, blocks)
+ local salt = salt or rand.bytes(256)
+ local algo = algo or 'sha3-512'
+ local iter = iter or 10000
+ local blocks = blocks or 1
+
+ local dk = ""
+ for i = 1, blocks do
+ dk = dk .. prf(algo, password, salt..(('>I4'):pack(i)), iter)
+ end
+ return dk, salt, algo, iter, blocks
+end
diff --git a/service.lua b/service.lua
new file mode 100644
index 0000000..70a3374
--- /dev/null
+++ b/service.lua
@@ -0,0 +1,60 @@
+local http_rq = require 'http.request'
+local json = require 'json'
+local lmdb = require 'lmdb'
+
+local M = {}
+
+local db
+local function get_db()
+ if db then
+ return db
+ end
+ db = assert(lmdb.open("data/cache.mdb", {nosubdir = true, mapsize = 2^20}))
+ return db
+end
+
+function M.lookup(domain)
+ if not domain:match "^[%w_%-%.]+$" then
+ return nil, "invalid service name!"
+ end
+ domain = domain:gsub('%.+', ".")
+
+ local db = get_db()
+
+ local txn = db:txn_begin()
+ if txn then
+ local cache = txn:open()
+ if cache[domain] then
+ local sv = json.decode(cache[domain])
+ if os.time() <= sv.expires then
+ txn:commit()
+ return sv
+ end
+ end
+ txn:commit()
+ end
+
+ local meta_uri = "https://"..domain.."/.well-known/citrons/auth"
+ local rq = http_rq.new_from_uri(meta_uri)
+ local headers, stream = rq:go(8)
+ if not headers then return nil, stream end
+ if headers:get ":status" ~= "200" then
+ return nil, "HTTP error: "..headers:get ":status"
+ end
+ local data, err = stream:get_body_chars(4096, 4)
+ if not data then return nil, err end
+ local ok, result = pcall(json.decode, data)
+ if not ok then return nil, "could not decode JSON" end
+
+ local txn = db:txn_begin(true)
+ if txn then
+ local cache = txn:open()
+ result.expires = os.time() + (result.ttl or 300)
+ cache[domain] = json.encode(result)
+ pcall(txn.commit, txn)
+ end
+
+ return result
+end
+
+return M
diff --git a/static/apiopage.png b/static/apiopage.png
new file mode 100644
index 0000000..686ec4c
--- /dev/null
+++ b/static/apiopage.png
Binary files differ
diff --git a/static/auth.dot b/static/auth.dot
new file mode 100644
index 0000000..9345b99
--- /dev/null
+++ b/static/auth.dot
@@ -0,0 +1,21 @@
+digraph "auth.citrons.xyz" {
+ "auth.citrons.xyz" -> "wiki encyclopedia";
+ "auth.citrons.xyz" -> "citrons.xyz";
+ "auth.citrons.xyz" -> "gollark (GPT-5)" [weight=10];
+ "auth.citrons.xyz" -> "SCP-[REDACTED]";
+ "auth.citrons.xyz" -> "heav unauth service" [weight=0];
+ "citrons.xyz" -> "SCP-[REDACTED]";
+ "gollark (GPT-5)" -> "GTech™";
+ "GPT-5" -> "gollark (GPT-5)";
+ "GTech™" -> "God";
+ "GTech™" -> "RSAPI";
+ "GTech™" -> "world governments";
+ "GTech™" -> "tmpim";
+ "tmpim" -> "world governments";
+ "God" -> "world governments";
+ "SCP-[REDACTED]" -> "world governments";
+ "God" -> "GPT-5";
+ "world governments" -> "GPT-5" [weight=10];
+ "wiki encyclopedia" -> "Leonhard Euler";
+ "Leonhard Euler" -> "God";
+}
diff --git a/static/bg.jpeg b/static/bg.jpeg
new file mode 100644
index 0000000..4bb2fb8
--- /dev/null
+++ b/static/bg.jpeg
Binary files differ
diff --git a/static/bubble-tail.svg b/static/bubble-tail.svg
new file mode 100644
index 0000000..2935e63
--- /dev/null
+++ b/static/bubble-tail.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" standalone="no"?>
+<svg width="38" height="38" version="1.1" xmlns="http://www.w3.org/2000/svg">
+<polygon points="0, 0 20, 0 20, 32" fill="white"/>
+</svg>
diff --git a/static/citrine.css b/static/citrine.css
new file mode 100644
index 0000000..62c70e3
--- /dev/null
+++ b/static/citrine.css
@@ -0,0 +1,363 @@
+/*
+ * citrine.css
+ *
+ * Copyright © 2023 citrons
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the “Software”), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ *
+ */
+
+
+body {
+ background-color: #002681;
+
+ margin-top: 20px;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 50px;
+ padding-left: 20px;
+ padding-right: 20px;
+ max-width: 80ch;
+
+ overflow-wrap: break-word;
+ min-width: 750px;
+ color: #FEF;
+ font-family: serif;
+}
+
+#page-grid {
+ display: grid;
+ grid-template-areas:
+ "header-left header-right"
+ "content content";
+ grid-template-columns: var(--navbar-width) auto;
+}
+
+@media not all and (min-width: 800px) {
+ body {
+ min-width: 35ch;
+ font-size: large;
+ }
+
+ #page-grid {
+ grid-template-areas:
+ "header-left header-right"
+ "navbar navbar"
+ "content content";
+ }
+
+ .help {
+ width: 45px !important;
+ }
+
+ .header-icons {
+ margin-top: 8px !important;
+ }
+
+ .header {
+ width: 270px;
+ }
+
+ .site-nav {
+ margin-right: 0 !important;
+ padding: 0px !important;
+ margin-top: 10px;
+ display: flex;
+ }
+
+ .site-nav a {
+ border-bottom: none !important;
+ border-top: none !important;
+ padding-top: 10px !important;
+ padding-bottom: 10px !important;
+ padding-left: 2px !important;
+ padding-right: 2px !important;
+ font-size: 12pt !important;
+ flex-basis: 100%;
+ }
+
+ .site-nav a:not(:last-of-type) {
+ border-right: 3px groove #7c67c3;
+ }
+
+ .dyk {
+ float: none !important;
+ margin-left: auto !important;
+ margin-right: auto !important;
+ max-width: 500px !important;
+ }
+}
+
+a {
+ color: lightblue;
+}
+
+a:visited {
+ color: lightgrey;
+}
+
+.site-header {
+ grid-area: header-left;
+ margin-left: -20px;
+ font-size: 30px;
+ white-space: nowrap;
+ font-family: sans-serif;
+ user-select: none;
+}
+
+.header-icons {
+ grid-area: header-right;
+ display: flex;
+ justify-content: flex-end;
+ margin-left: auto;
+}
+
+.help {
+ display: block;
+ margin-right: 5px;
+ margin-top: 8px;
+ width: 62px;
+}
+
+button, input[type="submit"] {
+ padding: 10px;
+ border: 0;
+ color: black;
+ font-size: 12pt;
+ font-weight: normal;
+ border-radius: 10px;
+ background: linear-gradient(to bottom, #cbbeff 0px, #a286ff 30px);
+}
+
+button:hover, input[type="submit"]:hover {
+ background: #cbbeff;
+}
+
+button:active, input[type="submit"]:active {
+ background: #a286ff;
+}
+
+input[type="text"], input[type="email"], input[type="password"], input[type="time"] {
+ border: 1px solid black;
+ padding: 8px;
+ margin-right: 10px;
+ border-radius: 5px;
+ font-size: 12pt;
+}
+
+input[type="text"], input[type="email"], input[type="password"] {
+ width: 300px;
+}
+
+input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus, input[type="time"]:focus {
+ box-shadow: inset 0 0 3px black;
+}
+
+.content {
+ grid-area: content;
+}
+
+main {
+ min-height: 320px;
+ display: flow-root;
+}
+
+.navbar {
+ grid-area: navbar;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: sans-serif;
+ font-weight: bold;
+}
+
+h1.article, h2.article {
+ margin-bottom: -10px;
+ padding-bottom: 20px;
+ background-image: url("/static/shadow.png");
+ background-repeat: repeat-x;
+ background-position: bottom;
+}
+
+.dyk {
+ float: right;
+ background-color: white;
+ color: black;
+ margin-left: 20px;
+ margin-right: 20px;
+ margin-bottom: 30px;
+ max-width: 320px;
+ padding: 10px;
+ box-shadow: 10px 10px 0px #ffdd9b;
+}
+
+.dyk h2 {
+ font-size: medium;
+}
+
+.quote {
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 700px;
+ clear: both;
+}
+
+.quote blockquote {
+ background-color: white;
+ color: black;
+ border-radius: 20px;
+ margin: 0;
+ margin-bottom: 30px;
+ padding-left: 15px;
+ padding-right: 15px;
+ padding-top: 2px;
+ padding-bottom: 2px;
+ position: relative;
+ box-shadow: 8px 8px 0px pink;
+ text-align: center;
+}
+
+.quote.attrib blockquote::after {
+ content: "";
+ position: absolute;
+ width: 20px;
+ height: 32px;
+ background-image: url("/static/bubble-tail.svg");
+ bottom: -32px;
+ right: 40px;
+ margin-left: -20px;
+}
+
+.quote.attrib figcaption {
+ font-style: italic;
+ min-width: 100px;
+ margin-left: auto;
+ margin-right: 15px;
+ margin-top: 34px;
+ margin-bottom: 0;
+ width: max-content;
+ text-align: center;
+}
+
+.site-footer {
+ text-align: center;
+ color: grey;
+ font-family: sans-serif;
+ font-size: smaller;
+ min-width: 100%;
+ clear: both;
+ padding-top: 20px;
+}
+
+.code-block {
+ white-space: pre-wrap;
+}
+
+.redaction {
+ background-color: lightgrey;
+}
+
+.redaction > span {
+ visibility: hidden;
+}
+
+.redaction::after {
+ clip-path: inset(100%);
+ clip: rect(1px, 1px, 1px, 1px);
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+ content: "[REDACTED]";
+}
+
+.error-image {
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.box, .login-box {
+ background-color: white;
+ color: black;
+ margin-left: 20px;
+ margin-right: 20px;
+ margin-bottom: 30px;
+ margin-top: 20px;
+ padding: 10px;
+ padding-top: 3px;
+ box-shadow: 10px 10px 0px #9782de;
+}
+
+.box {
+ margin-left: auto;
+ margin-right: auto;
+ width: fit-content;
+ padding-left: 20px;
+ padding-right: 20px;
+}
+
+.login-box {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.login-form, .register-form {
+ flex-basis: 350px;
+}
+
+.field > label {
+ font-family: sans-serif;
+ display: block;
+ font-size: smaller;
+ font-weight: bold;
+}
+
+.field > .checkbox-label {
+ font-weight: normal;
+ font-size: 11pt;
+ display: inline;
+}
+
+.errmsg {
+ background-color: #ffdce2;
+ color: #cb0000;
+ border: 1px solid red;
+ border-radius: 5px;
+ padding: 5px;
+ margin-bottom: 5px;
+ width: 300px;
+ font-size: 10pt;
+}
+
+.errmsg > p {
+ margin: 0;
+}
+
+.connect-form {
+ width: fit-content;
+ margin-left: auto;
+}
+
+.the-graph {
+ width: 100%;
+ object-fit: contain;
+}
diff --git a/static/gear.png b/static/gear.png
new file mode 100644
index 0000000..ca987bc
--- /dev/null
+++ b/static/gear.png
Binary files differ
diff --git a/static/graph.png b/static/graph.png
new file mode 100644
index 0000000..3143d98
--- /dev/null
+++ b/static/graph.png
Binary files differ
diff --git a/static/graph.svg b/static/graph.svg
new file mode 100644
index 0000000..9428554
--- /dev/null
+++ b/static/graph.svg
@@ -0,0 +1,206 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- Generated by graphviz version 2.43.0 (0)
+ -->
+<!-- dot auth.dot -Tsvg -G'bgcolor=#00268100' -N'style=filled' -N'fontcolor=black' -N'color=white' -E'color=#ffeeff' -->
+<!-- Title: auth.citrons.xyz Pages: 1 -->
+<svg width="634pt" height="620pt"
+ viewBox="0.00 0.00 633.84 620.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 616)">
+<title>auth.citrons.xyz</title>
+<polygon fill="transparent" stroke="transparent" points="-4,4 -4,-616 629.84,-616 629.84,4 -4,4"/>
+<!-- auth.citrons.xyz -->
+<g id="node1" class="node">
+<title>auth.citrons.xyz</title>
+<ellipse fill="white" stroke="white" cx="114.2" cy="-594" rx="85.29" ry="18"/>
+<text text-anchor="middle" x="114.2" y="-590.3" font-family="Times,serif" font-size="14.00">auth.citrons.xyz</text>
+</g>
+<!-- wiki encyclopedia -->
+<g id="node2" class="node">
+<title>wiki encyclopedia</title>
+<ellipse fill="white" stroke="white" cx="235.2" cy="-522" rx="92.88" ry="18"/>
+<text text-anchor="middle" x="235.2" y="-518.3" font-family="Times,serif" font-size="14.00">wiki encyclopedia</text>
+</g>
+<!-- auth.citrons.xyz&#45;&gt;wiki encyclopedia -->
+<g id="edge1" class="edge">
+<title>auth.citrons.xyz&#45;&gt;wiki encyclopedia</title>
+<path fill="none" stroke="#ffeeff" d="M141.96,-576.94C158.59,-567.32 179.96,-554.96 198.04,-544.5"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="200.24,-547.26 207.15,-539.23 196.74,-541.21 200.24,-547.26"/>
+</g>
+<!-- citrons.xyz -->
+<g id="node3" class="node">
+<title>citrons.xyz</title>
+<ellipse fill="white" stroke="white" cx="408.2" cy="-522" rx="61.99" ry="18"/>
+<text text-anchor="middle" x="408.2" y="-518.3" font-family="Times,serif" font-size="14.00">citrons.xyz</text>
+</g>
+<!-- auth.citrons.xyz&#45;&gt;citrons.xyz -->
+<g id="edge2" class="edge">
+<title>auth.citrons.xyz&#45;&gt;citrons.xyz</title>
+<path fill="none" stroke="#ffeeff" d="M168.73,-580.07C213.89,-569.35 279.72,-553.71 337.2,-540 342.08,-538.84 347.15,-537.62 352.24,-536.41"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="353.08,-539.81 361.99,-534.08 351.45,-533 353.08,-539.81"/>
+</g>
+<!-- gollark (GPT&#45;5) -->
+<g id="node4" class="node">
+<title>gollark (GPT&#45;5)</title>
+<ellipse fill="white" stroke="white" cx="114.2" cy="-234" rx="79.89" ry="18"/>
+<text text-anchor="middle" x="114.2" y="-230.3" font-family="Times,serif" font-size="14.00">gollark (GPT&#45;5)</text>
+</g>
+<!-- auth.citrons.xyz&#45;&gt;gollark (GPT&#45;5) -->
+<g id="edge3" class="edge">
+<title>auth.citrons.xyz&#45;&gt;gollark (GPT&#45;5)</title>
+<path fill="none" stroke="#ffeeff" d="M114.2,-575.95C114.2,-549.29 114.2,-496.11 114.2,-451 114.2,-451 114.2,-451 114.2,-377 114.2,-337 114.2,-290.65 114.2,-262.08"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="117.7,-262.05 114.2,-252.05 110.7,-262.05 117.7,-262.05"/>
+</g>
+<!-- SCP&#45;[REDACTED] -->
+<g id="node5" class="node">
+<title>SCP&#45;[REDACTED]</title>
+<ellipse fill="white" stroke="white" cx="420.2" cy="-450" rx="93.68" ry="18"/>
+<text text-anchor="middle" x="420.2" y="-446.3" font-family="Times,serif" font-size="14.00">SCP&#45;[REDACTED]</text>
+</g>
+<!-- auth.citrons.xyz&#45;&gt;SCP&#45;[REDACTED] -->
+<g id="edge4" class="edge">
+<title>auth.citrons.xyz&#45;&gt;SCP&#45;[REDACTED]</title>
+<path fill="none" stroke="#ffeeff" d="M199.32,-592.98C295.63,-590.94 443.74,-580.98 479.2,-540 497.13,-519.27 474.67,-492.8 452.5,-474.14"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="454.4,-471.18 444.41,-467.64 450.01,-476.63 454.4,-471.18"/>
+</g>
+<!-- heav unauth service -->
+<g id="node6" class="node">
+<title>heav unauth service</title>
+<ellipse fill="white" stroke="white" cx="521.2" cy="-378" rx="104.78" ry="18"/>
+<text text-anchor="middle" x="521.2" y="-374.3" font-family="Times,serif" font-size="14.00">heav unauth service</text>
+</g>
+<!-- auth.citrons.xyz&#45;&gt;heav unauth service -->
+<g id="edge5" class="edge">
+<title>auth.citrons.xyz&#45;&gt;heav unauth service</title>
+<path fill="none" stroke="#ffeeff" d="M198.51,-591.28C297.15,-587.61 452.67,-576.08 497.2,-540 536.17,-508.43 532.64,-443.25 526.81,-406.24"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="530.22,-405.41 525.05,-396.16 523.32,-406.61 530.22,-405.41"/>
+</g>
+<!-- Leonhard Euler -->
+<g id="node13" class="node">
+<title>Leonhard Euler</title>
+<ellipse fill="white" stroke="white" cx="225.2" cy="-450" rx="83.39" ry="18"/>
+<text text-anchor="middle" x="225.2" y="-446.3" font-family="Times,serif" font-size="14.00">Leonhard Euler</text>
+</g>
+<!-- wiki encyclopedia&#45;&gt;Leonhard Euler -->
+<g id="edge18" class="edge">
+<title>wiki encyclopedia&#45;&gt;Leonhard Euler</title>
+<path fill="none" stroke="#ffeeff" d="M232.73,-503.7C231.62,-495.98 230.3,-486.71 229.07,-478.11"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="232.52,-477.51 227.64,-468.1 225.59,-478.5 232.52,-477.51"/>
+</g>
+<!-- citrons.xyz&#45;&gt;SCP&#45;[REDACTED] -->
+<g id="edge6" class="edge">
+<title>citrons.xyz&#45;&gt;SCP&#45;[REDACTED]</title>
+<path fill="none" stroke="#ffeeff" d="M411.16,-503.7C412.49,-495.98 414.08,-486.71 415.55,-478.11"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="419.03,-478.55 417.27,-468.1 412.13,-477.37 419.03,-478.55"/>
+</g>
+<!-- GTech™ -->
+<g id="node7" class="node">
+<title>GTech™</title>
+<ellipse fill="white" stroke="white" cx="56.2" cy="-162" rx="48.99" ry="18"/>
+<text text-anchor="middle" x="56.2" y="-158.3" font-family="Times,serif" font-size="14.00">GTech™</text>
+</g>
+<!-- gollark (GPT&#45;5)&#45;&gt;GTech™ -->
+<g id="edge7" class="edge">
+<title>gollark (GPT&#45;5)&#45;&gt;GTech™</title>
+<path fill="none" stroke="#ffeeff" d="M100.16,-216.05C93,-207.42 84.17,-196.76 76.31,-187.27"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="78.78,-184.77 69.71,-179.31 73.39,-189.24 78.78,-184.77"/>
+</g>
+<!-- world governments -->
+<g id="node11" class="node">
+<title>world governments</title>
+<ellipse fill="white" stroke="white" cx="223.2" cy="-18" rx="100.98" ry="18"/>
+<text text-anchor="middle" x="223.2" y="-14.3" font-family="Times,serif" font-size="14.00">world governments</text>
+</g>
+<!-- SCP&#45;[REDACTED]&#45;&gt;world governments -->
+<g id="edge15" class="edge">
+<title>SCP&#45;[REDACTED]&#45;&gt;world governments</title>
+<path fill="none" stroke="#ffeeff" d="M409.42,-431.81C394.19,-405.83 368.2,-354.52 368.2,-307 368.2,-307 368.2,-307 368.2,-161 368.2,-104.21 310.72,-62.64 268.14,-39.64"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="269.52,-36.41 259.04,-34.88 266.28,-42.62 269.52,-36.41"/>
+</g>
+<!-- God -->
+<g id="node9" class="node">
+<title>God</title>
+<ellipse fill="white" stroke="white" cx="224.2" cy="-378" rx="29.5" ry="18"/>
+<text text-anchor="middle" x="224.2" y="-374.3" font-family="Times,serif" font-size="14.00">God</text>
+</g>
+<!-- GTech™&#45;&gt;God -->
+<g id="edge9" class="edge">
+<title>GTech™&#45;&gt;God</title>
+<path fill="none" stroke="#ffeeff" d="M42.53,-179.45C28.99,-197.86 12.06,-228.23 25.2,-252 60.02,-315 141.38,-350.54 188.58,-366.54"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="187.51,-369.88 198.11,-369.66 189.69,-363.22 187.51,-369.88"/>
+</g>
+<!-- RSAPI -->
+<g id="node10" class="node">
+<title>RSAPI</title>
+<ellipse fill="white" stroke="white" cx="155.2" cy="-90" rx="40.09" ry="18"/>
+<text text-anchor="middle" x="155.2" y="-86.3" font-family="Times,serif" font-size="14.00">RSAPI</text>
+</g>
+<!-- GTech™&#45;&gt;RSAPI -->
+<g id="edge10" class="edge">
+<title>GTech™&#45;&gt;RSAPI</title>
+<path fill="none" stroke="#ffeeff" d="M77.68,-145.81C91.88,-135.77 110.67,-122.48 126.2,-111.5"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="128.29,-114.31 134.44,-105.68 124.25,-108.6 128.29,-114.31"/>
+</g>
+<!-- GTech™&#45;&gt;world governments -->
+<g id="edge11" class="edge">
+<title>GTech™&#45;&gt;world governments</title>
+<path fill="none" stroke="#ffeeff" d="M36.39,-145.31C15.42,-126.8 -12.3,-95.56 6.2,-72 22.03,-51.83 79.4,-38.36 131.18,-30.05"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="131.76,-33.5 141.1,-28.5 130.68,-26.58 131.76,-33.5"/>
+</g>
+<!-- tmpim -->
+<g id="node12" class="node">
+<title>tmpim</title>
+<ellipse fill="white" stroke="white" cx="56.2" cy="-90" rx="40.89" ry="18"/>
+<text text-anchor="middle" x="56.2" y="-86.3" font-family="Times,serif" font-size="14.00">tmpim</text>
+</g>
+<!-- GTech™&#45;&gt;tmpim -->
+<g id="edge12" class="edge">
+<title>GTech™&#45;&gt;tmpim</title>
+<path fill="none" stroke="#ffeeff" d="M56.2,-143.7C56.2,-135.98 56.2,-126.71 56.2,-118.11"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="59.7,-118.1 56.2,-108.1 52.7,-118.1 59.7,-118.1"/>
+</g>
+<!-- GPT&#45;5 -->
+<g id="node8" class="node">
+<title>GPT&#45;5</title>
+<ellipse fill="white" stroke="white" cx="223.2" cy="-306" rx="38.19" ry="18"/>
+<text text-anchor="middle" x="223.2" y="-302.3" font-family="Times,serif" font-size="14.00">GPT&#45;5</text>
+</g>
+<!-- GPT&#45;5&#45;&gt;gollark (GPT&#45;5) -->
+<g id="edge8" class="edge">
+<title>GPT&#45;5&#45;&gt;gollark (GPT&#45;5)</title>
+<path fill="none" stroke="#ffeeff" d="M201.4,-291C186.17,-281.22 165.52,-267.96 148.14,-256.8"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="149.7,-253.64 139.39,-251.18 145.92,-259.53 149.7,-253.64"/>
+</g>
+<!-- God&#45;&gt;GPT&#45;5 -->
+<g id="edge16" class="edge">
+<title>God&#45;&gt;GPT&#45;5</title>
+<path fill="none" stroke="#ffeeff" d="M223.95,-359.7C223.84,-351.98 223.71,-342.71 223.58,-334.11"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="227.08,-334.05 223.44,-324.1 220.09,-334.15 227.08,-334.05"/>
+</g>
+<!-- God&#45;&gt;world governments -->
+<g id="edge14" class="edge">
+<title>God&#45;&gt;world governments</title>
+<path fill="none" stroke="#ffeeff" d="M240.17,-362.76C250.28,-352.86 262.8,-338.83 270.2,-324 288.25,-287.8 289.2,-275.45 289.2,-235 289.2,-235 289.2,-235 289.2,-161 289.2,-116.84 262.63,-71.56 243.21,-44.35"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="245.94,-42.16 237.19,-36.18 240.3,-46.31 245.94,-42.16"/>
+</g>
+<!-- world governments&#45;&gt;GPT&#45;5 -->
+<g id="edge17" class="edge">
+<title>world governments&#45;&gt;GPT&#45;5</title>
+<path fill="none" stroke="#ffeeff" d="M223.2,-36.31C223.2,-84.31 223.2,-219.44 223.2,-277.7"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="219.7,-277.97 223.2,-287.97 226.7,-277.97 219.7,-277.97"/>
+</g>
+<!-- tmpim&#45;&gt;world governments -->
+<g id="edge13" class="edge">
+<title>tmpim&#45;&gt;world governments</title>
+<path fill="none" stroke="#ffeeff" d="M84.91,-76.97C109.98,-66.46 146.86,-51 176.23,-38.69"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="177.61,-41.9 185.48,-34.81 174.9,-35.45 177.61,-41.9"/>
+</g>
+<!-- Leonhard Euler&#45;&gt;God -->
+<g id="edge19" class="edge">
+<title>Leonhard Euler&#45;&gt;God</title>
+<path fill="none" stroke="#ffeeff" d="M224.95,-431.7C224.84,-423.98 224.71,-414.71 224.58,-406.11"/>
+<polygon fill="#ffeeff" stroke="#ffeeff" points="228.08,-406.05 224.44,-396.1 221.09,-406.15 228.08,-406.05"/>
+</g>
+</g>
+</svg>
diff --git a/static/headers/default.png b/static/headers/default.png
new file mode 100644
index 0000000..e92a7ab
--- /dev/null
+++ b/static/headers/default.png
Binary files differ
diff --git a/static/headers/error.png b/static/headers/error.png
new file mode 100644
index 0000000..90b93b9
--- /dev/null
+++ b/static/headers/error.png
Binary files differ
diff --git a/static/qmark.png b/static/qmark.png
new file mode 100644
index 0000000..a28679d
--- /dev/null
+++ b/static/qmark.png
Binary files differ
diff --git a/static/question.svg b/static/question.svg
new file mode 100644
index 0000000..6c6d462
--- /dev/null
+++ b/static/question.svg
@@ -0,0 +1,13 @@
+<?xml version="1.0" standalone="no"?>
+<svg width="175" height="175" version="1.1" xmlns="http://www.w3.org/2000/svg">
+ <path
+ stroke="#6061a9"
+ stroke-width="8"
+ fill="none"
+ d="M 60,75
+ S 87.5,25 115,75
+ L 87.5,120
+ L 87.5,150
+ M 87.5,160
+ L 87.5,168" />
+</svg>
diff --git a/static/shadow.png b/static/shadow.png
new file mode 100644
index 0000000..9aa7619
--- /dev/null
+++ b/static/shadow.png
Binary files differ