From 1becf46aea2ab522c82212d1c74732a77b3142a9 Mon Sep 17 00:00:00 2001 From: the lemons Date: Fri, 7 Apr 2023 03:29:49 -0500 Subject: intial commit --- .gitignore | 2 + account.lua | 139 +++++++++++++++ auth.cgi | 385 ++++++++++++++++++++++++++++++++++++++++ cgi.lua | 162 +++++++++++++++++ citrine.lua | 172 ++++++++++++++++++ db.lua | 29 +++ forms.lua | 115 ++++++++++++ html.lua | 99 +++++++++++ md5.lua | 427 +++++++++++++++++++++++++++++++++++++++++++++ pbkdf2.lua | 35 ++++ service.lua | 60 +++++++ static/apiopage.png | Bin 0 -> 8783 bytes static/auth.dot | 21 +++ static/bg.jpeg | Bin 0 -> 31989 bytes static/bubble-tail.svg | 4 + static/citrine.css | 363 ++++++++++++++++++++++++++++++++++++++ static/gear.png | Bin 0 -> 1937 bytes static/graph.png | Bin 0 -> 109407 bytes static/graph.svg | 206 ++++++++++++++++++++++ static/headers/default.png | Bin 0 -> 9610 bytes static/headers/error.png | Bin 0 -> 8700 bytes static/qmark.png | Bin 0 -> 5031 bytes static/question.svg | 13 ++ static/shadow.png | Bin 0 -> 163 bytes 24 files changed, 2232 insertions(+) create mode 100644 .gitignore create mode 100644 account.lua create mode 100755 auth.cgi create mode 100644 cgi.lua create mode 100644 citrine.lua create mode 100644 db.lua create mode 100644 forms.lua create mode 100644 html.lua create mode 100644 md5.lua create mode 100644 pbkdf2.lua create mode 100644 service.lua create mode 100644 static/apiopage.png create mode 100644 static/auth.dot create mode 100644 static/bg.jpeg create mode 100644 static/bubble-tail.svg create mode 100644 static/citrine.css create mode 100644 static/gear.png create mode 100644 static/graph.png create mode 100644 static/graph.svg create mode 100644 static/headers/default.png create mode 100644 static/headers/error.png create mode 100644 static/qmark.png create mode 100644 static/question.svg create mode 100644 static/shadow.png 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 = { + ["<"] = "<", + [">"] = ">", + ['"'] = """, + ["&"] = "&", +} +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("") + else + assert(not inner) + end + end +end + +setmetatable(M, {__index = ele}) + +local function document(fn) + return function() + coroutine.yield("") + 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 Binary files /dev/null and b/static/apiopage.png 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 Binary files /dev/null and b/static/bg.jpeg 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 @@ + + + + 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 Binary files /dev/null and b/static/gear.png differ diff --git a/static/graph.png b/static/graph.png new file mode 100644 index 0000000..3143d98 Binary files /dev/null and b/static/graph.png 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 @@ + + + + + + + +auth.citrons.xyz + + + +auth.citrons.xyz + +auth.citrons.xyz + + + +wiki encyclopedia + +wiki encyclopedia + + + +auth.citrons.xyz->wiki encyclopedia + + + + + +citrons.xyz + +citrons.xyz + + + +auth.citrons.xyz->citrons.xyz + + + + + +gollark (GPT-5) + +gollark (GPT-5) + + + +auth.citrons.xyz->gollark (GPT-5) + + + + + +SCP-[REDACTED] + +SCP-[REDACTED] + + + +auth.citrons.xyz->SCP-[REDACTED] + + + + + +heav unauth service + +heav unauth service + + + +auth.citrons.xyz->heav unauth service + + + + + +Leonhard Euler + +Leonhard Euler + + + +wiki encyclopedia->Leonhard Euler + + + + + +citrons.xyz->SCP-[REDACTED] + + + + + +GTech™ + +GTech™ + + + +gollark (GPT-5)->GTech™ + + + + + +world governments + +world governments + + + +SCP-[REDACTED]->world governments + + + + + +God + +God + + + +GTech™->God + + + + + +RSAPI + +RSAPI + + + +GTech™->RSAPI + + + + + +GTech™->world governments + + + + + +tmpim + +tmpim + + + +GTech™->tmpim + + + + + +GPT-5 + +GPT-5 + + + +GPT-5->gollark (GPT-5) + + + + + +God->GPT-5 + + + + + +God->world governments + + + + + +world governments->GPT-5 + + + + + +tmpim->world governments + + + + + +Leonhard Euler->God + + + + + diff --git a/static/headers/default.png b/static/headers/default.png new file mode 100644 index 0000000..e92a7ab Binary files /dev/null and b/static/headers/default.png differ diff --git a/static/headers/error.png b/static/headers/error.png new file mode 100644 index 0000000..90b93b9 Binary files /dev/null and b/static/headers/error.png differ diff --git a/static/qmark.png b/static/qmark.png new file mode 100644 index 0000000..a28679d Binary files /dev/null and b/static/qmark.png 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 @@ + + + + diff --git a/static/shadow.png b/static/shadow.png new file mode 100644 index 0000000..9aa7619 Binary files /dev/null and b/static/shadow.png differ -- cgit v1.2.3