summary refs log tree commit diff
path: root/www/git.causal.agency/cgit/filters
diff options
context:
space:
mode:
authorJune McEnroe <june@causal.agency>2020-12-27 18:45:04 -0500
committerJune McEnroe <june@causal.agency>2020-12-27 18:45:35 -0500
commit7b715a386a625ea3cf92b1281a4392e2473b830a (patch)
tree9353eea94b35e91f229be03061835b8fb788570a /www/git.causal.agency/cgit/filters
parentRemove 1sh (diff)
parentSquashed 'www/git.causal.agency/cgit/' content from commit 02221fd3 (diff)
downloadsrc-7b715a386a625ea3cf92b1281a4392e2473b830a.tar.gz
src-7b715a386a625ea3cf92b1281a4392e2473b830a.zip
Merge commit '85016e706cd00e527dba3fa83b2783dfb56a4ffa' as 'www/git.causal.agency/cgit'
From tag 'v1.2.3'.
Diffstat (limited to 'www/git.causal.agency/cgit/filters')
-rwxr-xr-xwww/git.causal.agency/cgit/filters/about-formatting.sh27
-rwxr-xr-xwww/git.causal.agency/cgit/filters/commit-links.sh28
-rw-r--r--www/git.causal.agency/cgit/filters/email-gravatar.lua35
-rwxr-xr-xwww/git.causal.agency/cgit/filters/email-gravatar.py39
-rw-r--r--www/git.causal.agency/cgit/filters/email-libravatar.lua36
-rw-r--r--www/git.causal.agency/cgit/filters/file-authentication.lua359
-rw-r--r--www/git.causal.agency/cgit/filters/gentoo-ldap-authentication.lua360
-rwxr-xr-xwww/git.causal.agency/cgit/filters/html-converters/man2html4
-rwxr-xr-xwww/git.causal.agency/cgit/filters/html-converters/md2html307
-rwxr-xr-xwww/git.causal.agency/cgit/filters/html-converters/rst2html2
-rwxr-xr-xwww/git.causal.agency/cgit/filters/html-converters/txt2html4
-rw-r--r--www/git.causal.agency/cgit/filters/owner-example.lua17
-rw-r--r--www/git.causal.agency/cgit/filters/simple-authentication.lua314
-rwxr-xr-xwww/git.causal.agency/cgit/filters/syntax-highlighting.py55
-rwxr-xr-xwww/git.causal.agency/cgit/filters/syntax-highlighting.sh121
15 files changed, 1708 insertions, 0 deletions
diff --git a/www/git.causal.agency/cgit/filters/about-formatting.sh b/www/git.causal.agency/cgit/filters/about-formatting.sh
new file mode 100755
index 00000000..85daf9c2
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/about-formatting.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+
+# This may be used with the about-filter or repo.about-filter setting in cgitrc.
+# It passes formatting of about pages to differing programs, depending on the usage.
+
+# Markdown support requires python and markdown-python.
+# RestructuredText support requires python and docutils.
+# Man page support requires groff.
+
+# The following environment variables can be used to retrieve the configuration
+# of the repository for which this script is called:
+# CGIT_REPO_URL        ( = repo.url       setting )
+# CGIT_REPO_NAME       ( = repo.name      setting )
+# CGIT_REPO_PATH       ( = repo.path      setting )
+# CGIT_REPO_OWNER      ( = repo.owner     setting )
+# CGIT_REPO_DEFBRANCH  ( = repo.defbranch setting )
+# CGIT_REPO_SECTION    ( = section        setting )
+# CGIT_REPO_CLONE_URL  ( = repo.clone-url setting )
+
+cd "$(dirname $0)/html-converters/"
+case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
+	*.markdown|*.mdown|*.md|*.mkd) exec ./md2html; ;;
+	*.rst) exec ./rst2html; ;;
+	*.[1-9]) exec ./man2html; ;;
+	*.htm|*.html) exec cat; ;;
+	*.txt|*) exec ./txt2html; ;;
+esac
diff --git a/www/git.causal.agency/cgit/filters/commit-links.sh b/www/git.causal.agency/cgit/filters/commit-links.sh
new file mode 100755
index 00000000..58819524
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/commit-links.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+# This script can be used to generate links in commit messages.
+#
+# To use this script, refer to this file with either the commit-filter or the
+# repo.commit-filter options in cgitrc.
+#
+# The following environment variables can be used to retrieve the configuration
+# of the repository for which this script is called:
+# CGIT_REPO_URL        ( = repo.url       setting )
+# CGIT_REPO_NAME       ( = repo.name      setting )
+# CGIT_REPO_PATH       ( = repo.path      setting )
+# CGIT_REPO_OWNER      ( = repo.owner     setting )
+# CGIT_REPO_DEFBRANCH  ( = repo.defbranch setting )
+# CGIT_REPO_SECTION    ( = section        setting )
+# CGIT_REPO_CLONE_URL  ( = repo.clone-url setting )
+#
+
+regex=''
+
+# This expression generates links to commits referenced by their SHA1.
+regex=$regex'
+s|\b([0-9a-fA-F]{7,40})\b|<a href="./?id=\1">\1</a>|g'
+
+# This expression generates links to a fictional bugtracker.
+regex=$regex'
+s|#([0-9]+)\b|<a href="http://bugs.example.com/?bug=\1">#\1</a>|g'
+
+sed -re "$regex"
diff --git a/www/git.causal.agency/cgit/filters/email-gravatar.lua b/www/git.causal.agency/cgit/filters/email-gravatar.lua
new file mode 100644
index 00000000..c39b490d
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/email-gravatar.lua
@@ -0,0 +1,35 @@
+-- This script may be used with the email-filter or repo.email-filter settings in cgitrc.
+-- It adds gravatar icons to author names. It is designed to be used with the lua:
+-- prefix in filters. It is much faster than the corresponding python script.
+--
+-- Requirements:
+-- 	luaossl
+-- 	<http://25thandclement.com/~william/projects/luaossl.html>
+--
+
+local digest = require("openssl.digest")
+
+function md5_hex(input)
+	local b = digest.new("md5"):final(input)
+	local x = ""
+	for i = 1, #b do
+		x = x .. string.format("%.2x", string.byte(b, i))
+	end
+	return x
+end
+
+function filter_open(email, page)
+	buffer = ""
+	md5 = md5_hex(email:sub(2, -2):lower())
+end
+
+function filter_close()
+	html("<img src='//www.gravatar.com/avatar/" .. md5 .. "?s=13&amp;d=retro' width='13' height='13' alt='Gravatar' /> " .. buffer)
+	return 0
+end
+
+function filter_write(str)
+	buffer = buffer .. str
+end
+
+
diff --git a/www/git.causal.agency/cgit/filters/email-gravatar.py b/www/git.causal.agency/cgit/filters/email-gravatar.py
new file mode 100755
index 00000000..d70440ea
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/email-gravatar.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+
+# Please prefer the email-gravatar.lua using lua: as a prefix over this script. This
+# script is very slow, in comparison.
+#
+# This script may be used with the email-filter or repo.email-filter settings in cgitrc.
+#
+# The following environment variables can be used to retrieve the configuration
+# of the repository for which this script is called:
+# CGIT_REPO_URL        ( = repo.url       setting )
+# CGIT_REPO_NAME       ( = repo.name      setting )
+# CGIT_REPO_PATH       ( = repo.path      setting )
+# CGIT_REPO_OWNER      ( = repo.owner     setting )
+# CGIT_REPO_DEFBRANCH  ( = repo.defbranch setting )
+# CGIT_REPO_SECTION    ( = section        setting )
+# CGIT_REPO_CLONE_URL  ( = repo.clone-url setting )
+#
+# It receives an email address on argv[1] and text on stdin. It prints
+# to stdout that text prepended by a gravatar at 10pt.
+
+import sys
+import hashlib
+import codecs
+
+email = sys.argv[1].lower().strip()
+if email[0] == '<':
+        email = email[1:]
+if email[-1] == '>':
+        email = email[0:-1]
+
+page = sys.argv[2]
+
+sys.stdin = codecs.getreader("utf-8")(sys.stdin.detach())
+sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach())
+
+md5 = hashlib.md5(email.encode()).hexdigest()
+text = sys.stdin.read().strip()
+
+print("<img src='//www.gravatar.com/avatar/" + md5 + "?s=13&amp;d=retro' width='13' height='13' alt='Gravatar' /> " + text)
diff --git a/www/git.causal.agency/cgit/filters/email-libravatar.lua b/www/git.causal.agency/cgit/filters/email-libravatar.lua
new file mode 100644
index 00000000..7336baf8
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/email-libravatar.lua
@@ -0,0 +1,36 @@
+-- This script may be used with the email-filter or repo.email-filter settings in cgitrc.
+-- It adds libravatar icons to author names. It is designed to be used with the lua:
+-- prefix in filters.
+--
+-- Requirements:
+-- 	luaossl
+-- 	<http://25thandclement.com/~william/projects/luaossl.html>
+--
+
+local digest = require("openssl.digest")
+
+function md5_hex(input)
+	local b = digest.new("md5"):final(input)
+	local x = ""
+	for i = 1, #b do
+		x = x .. string.format("%.2x", string.byte(b, i))
+	end
+	return x
+end
+
+function filter_open(email, page)
+	buffer = ""
+	md5 = md5_hex(email:sub(2, -2):lower())
+end
+
+function filter_close()
+	baseurl = os.getenv("HTTPS") and "https://seccdn.libravatar.org/" or "http://cdn.libravatar.org/"
+	html("<img src='" .. baseurl .. "avatar/" .. md5 .. "?s=13&amp;d=retro' width='13' height='13' alt='Libravatar' /> " .. buffer)
+	return 0
+end
+
+function filter_write(str)
+	buffer = buffer .. str
+end
+
+
diff --git a/www/git.causal.agency/cgit/filters/file-authentication.lua b/www/git.causal.agency/cgit/filters/file-authentication.lua
new file mode 100644
index 00000000..02488046
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/file-authentication.lua
@@ -0,0 +1,359 @@
+-- This script may be used with the auth-filter.
+--
+-- Requirements:
+-- 	luaossl
+-- 	<http://25thandclement.com/~william/projects/luaossl.html>
+-- 	luaposix
+-- 	<https://github.com/luaposix/luaposix>
+--
+local sysstat = require("posix.sys.stat")
+local unistd = require("posix.unistd")
+local rand = require("openssl.rand")
+local hmac = require("openssl.hmac")
+
+-- This file should contain a series of lines in the form of:
+--	username1:hash1
+--	username2:hash2
+--	username3:hash3
+--	...
+-- Hashes can be generated using something like `mkpasswd -m sha-512 -R 300000`.
+-- This file should not be world-readable.
+local users_filename = "/etc/cgit-auth/users"
+
+-- This file should contain a series of lines in the form of:
+-- 	groupname1:username1,username2,username3,...
+--	...
+local groups_filename = "/etc/cgit-auth/groups"
+
+-- This file should contain a series of lines in the form of:
+-- 	reponame1:groupname1,groupname2,groupname3,...
+--	...
+local repos_filename = "/etc/cgit-auth/repos"
+
+-- Set this to a path this script can write to for storing a persistent
+-- cookie secret, which should not be world-readable.
+local secret_filename = "/var/cache/cgit/auth-secret"
+
+--
+--
+-- Authentication functions follow below. Swap these out if you want different authentication semantics.
+--
+--
+
+-- Looks up a hash for a given user.
+function lookup_hash(user)
+	local line
+	for line in io.lines(users_filename) do
+		local u, h = string.match(line, "(.-):(.+)")
+		if u:lower() == user:lower() then
+			return h
+		end
+	end
+	return nil
+end
+
+-- Looks up users for a given repo.
+function lookup_users(repo)
+	local users = nil
+	local groups = nil
+	local line, group, user
+	for line in io.lines(repos_filename) do
+		local r, g = string.match(line, "(.-):(.+)")
+		if r == repo then
+			groups = { }
+			for group in string.gmatch(g, "([^,]+)") do
+				groups[group:lower()] = true
+			end
+			break
+		end
+	end
+	if groups == nil then
+		return nil
+	end
+	for line in io.lines(groups_filename) do
+		local g, u = string.match(line, "(.-):(.+)")
+		if groups[g:lower()] then
+			if users == nil then
+				users = { }
+			end
+			for user in string.gmatch(u, "([^,]+)") do
+				users[user:lower()] = true
+			end
+		end
+	end
+	return users
+end
+
+
+-- Sets HTTP cookie headers based on post and sets up redirection.
+function authenticate_post()
+	local hash = lookup_hash(post["username"])
+	local redirect = validate_value("redirect", post["redirect"])
+
+	if redirect == nil then
+		not_found()
+		return 0
+	end
+
+	redirect_to(redirect)
+
+	if hash == nil or hash ~= unistd.crypt(post["password"], hash) then
+		set_cookie("cgitauth", "")
+	else
+		-- One week expiration time
+		local username = secure_value("username", post["username"], os.time() + 604800)
+		set_cookie("cgitauth", username)
+	end
+
+	html("\n")
+	return 0
+end
+
+
+-- Returns 1 if the cookie is valid and 0 if it is not.
+function authenticate_cookie()
+	accepted_users = lookup_users(cgit["repo"])
+	if accepted_users == nil then
+		-- We return as valid if the repo is not protected.
+		return 1
+	end
+
+	local username = validate_value("username", get_cookie(http["cookie"], "cgitauth"))
+	if username == nil or not accepted_users[username:lower()] then
+		return 0
+	else
+		return 1
+	end
+end
+
+-- Prints the html for the login form.
+function body()
+	html("<h2>Authentication Required</h2>")
+	html("<form method='post' action='")
+	html_attr(cgit["login"])
+	html("'>")
+	html("<input type='hidden' name='redirect' value='")
+	html_attr(secure_value("redirect", cgit["url"], 0))
+	html("' />")
+	html("<table>")
+	html("<tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr>")
+	html("<tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr>")
+	html("<tr><td colspan='2'><input value='Login' type='submit' /></td></tr>")
+	html("</table></form>")
+
+	return 0
+end
+
+
+
+--
+--
+-- Wrapper around filter API, exposing the http table, the cgit table, and the post table to the above functions.
+--
+--
+
+local actions = {}
+actions["authenticate-post"] = authenticate_post
+actions["authenticate-cookie"] = authenticate_cookie
+actions["body"] = body
+
+function filter_open(...)
+	action = actions[select(1, ...)]
+
+	http = {}
+	http["cookie"] = select(2, ...)
+	http["method"] = select(3, ...)
+	http["query"] = select(4, ...)
+	http["referer"] = select(5, ...)
+	http["path"] = select(6, ...)
+	http["host"] = select(7, ...)
+	http["https"] = select(8, ...)
+
+	cgit = {}
+	cgit["repo"] = select(9, ...)
+	cgit["page"] = select(10, ...)
+	cgit["url"] = select(11, ...)
+	cgit["login"] = select(12, ...)
+
+end
+
+function filter_close()
+	return action()
+end
+
+function filter_write(str)
+	post = parse_qs(str)
+end
+
+
+--
+--
+-- Utility functions based on keplerproject/wsapi.
+--
+--
+
+function url_decode(str)
+	if not str then
+		return ""
+	end
+	str = string.gsub(str, "+", " ")
+	str = string.gsub(str, "%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
+	str = string.gsub(str, "\r\n", "\n")
+	return str
+end
+
+function url_encode(str)
+	if not str then
+		return ""
+	end
+	str = string.gsub(str, "\n", "\r\n")
+	str = string.gsub(str, "([^%w ])", function(c) return string.format("%%%02X", string.byte(c)) end)
+	str = string.gsub(str, " ", "+")
+	return str
+end
+
+function parse_qs(qs)
+	local tab = {}
+	for key, val in string.gmatch(qs, "([^&=]+)=([^&=]*)&?") do
+		tab[url_decode(key)] = url_decode(val)
+	end
+	return tab
+end
+
+function get_cookie(cookies, name)
+	cookies = string.gsub(";" .. cookies .. ";", "%s*;%s*", ";")
+	return url_decode(string.match(cookies, ";" .. name .. "=(.-);"))
+end
+
+function tohex(b)
+	local x = ""
+	for i = 1, #b do
+		x = x .. string.format("%.2x", string.byte(b, i))
+	end
+	return x
+end
+
+--
+--
+-- Cookie construction and validation helpers.
+--
+--
+
+local secret = nil
+
+-- Loads a secret from a file, creates a secret, or returns one from memory.
+function get_secret()
+	if secret ~= nil then
+		return secret
+	end
+	local secret_file = io.open(secret_filename, "r")
+	if secret_file == nil then
+		local old_umask = sysstat.umask(63)
+		local temporary_filename = secret_filename .. ".tmp." .. tohex(rand.bytes(16))
+		local temporary_file = io.open(temporary_filename, "w")
+		if temporary_file == nil then
+			os.exit(177)
+		end
+		temporary_file:write(tohex(rand.bytes(32)))
+		temporary_file:close()
+		unistd.link(temporary_filename, secret_filename) -- Intentionally fails in the case that another process is doing the same.
+		unistd.unlink(temporary_filename)
+		sysstat.umask(old_umask)
+		secret_file = io.open(secret_filename, "r")
+	end
+	if secret_file == nil then
+		os.exit(177)
+	end
+	secret = secret_file:read()
+	secret_file:close()
+	if secret:len() ~= 64 then
+		os.exit(177)
+	end
+	return secret
+end
+
+-- Returns value of cookie if cookie is valid. Otherwise returns nil.
+function validate_value(expected_field, cookie)
+	local i = 0
+	local value = ""
+	local field = ""
+	local expiration = 0
+	local salt = ""
+	local chmac = ""
+
+	if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then
+		return nil
+	end
+
+	for component in string.gmatch(cookie, "[^|]+") do
+		if i == 0 then
+			field = component
+		elseif i == 1 then
+			value = component
+		elseif i == 2 then
+			expiration = tonumber(component)
+			if expiration == nil then
+				expiration = -1
+			end
+		elseif i == 3 then
+			salt = component
+		elseif i == 4 then
+			chmac = component
+		else
+			break
+		end
+		i = i + 1
+	end
+
+	if chmac == nil or chmac:len() == 0 then
+		return nil
+	end
+
+	-- Lua hashes strings, so these comparisons are time invariant.
+	if chmac ~= tohex(hmac.new(get_secret(), "sha256"):final(field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt)) then
+		return nil
+	end
+
+	if expiration == -1 or (expiration ~= 0 and expiration <= os.time()) then
+		return nil
+	end
+
+	if url_decode(field) ~= expected_field then
+		return nil
+	end
+
+	return url_decode(value)
+end
+
+function secure_value(field, value, expiration)
+	if value == nil or value:len() <= 0 then
+		return ""
+	end
+
+	local authstr = ""
+	local salt = tohex(rand.bytes(16))
+	value = url_encode(value)
+	field = url_encode(field)
+	authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
+	authstr = authstr .. "|" .. tohex(hmac.new(get_secret(), "sha256"):final(authstr))
+	return authstr
+end
+
+function set_cookie(cookie, value)
+	html("Set-Cookie: " .. cookie .. "=" .. value .. "; HttpOnly")
+	if http["https"] == "yes" or http["https"] == "on" or http["https"] == "1" then
+		html("; secure")
+	end
+	html("\n")
+end
+
+function redirect_to(url)
+	html("Status: 302 Redirect\n")
+	html("Cache-Control: no-cache, no-store\n")
+	html("Location: " .. url .. "\n")
+end
+
+function not_found()
+	html("Status: 404 Not Found\n")
+	html("Cache-Control: no-cache, no-store\n\n")
+end
diff --git a/www/git.causal.agency/cgit/filters/gentoo-ldap-authentication.lua b/www/git.causal.agency/cgit/filters/gentoo-ldap-authentication.lua
new file mode 100644
index 00000000..673c88d1
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/gentoo-ldap-authentication.lua
@@ -0,0 +1,360 @@
+-- This script may be used with the auth-filter. Be sure to configure it as you wish.
+--
+-- Requirements:
+-- 	luaossl
+-- 	<http://25thandclement.com/~william/projects/luaossl.html>
+-- 	lualdap >= 1.2
+-- 	<https://git.zx2c4.com/lualdap/about/>
+-- 	luaposix
+-- 	<https://github.com/luaposix/luaposix>
+--
+local sysstat = require("posix.sys.stat")
+local unistd = require("posix.unistd")
+local lualdap = require("lualdap")
+local rand = require("openssl.rand")
+local hmac = require("openssl.hmac")
+
+--
+--
+-- Configure these variables for your settings.
+--
+--
+
+-- A list of password protected repositories, with which gentooAccess
+-- group is allowed to access each one.
+local protected_repos = {
+	glouglou = "infra",
+	portage = "dev"
+}
+
+-- Set this to a path this script can write to for storing a persistent
+-- cookie secret, which should be guarded.
+local secret_filename = "/var/cache/cgit/auth-secret"
+
+
+--
+--
+-- Authentication functions follow below. Swap these out if you want different authentication semantics.
+--
+--
+
+-- Sets HTTP cookie headers based on post and sets up redirection.
+function authenticate_post()
+	local redirect = validate_value("redirect", post["redirect"])
+
+	if redirect == nil then
+		not_found()
+		return 0
+	end
+
+	redirect_to(redirect)
+	
+	local groups = gentoo_ldap_user_groups(post["username"], post["password"])
+	if groups == nil then
+		set_cookie("cgitauth", "")
+	else
+		-- One week expiration time
+		set_cookie("cgitauth", secure_value("gentoogroups", table.concat(groups, ","), os.time() + 604800))
+	end
+
+	html("\n")
+	return 0
+end
+
+
+-- Returns 1 if the cookie is valid and 0 if it is not.
+function authenticate_cookie()
+	local required_group = protected_repos[cgit["repo"]]
+	if required_group == nil then
+		-- We return as valid if the repo is not protected.
+		return 1
+	end
+	
+	local user_groups = validate_value("gentoogroups", get_cookie(http["cookie"], "cgitauth"))
+	if user_groups == nil or user_groups == "" then
+		return 0
+	end
+	for group in string.gmatch(user_groups, "[^,]+") do
+		if group == required_group then
+			return 1
+		end
+	end
+	return 0
+end
+
+-- Prints the html for the login form.
+function body()
+	html("<h2>Gentoo LDAP Authentication Required</h2>")
+	html("<form method='post' action='")
+	html_attr(cgit["login"])
+	html("'>")
+	html("<input type='hidden' name='redirect' value='")
+	html_attr(secure_value("redirect", cgit["url"], 0))
+	html("' />")
+	html("<table>")
+	html("<tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr>")
+	html("<tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr>")
+	html("<tr><td colspan='2'><input value='Login' type='submit' /></td></tr>")
+	html("</table></form>")
+
+	return 0
+end
+
+--
+--
+-- Gentoo LDAP support.
+--
+--
+
+function gentoo_ldap_user_groups(username, password)
+	-- Ensure the user is alphanumeric
+	if username == nil or username:match("%W") then
+		return nil
+	end
+
+	local who = "uid=" .. username .. ",ou=devs,dc=gentoo,dc=org"
+
+	local ldap, err = lualdap.open_simple {
+		uri = "ldap://ldap1.gentoo.org",
+		who = who,
+		password = password,
+		starttls = true,
+		certfile = "/var/www/uwsgi/cgit/gentoo-ldap/star.gentoo.org.crt",
+		keyfile = "/var/www/uwsgi/cgit/gentoo-ldap/star.gentoo.org.key",
+		cacertfile = "/var/www/uwsgi/cgit/gentoo-ldap/ca.pem"
+	}
+	if ldap == nil then
+		return nil
+	end
+
+	local group_suffix = ".group"
+	local group_suffix_len = group_suffix:len()
+	local groups = {}
+	for dn, attribs in ldap:search { base = who, scope = "subtree" } do
+		local access = attribs["gentooAccess"]
+		if dn == who and access ~= nil then
+			for i, v in ipairs(access) do
+				local vlen = v:len()
+				if vlen > group_suffix_len and v:sub(-group_suffix_len) == group_suffix then
+					table.insert(groups, v:sub(1, vlen - group_suffix_len))
+				end
+			end
+		end
+	end
+
+	ldap:close()
+
+	return groups
+end
+
+--
+--
+-- Wrapper around filter API, exposing the http table, the cgit table, and the post table to the above functions.
+--
+--
+
+local actions = {}
+actions["authenticate-post"] = authenticate_post
+actions["authenticate-cookie"] = authenticate_cookie
+actions["body"] = body
+
+function filter_open(...)
+	action = actions[select(1, ...)]
+
+	http = {}
+	http["cookie"] = select(2, ...)
+	http["method"] = select(3, ...)
+	http["query"] = select(4, ...)
+	http["referer"] = select(5, ...)
+	http["path"] = select(6, ...)
+	http["host"] = select(7, ...)
+	http["https"] = select(8, ...)
+
+	cgit = {}
+	cgit["repo"] = select(9, ...)
+	cgit["page"] = select(10, ...)
+	cgit["url"] = select(11, ...)
+	cgit["login"] = select(12, ...)
+
+end
+
+function filter_close()
+	return action()
+end
+
+function filter_write(str)
+	post = parse_qs(str)
+end
+
+
+--
+--
+-- Utility functions based on keplerproject/wsapi.
+--
+--
+
+function url_decode(str)
+	if not str then
+		return ""
+	end
+	str = string.gsub(str, "+", " ")
+	str = string.gsub(str, "%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
+	str = string.gsub(str, "\r\n", "\n")
+	return str
+end
+
+function url_encode(str)
+	if not str then
+		return ""
+	end
+	str = string.gsub(str, "\n", "\r\n")
+	str = string.gsub(str, "([^%w ])", function(c) return string.format("%%%02X", string.byte(c)) end)
+	str = string.gsub(str, " ", "+")
+	return str
+end
+
+function parse_qs(qs)
+	local tab = {}
+	for key, val in string.gmatch(qs, "([^&=]+)=([^&=]*)&?") do
+		tab[url_decode(key)] = url_decode(val)
+	end
+	return tab
+end
+
+function get_cookie(cookies, name)
+	cookies = string.gsub(";" .. cookies .. ";", "%s*;%s*", ";")
+	return string.match(cookies, ";" .. name .. "=(.-);")
+end
+
+function tohex(b)
+	local x = ""
+	for i = 1, #b do
+		x = x .. string.format("%.2x", string.byte(b, i))
+	end
+	return x
+end
+
+--
+--
+-- Cookie construction and validation helpers.
+--
+--
+
+local secret = nil
+
+-- Loads a secret from a file, creates a secret, or returns one from memory.
+function get_secret()
+	if secret ~= nil then
+		return secret
+	end
+	local secret_file = io.open(secret_filename, "r")
+	if secret_file == nil then
+		local old_umask = sysstat.umask(63)
+		local temporary_filename = secret_filename .. ".tmp." .. tohex(rand.bytes(16))
+		local temporary_file = io.open(temporary_filename, "w")
+		if temporary_file == nil then
+			os.exit(177)
+		end
+		temporary_file:write(tohex(rand.bytes(32)))
+		temporary_file:close()
+		unistd.link(temporary_filename, secret_filename) -- Intentionally fails in the case that another process is doing the same.
+		unistd.unlink(temporary_filename)
+		sysstat.umask(old_umask)
+		secret_file = io.open(secret_filename, "r")
+	end
+	if secret_file == nil then
+		os.exit(177)
+	end
+	secret = secret_file:read()
+	secret_file:close()
+	if secret:len() ~= 64 then
+		os.exit(177)
+	end
+	return secret
+end
+
+-- Returns value of cookie if cookie is valid. Otherwise returns nil.
+function validate_value(expected_field, cookie)
+	local i = 0
+	local value = ""
+	local field = ""
+	local expiration = 0
+	local salt = ""
+	local chmac = ""
+
+	if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then
+		return nil
+	end
+
+	for component in string.gmatch(cookie, "[^|]+") do
+		if i == 0 then
+			field = component
+		elseif i == 1 then
+			value = component
+		elseif i == 2 then
+			expiration = tonumber(component)
+			if expiration == nil then
+				expiration = -1
+			end
+		elseif i == 3 then
+			salt = component
+		elseif i == 4 then
+			chmac = component
+		else
+			break
+		end
+		i = i + 1
+	end
+
+	if chmac == nil or chmac:len() == 0 then
+		return nil
+	end
+
+	-- Lua hashes strings, so these comparisons are time invariant.
+	if chmac ~= tohex(hmac.new(get_secret(), "sha256"):final(field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt)) then
+		return nil
+	end
+
+	if expiration == -1 or (expiration ~= 0 and expiration <= os.time()) then
+		return nil
+	end
+
+	if url_decode(field) ~= expected_field then
+		return nil
+	end
+
+	return url_decode(value)
+end
+
+function secure_value(field, value, expiration)
+	if value == nil or value:len() <= 0 then
+		return ""
+	end
+
+	local authstr = ""
+	local salt = tohex(rand.bytes(16))
+	value = url_encode(value)
+	field = url_encode(field)
+	authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
+	authstr = authstr .. "|" .. tohex(hmac.new(get_secret(), "sha256"):final(authstr))
+	return authstr
+end
+
+function set_cookie(cookie, value)
+	html("Set-Cookie: " .. cookie .. "=" .. value .. "; HttpOnly")
+	if http["https"] == "yes" or http["https"] == "on" or http["https"] == "1" then
+		html("; secure")
+	end
+	html("\n")
+end
+
+function redirect_to(url)
+	html("Status: 302 Redirect\n")
+	html("Cache-Control: no-cache, no-store\n")
+	html("Location: " .. url .. "\n")
+end
+
+function not_found()
+	html("Status: 404 Not Found\n")
+	html("Cache-Control: no-cache, no-store\n\n")
+end
diff --git a/www/git.causal.agency/cgit/filters/html-converters/man2html b/www/git.causal.agency/cgit/filters/html-converters/man2html
new file mode 100755
index 00000000..0ef78841
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/html-converters/man2html
@@ -0,0 +1,4 @@
+#!/bin/sh
+echo "<div style=\"font-family: monospace\">"
+groff -mandoc -T html -P -r -P -l | egrep -v '(<html>|<head>|<meta|<title>|</title>|</head>|<body>|</body>|</html>|<!DOCTYPE|"http://www.w3.org)'
+echo "</div>"
diff --git a/www/git.causal.agency/cgit/filters/html-converters/md2html b/www/git.causal.agency/cgit/filters/html-converters/md2html
new file mode 100755
index 00000000..dc20f42a
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/html-converters/md2html
@@ -0,0 +1,307 @@
+#!/usr/bin/env python3
+import markdown
+import sys
+import io
+from pygments.formatters import HtmlFormatter
+from markdown.extensions.toc import TocExtension
+sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8')
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+sys.stdout.write('''
+<style>
+.markdown-body {
+    font-size: 14px;
+    line-height: 1.6;
+    overflow: hidden;
+}
+.markdown-body>*:first-child {
+    margin-top: 0 !important;
+}
+.markdown-body>*:last-child {
+    margin-bottom: 0 !important;
+}
+.markdown-body a.absent {
+    color: #c00;
+}
+.markdown-body a.anchor {
+    display: block;
+    padding-left: 30px;
+    margin-left: -30px;
+    cursor: pointer;
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+}
+.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
+    margin: 20px 0 10px;
+    padding: 0;
+    font-weight: bold;
+    -webkit-font-smoothing: antialiased;
+    cursor: text;
+    position: relative;
+}
+.markdown-body h1 .mini-icon-link, .markdown-body h2 .mini-icon-link, .markdown-body h3 .mini-icon-link, .markdown-body h4 .mini-icon-link, .markdown-body h5 .mini-icon-link, .markdown-body h6 .mini-icon-link {
+    display: none;
+    color: #000;
+}
+.markdown-body h1:hover a.anchor, .markdown-body h2:hover a.anchor, .markdown-body h3:hover a.anchor, .markdown-body h4:hover a.anchor, .markdown-body h5:hover a.anchor, .markdown-body h6:hover a.anchor {
+    text-decoration: none;
+    line-height: 1;
+    padding-left: 0;
+    margin-left: -22px;
+    top: 15%;
+}
+.markdown-body h1:hover a.anchor .mini-icon-link, .markdown-body h2:hover a.anchor .mini-icon-link, .markdown-body h3:hover a.anchor .mini-icon-link, .markdown-body h4:hover a.anchor .mini-icon-link, .markdown-body h5:hover a.anchor .mini-icon-link, .markdown-body h6:hover a.anchor .mini-icon-link {
+    display: inline-block;
+}
+div#cgit .markdown-body h1 a.toclink, div#cgit .markdown-body h2 a.toclink, div#cgit .markdown-body h3 a.toclink, div#cgit .markdown-body h4 a.toclink, div#cgit .markdown-body h5 a.toclink, div#cgit .markdown-body h6 a.toclink {
+    color: black;
+}
+.markdown-body h1 tt, .markdown-body h1 code, .markdown-body h2 tt, .markdown-body h2 code, .markdown-body h3 tt, .markdown-body h3 code, .markdown-body h4 tt, .markdown-body h4 code, .markdown-body h5 tt, .markdown-body h5 code, .markdown-body h6 tt, .markdown-body h6 code {
+    font-size: inherit;
+}
+.markdown-body h1 {
+    font-size: 28px;
+    color: #000;
+}
+.markdown-body h2 {
+    font-size: 24px;
+    border-bottom: 1px solid #ccc;
+    color: #000;
+}
+.markdown-body h3 {
+    font-size: 18px;
+}
+.markdown-body h4 {
+    font-size: 16px;
+}
+.markdown-body h5 {
+    font-size: 14px;
+}
+.markdown-body h6 {
+    color: #777;
+    font-size: 14px;
+}
+.markdown-body p, .markdown-body blockquote, .markdown-body ul, .markdown-body ol, .markdown-body dl, .markdown-body table, .markdown-body pre {
+    margin: 15px 0;
+}
+.markdown-body hr {
+    background: transparent url("/dirty-shade.png") repeat-x 0 0;
+    border: 0 none;
+    color: #ccc;
+    height: 4px;
+    padding: 0;
+}
+.markdown-body>h2:first-child, .markdown-body>h1:first-child, .markdown-body>h1:first-child+h2, .markdown-body>h3:first-child, .markdown-body>h4:first-child, .markdown-body>h5:first-child, .markdown-body>h6:first-child {
+    margin-top: 0;
+    padding-top: 0;
+}
+.markdown-body a:first-child h1, .markdown-body a:first-child h2, .markdown-body a:first-child h3, .markdown-body a:first-child h4, .markdown-body a:first-child h5, .markdown-body a:first-child h6 {
+    margin-top: 0;
+    padding-top: 0;
+}
+.markdown-body h1+p, .markdown-body h2+p, .markdown-body h3+p, .markdown-body h4+p, .markdown-body h5+p, .markdown-body h6+p {
+    margin-top: 0;
+}
+.markdown-body li p.first {
+    display: inline-block;
+}
+.markdown-body ul, .markdown-body ol {
+    padding-left: 30px;
+}
+.markdown-body ul.no-list, .markdown-body ol.no-list {
+    list-style-type: none;
+    padding: 0;
+}
+.markdown-body ul li>:first-child, .markdown-body ul li ul:first-of-type, .markdown-body ul li ol:first-of-type, .markdown-body ol li>:first-child, .markdown-body ol li ul:first-of-type, .markdown-body ol li ol:first-of-type {
+    margin-top: 0px;
+}
+.markdown-body ul li p:last-of-type, .markdown-body ol li p:last-of-type {
+    margin-bottom: 0;
+}
+.markdown-body ul ul, .markdown-body ul ol, .markdown-body ol ol, .markdown-body ol ul {
+    margin-bottom: 0;
+}
+.markdown-body dl {
+    padding: 0;
+}
+.markdown-body dl dt {
+    font-size: 14px;
+    font-weight: bold;
+    font-style: italic;
+    padding: 0;
+    margin: 15px 0 5px;
+}
+.markdown-body dl dt:first-child {
+    padding: 0;
+}
+.markdown-body dl dt>:first-child {
+    margin-top: 0px;
+}
+.markdown-body dl dt>:last-child {
+    margin-bottom: 0px;
+}
+.markdown-body dl dd {
+    margin: 0 0 15px;
+    padding: 0 15px;
+}
+.markdown-body dl dd>:first-child {
+    margin-top: 0px;
+}
+.markdown-body dl dd>:last-child {
+    margin-bottom: 0px;
+}
+.markdown-body blockquote {
+    border-left: 4px solid #DDD;
+    padding: 0 15px;
+    color: #777;
+}
+.markdown-body blockquote>:first-child {
+    margin-top: 0px;
+}
+.markdown-body blockquote>:last-child {
+    margin-bottom: 0px;
+}
+.markdown-body table th {
+    font-weight: bold;
+}
+.markdown-body table th, .markdown-body table td {
+    border: 1px solid #ccc;
+    padding: 6px 13px;
+}
+.markdown-body table tr {
+    border-top: 1px solid #ccc;
+    background-color: #fff;
+}
+.markdown-body table tr:nth-child(2n) {
+    background-color: #f8f8f8;
+}
+.markdown-body img {
+    max-width: 100%;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box;
+}
+.markdown-body span.frame {
+    display: block;
+    overflow: hidden;
+}
+.markdown-body span.frame>span {
+    border: 1px solid #ddd;
+    display: block;
+    float: left;
+    overflow: hidden;
+    margin: 13px 0 0;
+    padding: 7px;
+    width: auto;
+}
+.markdown-body span.frame span img {
+    display: block;
+    float: left;
+}
+.markdown-body span.frame span span {
+    clear: both;
+    color: #333;
+    display: block;
+    padding: 5px 0 0;
+}
+.markdown-body span.align-center {
+    display: block;
+    overflow: hidden;
+    clear: both;
+}
+.markdown-body span.align-center>span {
+    display: block;
+    overflow: hidden;
+    margin: 13px auto 0;
+    text-align: center;
+}
+.markdown-body span.align-center span img {
+    margin: 0 auto;
+    text-align: center;
+}
+.markdown-body span.align-right {
+    display: block;
+    overflow: hidden;
+    clear: both;
+}
+.markdown-body span.align-right>span {
+    display: block;
+    overflow: hidden;
+    margin: 13px 0 0;
+    text-align: right;
+}
+.markdown-body span.align-right span img {
+    margin: 0;
+    text-align: right;
+}
+.markdown-body span.float-left {
+    display: block;
+    margin-right: 13px;
+    overflow: hidden;
+    float: left;
+}
+.markdown-body span.float-left span {
+    margin: 13px 0 0;
+}
+.markdown-body span.float-right {
+    display: block;
+    margin-left: 13px;
+    overflow: hidden;
+    float: right;
+}
+.markdown-body span.float-right>span {
+    display: block;
+    overflow: hidden;
+    margin: 13px auto 0;
+    text-align: right;
+}
+.markdown-body code, .markdown-body tt {
+    margin: 0 2px;
+    padding: 0px 5px;
+    border: 1px solid #eaeaea;
+    background-color: #f8f8f8;
+    border-radius: 3px;
+}
+.markdown-body code {
+    white-space: nowrap;
+}
+.markdown-body pre>code {
+    margin: 0;
+    padding: 0;
+    white-space: pre;
+    border: none;
+    background: transparent;
+}
+.markdown-body .highlight pre, .markdown-body pre {
+    background-color: #f8f8f8;
+    border: 1px solid #ccc;
+    font-size: 13px;
+    line-height: 19px;
+    overflow: auto;
+    padding: 6px 10px;
+    border-radius: 3px;
+}
+.markdown-body pre code, .markdown-body pre tt {
+    margin: 0;
+    padding: 0;
+    background-color: transparent;
+    border: none;
+}
+''')
+sys.stdout.write(HtmlFormatter(style='pastie').get_style_defs('.highlight'))
+sys.stdout.write('''
+</style>   
+''')
+sys.stdout.write("<div class='markdown-body'>")
+sys.stdout.flush()
+# Note: you may want to run this through bleach for sanitization
+markdown.markdownFromFile(
+	output_format="html5",
+	extensions=[
+		"markdown.extensions.fenced_code",
+		"markdown.extensions.codehilite",
+		"markdown.extensions.tables",
+		TocExtension(anchorlink=True)],
+	extension_configs={
+		"markdown.extensions.codehilite":{"css_class":"highlight"}})
+sys.stdout.write("</div>")
diff --git a/www/git.causal.agency/cgit/filters/html-converters/rst2html b/www/git.causal.agency/cgit/filters/html-converters/rst2html
new file mode 100755
index 00000000..02d90f81
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/html-converters/rst2html
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec rst2html.py --template <(echo -e "%(stylesheet)s\n%(body_pre_docinfo)s\n%(docinfo)s\n%(body)s")
diff --git a/www/git.causal.agency/cgit/filters/html-converters/txt2html b/www/git.causal.agency/cgit/filters/html-converters/txt2html
new file mode 100755
index 00000000..495eeceb
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/html-converters/txt2html
@@ -0,0 +1,4 @@
+#!/bin/sh
+echo "<pre>"
+sed "s|&|\\&amp;|g;s|'|\\&apos;|g;s|\"|\\&quot;|g;s|<|\\&lt;|g;s|>|\\&gt;|g"
+echo "</pre>"
diff --git a/www/git.causal.agency/cgit/filters/owner-example.lua b/www/git.causal.agency/cgit/filters/owner-example.lua
new file mode 100644
index 00000000..50fc25a8
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/owner-example.lua
@@ -0,0 +1,17 @@
+-- This script is an example of an owner-filter.  It replaces the
+-- usual query link with one to a fictional homepage.  This script may
+-- be used with the owner-filter or repo.owner-filter settings in
+-- cgitrc with the `lua:` prefix.
+
+function filter_open()
+	buffer = ""
+end
+
+function filter_close()
+	html(string.format("<a href=\"%s\">%s</a>", "http://wiki.example.com/about/" .. buffer, buffer))
+	return 0
+end
+
+function filter_write(str)
+	buffer = buffer .. str
+end
diff --git a/www/git.causal.agency/cgit/filters/simple-authentication.lua b/www/git.causal.agency/cgit/filters/simple-authentication.lua
new file mode 100644
index 00000000..23d34576
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/simple-authentication.lua
@@ -0,0 +1,314 @@
+-- This script may be used with the auth-filter. Be sure to configure it as you wish.
+--
+-- Requirements:
+-- 	luaossl
+-- 	<http://25thandclement.com/~william/projects/luaossl.html>
+-- 	luaposix
+-- 	<https://github.com/luaposix/luaposix>
+--
+local sysstat = require("posix.sys.stat")
+local unistd = require("posix.unistd")
+local rand = require("openssl.rand")
+local hmac = require("openssl.hmac")
+
+--
+--
+-- Configure these variables for your settings.
+--
+--
+
+-- A list of password protected repositories along with the users who can access them.
+local protected_repos = {
+	glouglou	= { laurent = true, jason = true },
+	qt		= { jason = true, bob = true }
+}
+
+-- A list of users and hashes, generated with `mkpasswd -m sha-512 -R 300000`.
+local users = {
+	jason		= "$6$rounds=300000$YYJct3n/o.ruYK$HhpSeuCuW1fJkpvMZOZzVizeLsBKcGA/aF2UPuV5v60JyH2MVSG6P511UMTj2F3H75.IT2HIlnvXzNb60FcZH1",
+	laurent		= "$6$rounds=300000$dP0KNHwYb3JKigT$pN/LG7rWxQ4HniFtx5wKyJXBJUKP7R01zTNZ0qSK/aivw8ywGAOdfYiIQFqFhZFtVGvr11/7an.nesvm8iJUi.",
+	bob		= "$6$rounds=300000$jCLCCt6LUpTz$PI1vvd1yaVYcCzqH8QAJFcJ60b6W/6sjcOsU7mAkNo7IE8FRGW1vkjF8I/T5jt/auv5ODLb1L4S2s.CAyZyUC"
+}
+
+-- Set this to a path this script can write to for storing a persistent
+-- cookie secret, which should be guarded.
+local secret_filename = "/var/cache/cgit/auth-secret"
+
+--
+--
+-- Authentication functions follow below. Swap these out if you want different authentication semantics.
+--
+--
+
+-- Sets HTTP cookie headers based on post and sets up redirection.
+function authenticate_post()
+	local hash = users[post["username"]]
+	local redirect = validate_value("redirect", post["redirect"])
+
+	if redirect == nil then
+		not_found()
+		return 0
+	end
+
+	redirect_to(redirect)
+
+	if hash == nil or hash ~= unistd.crypt(post["password"], hash) then
+		set_cookie("cgitauth", "")
+	else
+		-- One week expiration time
+		local username = secure_value("username", post["username"], os.time() + 604800)
+		set_cookie("cgitauth", username)
+	end
+
+	html("\n")
+	return 0
+end
+
+
+-- Returns 1 if the cookie is valid and 0 if it is not.
+function authenticate_cookie()
+	accepted_users = protected_repos[cgit["repo"]]
+	if accepted_users == nil then
+		-- We return as valid if the repo is not protected.
+		return 1
+	end
+
+	local username = validate_value("username", get_cookie(http["cookie"], "cgitauth"))
+	if username == nil or not accepted_users[username:lower()] then
+		return 0
+	else
+		return 1
+	end
+end
+
+-- Prints the html for the login form.
+function body()
+	html("<h2>Authentication Required</h2>")
+	html("<form method='post' action='")
+	html_attr(cgit["login"])
+	html("'>")
+	html("<input type='hidden' name='redirect' value='")
+	html_attr(secure_value("redirect", cgit["url"], 0))
+	html("' />")
+	html("<table>")
+	html("<tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr>")
+	html("<tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr>")
+	html("<tr><td colspan='2'><input value='Login' type='submit' /></td></tr>")
+	html("</table></form>")
+
+	return 0
+end
+
+
+
+--
+--
+-- Wrapper around filter API, exposing the http table, the cgit table, and the post table to the above functions.
+--
+--
+
+local actions = {}
+actions["authenticate-post"] = authenticate_post
+actions["authenticate-cookie"] = authenticate_cookie
+actions["body"] = body
+
+function filter_open(...)
+	action = actions[select(1, ...)]
+
+	http = {}
+	http["cookie"] = select(2, ...)
+	http["method"] = select(3, ...)
+	http["query"] = select(4, ...)
+	http["referer"] = select(5, ...)
+	http["path"] = select(6, ...)
+	http["host"] = select(7, ...)
+	http["https"] = select(8, ...)
+
+	cgit = {}
+	cgit["repo"] = select(9, ...)
+	cgit["page"] = select(10, ...)
+	cgit["url"] = select(11, ...)
+	cgit["login"] = select(12, ...)
+
+end
+
+function filter_close()
+	return action()
+end
+
+function filter_write(str)
+	post = parse_qs(str)
+end
+
+
+--
+--
+-- Utility functions based on keplerproject/wsapi.
+--
+--
+
+function url_decode(str)
+	if not str then
+		return ""
+	end
+	str = string.gsub(str, "+", " ")
+	str = string.gsub(str, "%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
+	str = string.gsub(str, "\r\n", "\n")
+	return str
+end
+
+function url_encode(str)
+	if not str then
+		return ""
+	end
+	str = string.gsub(str, "\n", "\r\n")
+	str = string.gsub(str, "([^%w ])", function(c) return string.format("%%%02X", string.byte(c)) end)
+	str = string.gsub(str, " ", "+")
+	return str
+end
+
+function parse_qs(qs)
+	local tab = {}
+	for key, val in string.gmatch(qs, "([^&=]+)=([^&=]*)&?") do
+		tab[url_decode(key)] = url_decode(val)
+	end
+	return tab
+end
+
+function get_cookie(cookies, name)
+	cookies = string.gsub(";" .. cookies .. ";", "%s*;%s*", ";")
+	return url_decode(string.match(cookies, ";" .. name .. "=(.-);"))
+end
+
+function tohex(b)
+	local x = ""
+	for i = 1, #b do
+		x = x .. string.format("%.2x", string.byte(b, i))
+	end
+	return x
+end
+
+--
+--
+-- Cookie construction and validation helpers.
+--
+--
+
+local secret = nil
+
+-- Loads a secret from a file, creates a secret, or returns one from memory.
+function get_secret()
+	if secret ~= nil then
+		return secret
+	end
+	local secret_file = io.open(secret_filename, "r")
+	if secret_file == nil then
+		local old_umask = sysstat.umask(63)
+		local temporary_filename = secret_filename .. ".tmp." .. tohex(rand.bytes(16))
+		local temporary_file = io.open(temporary_filename, "w")
+		if temporary_file == nil then
+			os.exit(177)
+		end
+		temporary_file:write(tohex(rand.bytes(32)))
+		temporary_file:close()
+		unistd.link(temporary_filename, secret_filename) -- Intentionally fails in the case that another process is doing the same.
+		unistd.unlink(temporary_filename)
+		sysstat.umask(old_umask)
+		secret_file = io.open(secret_filename, "r")
+	end
+	if secret_file == nil then
+		os.exit(177)
+	end
+	secret = secret_file:read()
+	secret_file:close()
+	if secret:len() ~= 64 then
+		os.exit(177)
+	end
+	return secret
+end
+
+-- Returns value of cookie if cookie is valid. Otherwise returns nil.
+function validate_value(expected_field, cookie)
+	local i = 0
+	local value = ""
+	local field = ""
+	local expiration = 0
+	local salt = ""
+	local chmac = ""
+
+	if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then
+		return nil
+	end
+
+	for component in string.gmatch(cookie, "[^|]+") do
+		if i == 0 then
+			field = component
+		elseif i == 1 then
+			value = component
+		elseif i == 2 then
+			expiration = tonumber(component)
+			if expiration == nil then
+				expiration = -1
+			end
+		elseif i == 3 then
+			salt = component
+		elseif i == 4 then
+			chmac = component
+		else
+			break
+		end
+		i = i + 1
+	end
+
+	if chmac == nil or chmac:len() == 0 then
+		return nil
+	end
+
+	-- Lua hashes strings, so these comparisons are time invariant.
+	if chmac ~= tohex(hmac.new(get_secret(), "sha256"):final(field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt)) then
+		return nil
+	end
+
+	if expiration == -1 or (expiration ~= 0 and expiration <= os.time()) then
+		return nil
+	end
+
+	if url_decode(field) ~= expected_field then
+		return nil
+	end
+
+	return url_decode(value)
+end
+
+function secure_value(field, value, expiration)
+	if value == nil or value:len() <= 0 then
+		return ""
+	end
+
+	local authstr = ""
+	local salt = tohex(rand.bytes(16))
+	value = url_encode(value)
+	field = url_encode(field)
+	authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
+	authstr = authstr .. "|" .. tohex(hmac.new(get_secret(), "sha256"):final(authstr))
+	return authstr
+end
+
+function set_cookie(cookie, value)
+	html("Set-Cookie: " .. cookie .. "=" .. value .. "; HttpOnly")
+	if http["https"] == "yes" or http["https"] == "on" or http["https"] == "1" then
+		html("; secure")
+	end
+	html("\n")
+end
+
+function redirect_to(url)
+	html("Status: 302 Redirect\n")
+	html("Cache-Control: no-cache, no-store\n")
+	html("Location: " .. url .. "\n")
+end
+
+function not_found()
+	html("Status: 404 Not Found\n")
+	html("Cache-Control: no-cache, no-store\n\n")
+end
diff --git a/www/git.causal.agency/cgit/filters/syntax-highlighting.py b/www/git.causal.agency/cgit/filters/syntax-highlighting.py
new file mode 100755
index 00000000..e912594c
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/syntax-highlighting.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+
+# This script uses Pygments and Python3. You must have both installed
+# for this to work.
+#
+# http://pygments.org/
+# http://python.org/
+#
+# It may be used with the source-filter or repo.source-filter settings
+# in cgitrc.
+#
+# The following environment variables can be used to retrieve the
+# configuration of the repository for which this script is called:
+# CGIT_REPO_URL        ( = repo.url       setting )
+# CGIT_REPO_NAME       ( = repo.name      setting )
+# CGIT_REPO_PATH       ( = repo.path      setting )
+# CGIT_REPO_OWNER      ( = repo.owner     setting )
+# CGIT_REPO_DEFBRANCH  ( = repo.defbranch setting )
+# CGIT_REPO_SECTION    ( = section        setting )
+# CGIT_REPO_CLONE_URL  ( = repo.clone-url setting )
+
+
+import sys
+import io
+from pygments import highlight
+from pygments.util import ClassNotFound
+from pygments.lexers import TextLexer
+from pygments.lexers import guess_lexer
+from pygments.lexers import guess_lexer_for_filename
+from pygments.formatters import HtmlFormatter
+
+
+sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace')
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
+data = sys.stdin.read()
+filename = sys.argv[1]
+formatter = HtmlFormatter(style='pastie', nobackground=True)
+
+try:
+	lexer = guess_lexer_for_filename(filename, data)
+except ClassNotFound:
+	# check if there is any shebang
+	if data[0:2] == '#!':
+		lexer = guess_lexer(data)
+	else:
+		lexer = TextLexer()
+except TypeError:
+	lexer = TextLexer()
+
+# highlight! :-)
+# printout pygments' css definitions as well
+sys.stdout.write('<style>')
+sys.stdout.write(formatter.get_style_defs('.highlight'))
+sys.stdout.write('</style>')
+sys.stdout.write(highlight(data, lexer, formatter, outfile=None))
diff --git a/www/git.causal.agency/cgit/filters/syntax-highlighting.sh b/www/git.causal.agency/cgit/filters/syntax-highlighting.sh
new file mode 100755
index 00000000..840bc34f
--- /dev/null
+++ b/www/git.causal.agency/cgit/filters/syntax-highlighting.sh
@@ -0,0 +1,121 @@
+#!/bin/sh
+# This script can be used to implement syntax highlighting in the cgit
+# tree-view by referring to this file with the source-filter or repo.source-
+# filter options in cgitrc.
+#
+# This script requires a shell supporting the ${var##pattern} syntax.
+# It is supported by at least dash and bash, however busybox environments
+# might have to use an external call to sed instead.
+#
+# Note: the highlight command (http://www.andre-simon.de/) uses css for syntax
+# highlighting, so you'll probably want something like the following included
+# in your css file:
+#
+# Style definition file generated by highlight 2.4.8, http://www.andre-simon.de/
+#
+# table.blob .num  { color:#2928ff; }
+# table.blob .esc  { color:#ff00ff; }
+# table.blob .str  { color:#ff0000; }
+# table.blob .dstr { color:#818100; }
+# table.blob .slc  { color:#838183; font-style:italic; }
+# table.blob .com  { color:#838183; font-style:italic; }
+# table.blob .dir  { color:#008200; }
+# table.blob .sym  { color:#000000; }
+# table.blob .kwa  { color:#000000; font-weight:bold; }
+# table.blob .kwb  { color:#830000; }
+# table.blob .kwc  { color:#000000; font-weight:bold; }
+# table.blob .kwd  { color:#010181; }
+#
+#
+# Style definition file generated by highlight 2.6.14, http://www.andre-simon.de/
+#
+# body.hl  { background-color:#ffffff; }
+# pre.hl   { color:#000000; background-color:#ffffff; font-size:10pt; font-family:'Courier New';}
+# .hl.num  { color:#2928ff; }
+# .hl.esc  { color:#ff00ff; }
+# .hl.str  { color:#ff0000; }
+# .hl.dstr { color:#818100; }
+# .hl.slc  { color:#838183; font-style:italic; }
+# .hl.com  { color:#838183; font-style:italic; }
+# .hl.dir  { color:#008200; }
+# .hl.sym  { color:#000000; }
+# .hl.line { color:#555555; }
+# .hl.mark { background-color:#ffffbb;}
+# .hl.kwa  { color:#000000; font-weight:bold; }
+# .hl.kwb  { color:#830000; }
+# .hl.kwc  { color:#000000; font-weight:bold; }
+# .hl.kwd  { color:#010181; }
+#
+#
+# Style definition file generated by highlight 3.8, http://www.andre-simon.de/
+#
+# body.hl { background-color:#e0eaee; }
+# pre.hl  { color:#000000; background-color:#e0eaee; font-size:10pt; font-family:'Courier New';}
+# .hl.num { color:#b07e00; }
+# .hl.esc { color:#ff00ff; }
+# .hl.str { color:#bf0303; }
+# .hl.pps { color:#818100; }
+# .hl.slc { color:#838183; font-style:italic; }
+# .hl.com { color:#838183; font-style:italic; }
+# .hl.ppc { color:#008200; }
+# .hl.opt { color:#000000; }
+# .hl.lin { color:#555555; }
+# .hl.kwa { color:#000000; font-weight:bold; }
+# .hl.kwb { color:#0057ae; }
+# .hl.kwc { color:#000000; font-weight:bold; }
+# .hl.kwd { color:#010181; }
+#
+#
+# Style definition file generated by highlight 3.13, http://www.andre-simon.de/
+#
+# body.hl { background-color:#e0eaee; }
+# pre.hl  { color:#000000; background-color:#e0eaee; font-size:10pt; font-family:'Courier New',monospace;}
+# .hl.num { color:#b07e00; }
+# .hl.esc { color:#ff00ff; }
+# .hl.str { color:#bf0303; }
+# .hl.pps { color:#818100; }
+# .hl.slc { color:#838183; font-style:italic; }
+# .hl.com { color:#838183; font-style:italic; }
+# .hl.ppc { color:#008200; }
+# .hl.opt { color:#000000; }
+# .hl.ipl { color:#0057ae; }
+# .hl.lin { color:#555555; }
+# .hl.kwa { color:#000000; font-weight:bold; }
+# .hl.kwb { color:#0057ae; }
+# .hl.kwc { color:#000000; font-weight:bold; }
+# .hl.kwd { color:#010181; }
+#
+#
+# The following environment variables can be used to retrieve the configuration
+# of the repository for which this script is called:
+# CGIT_REPO_URL        ( = repo.url       setting )
+# CGIT_REPO_NAME       ( = repo.name      setting )
+# CGIT_REPO_PATH       ( = repo.path      setting )
+# CGIT_REPO_OWNER      ( = repo.owner     setting )
+# CGIT_REPO_DEFBRANCH  ( = repo.defbranch setting )
+# CGIT_REPO_SECTION    ( = section        setting )
+# CGIT_REPO_CLONE_URL  ( = repo.clone-url setting )
+#
+
+# store filename and extension in local vars
+BASENAME="$1"
+EXTENSION="${BASENAME##*.}"
+
+[ "${BASENAME}" = "${EXTENSION}" ] && EXTENSION=txt
+[ -z "${EXTENSION}" ] && EXTENSION=txt
+
+# map Makefile and Makefile.* to .mk
+[ "${BASENAME%%.*}" = "Makefile" ] && EXTENSION=mk
+
+# highlight versions 2 and 3 have different commandline options. Specifically,
+# the -X option that is used for version 2 is replaced by the -O xhtml option
+# for version 3.
+#
+# Version 2 can be found (for example) on EPEL 5, while version 3 can be
+# found (for example) on EPEL 6.
+#
+# This is for version 2
+exec highlight --force -f -I -X -S "$EXTENSION" 2>/dev/null
+
+# This is for version 3
+#exec highlight --force -f -I -O xhtml -S "$EXTENSION" 2>/dev/null