diff options
author | the lemons <citrons@mondecitronne.com> | 2023-04-07 03:29:49 -0500 |
---|---|---|
committer | the lemons <citrons@mondecitronne.com> | 2023-04-07 03:29:49 -0500 |
commit | 1becf46aea2ab522c82212d1c74732a77b3142a9 (patch) | |
tree | d2861a0584dc68b6271408fddf0cb03c4eefc76d |
intial commit
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | account.lua | 139 | ||||
-rwxr-xr-x | auth.cgi | 385 | ||||
-rw-r--r-- | cgi.lua | 162 | ||||
-rw-r--r-- | citrine.lua | 172 | ||||
-rw-r--r-- | db.lua | 29 | ||||
-rw-r--r-- | forms.lua | 115 | ||||
-rw-r--r-- | html.lua | 99 | ||||
-rw-r--r-- | md5.lua | 427 | ||||
-rw-r--r-- | pbkdf2.lua | 35 | ||||
-rw-r--r-- | service.lua | 60 | ||||
-rw-r--r-- | static/apiopage.png | bin | 0 -> 8783 bytes | |||
-rw-r--r-- | static/auth.dot | 21 | ||||
-rw-r--r-- | static/bg.jpeg | bin | 0 -> 31989 bytes | |||
-rw-r--r-- | static/bubble-tail.svg | 4 | ||||
-rw-r--r-- | static/citrine.css | 363 | ||||
-rw-r--r-- | static/gear.png | bin | 0 -> 1937 bytes | |||
-rw-r--r-- | static/graph.png | bin | 0 -> 109407 bytes | |||
-rw-r--r-- | static/graph.svg | 206 | ||||
-rw-r--r-- | static/headers/default.png | bin | 0 -> 9610 bytes | |||
-rw-r--r-- | static/headers/error.png | bin | 0 -> 8700 bytes | |||
-rw-r--r-- | static/qmark.png | bin | 0 -> 5031 bytes | |||
-rw-r--r-- | static/question.svg | 13 | ||||
-rw-r--r-- | static/shadow.png | bin | 0 -> 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) @@ -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 @@ -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 = { + ["<"] = "<", + [">"] = ">", + ['"'] = """, + ["&"] = "&", +} +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 @@ -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 Binary files differnew file mode 100644 index 0000000..686ec4c --- /dev/null +++ b/static/apiopage.png 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 Binary files differnew file mode 100644 index 0000000..4bb2fb8 --- /dev/null +++ b/static/bg.jpeg 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 Binary files differnew file mode 100644 index 0000000..ca987bc --- /dev/null +++ b/static/gear.png diff --git a/static/graph.png b/static/graph.png Binary files differnew file mode 100644 index 0000000..3143d98 --- /dev/null +++ b/static/graph.png 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->wiki encyclopedia --> +<g id="edge1" class="edge"> +<title>auth.citrons.xyz->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->citrons.xyz --> +<g id="edge2" class="edge"> +<title>auth.citrons.xyz->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-5) --> +<g id="node4" class="node"> +<title>gollark (GPT-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-5)</text> +</g> +<!-- auth.citrons.xyz->gollark (GPT-5) --> +<g id="edge3" class="edge"> +<title>auth.citrons.xyz->gollark (GPT-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-[REDACTED] --> +<g id="node5" class="node"> +<title>SCP-[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-[REDACTED]</text> +</g> +<!-- auth.citrons.xyz->SCP-[REDACTED] --> +<g id="edge4" class="edge"> +<title>auth.citrons.xyz->SCP-[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->heav unauth service --> +<g id="edge5" class="edge"> +<title>auth.citrons.xyz->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->Leonhard Euler --> +<g id="edge18" class="edge"> +<title>wiki encyclopedia->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->SCP-[REDACTED] --> +<g id="edge6" class="edge"> +<title>citrons.xyz->SCP-[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-5)->GTech™ --> +<g id="edge7" class="edge"> +<title>gollark (GPT-5)->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-[REDACTED]->world governments --> +<g id="edge15" class="edge"> +<title>SCP-[REDACTED]->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™->God --> +<g id="edge9" class="edge"> +<title>GTech™->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™->RSAPI --> +<g id="edge10" class="edge"> +<title>GTech™->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™->world governments --> +<g id="edge11" class="edge"> +<title>GTech™->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™->tmpim --> +<g id="edge12" class="edge"> +<title>GTech™->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-5 --> +<g id="node8" class="node"> +<title>GPT-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-5</text> +</g> +<!-- GPT-5->gollark (GPT-5) --> +<g id="edge8" class="edge"> +<title>GPT-5->gollark (GPT-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->GPT-5 --> +<g id="edge16" class="edge"> +<title>God->GPT-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->world governments --> +<g id="edge14" class="edge"> +<title>God->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->GPT-5 --> +<g id="edge17" class="edge"> +<title>world governments->GPT-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->world governments --> +<g id="edge13" class="edge"> +<title>tmpim->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->God --> +<g id="edge19" class="edge"> +<title>Leonhard Euler->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 Binary files differnew file mode 100644 index 0000000..e92a7ab --- /dev/null +++ b/static/headers/default.png diff --git a/static/headers/error.png b/static/headers/error.png Binary files differnew file mode 100644 index 0000000..90b93b9 --- /dev/null +++ b/static/headers/error.png diff --git a/static/qmark.png b/static/qmark.png Binary files differnew file mode 100644 index 0000000..a28679d --- /dev/null +++ b/static/qmark.png 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 Binary files differnew file mode 100644 index 0000000..9aa7619 --- /dev/null +++ b/static/shadow.png |