diff options
context:
space:
mode:
Diffstat (limited to 'custom/extensions')
-rw-r--r--custom/extensions/about-render.lua585
-rw-r--r--custom/extensions/auth-file.lua341
-rw-r--r--custom/extensions/auth-inline.lua296
-rw-r--r--custom/extensions/email-gravatar.lua97
-rw-r--r--custom/extensions/email-libravatar.lua93
-rw-r--r--custom/extensions/link-commits.lua146
-rw-r--r--custom/extensions/syntax-highlight.lua204
7 files changed, 917 insertions, 845 deletions
diff --git a/custom/extensions/about-render.lua b/custom/extensions/about-render.lua
index c4c1c93..ea13746 100644
--- a/custom/extensions/about-render.lua
+++ b/custom/extensions/about-render.lua
This diff is too large to be rendered inline. View it on its own page.
diff --git a/custom/extensions/auth-file.lua b/custom/extensions/auth-file.lua
index 2553233..8c731a3 100644
--- a/custom/extensions/auth-file.lua
+++ b/custom/extensions/auth-file.lua
@@ -1,40 +1,28 @@
--- cgit auth-filter that gates repositories behind a login form and a signed
--- session cookie.
---
--- This is the FILE-BACKED variant. The user accounts, the groups, and the
--- per-repository access lists are read from files on disk, whose paths are set
--- among the configuration values below. Edit those files without touching this
--- script. This suits larger or externally managed user sets.
---
--- The companion auth-inline.lua behaves identically but keeps its accounts and
--- access lists inline in the script itself, which suits a small fixed set of
--- users.
---
--- Enable it in cgitrc with
--- auth-filter=lua:/path/to/auth-file.lua
---
--- SUPPORTED LUA
---
--- Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because luaossl
--- has no 5.5 build. Match the runtime to the Lua that cgit is built against.
---
--- HTTPS IS RECOMMENDED
---
--- Serve cgit over HTTPS. Terminate TLS at the web server in front of cgit. The
--- session cookie is marked Secure by default, so a browser only sends it back
--- over HTTPS. If you genuinely run cgit over plain HTTP with no TLS anywhere,
--- set cookie_insecure below, otherwise the cookie is never returned and login
--- appears to loop.
---
--- DEPENDENCIES
+-- A cgit auth filter that puts chosen repositories behind a login form and a
+-- signed session cookie. cgit consults it on every request once cgitrc names
+-- it with auth-filter=lua:/path/to/auth-file.lua, and it answers the
+-- authenticate-cookie, authenticate-post and body actions the filter API
+-- defines. This variant keeps the accounts, the groups and the per-repository
+-- access lists in files on disk, which suits a user set that is large or
+-- maintained by something else. The companion auth-inline.lua behaves
+-- identically but carries the same lists inside the script.
+
+-- The script runs on Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT, and the runtime has to
+-- be the same Lua that cgit was built against. Lua 5.5 will not do, because
+-- luaossl has no 5.5 build.
--
--- luaossl OpenSSL binding, provides openssl.rand and openssl.hmac
--- <https://github.com/wahern/luaossl>
--- luaposix POSIX binding, provides posix.sys.stat and posix.unistd
--- <https://github.com/luaposix/luaposix>
+-- Serve cgit over HTTPS and terminate TLS in the web server in front of it.
+-- The session cookie is marked Secure by default, so a browser only sends it
+-- back over HTTPS, and on an instance served over plain HTTP with no TLS
+-- anywhere the cookie never comes back and login appears to loop until
+-- cookie_insecure below is set.
--
--- The reliable cross-platform install is LuaRocks, matched to your Lua
--- version. luaossl also needs the OpenSSL development headers present.
+-- Two libraries are needed. luaossl provides openssl.rand and openssl.hmac and
+-- lives at <https://github.com/wahern/luaossl>, and luaposix provides
+-- posix.sys.stat and posix.unistd and lives at
+-- <https://github.com/luaposix/luaposix>. The reliable cross-platform way to
+-- install them is LuaRocks matched to your Lua version, and luaossl also needs
+-- the OpenSSL development headers present.
--
-- # Debian and Ubuntu
-- sudo apt install luarocks libssl-dev
@@ -54,152 +42,158 @@
-- luarocks install luaossl OPENSSL_DIR="$(brew --prefix openssl)"
-- luarocks install luaposix
--
--- Some distributions also package these, for example lua-luaossl and lua-posix
--- on Debian. If you use a distribution package, make sure it is built for the
--- same Lua version as cgit.
+-- Some distributions package both as well, for example lua-luaossl and
+-- lua-posix on Debian, and such a package has to be built for the same Lua
+-- version as cgit.
--
--- SECURITY NOTES
---
--- The cookie carries only a username with no server-side session store, so
--- deleting an account does not revoke a cookie already issued until it
--- expires, and instances that share a secret file accept each other's cookies.
--- The login form carries no CSRF token. Both are acceptable for gating read
--- access to a git browser. Weigh them before guarding anything more sensitive.
+-- The cookie carries only a user name and there is no server-side session
+-- store, so deleting an account does not revoke a cookie already issued until
+-- it expires, and instances that share a secret file accept each other's
+-- cookies. The login form carries no CSRF token. Both are acceptable for
+-- gating read access to a git browser, so weigh them before guarding anything
+-- more sensitive.
local sysstat = require("posix.sys.stat")
local unistd = require("posix.unistd")
local rand = require("openssl.rand")
local hmac = require("openssl.hmac")
--- Configuration, edit these values. Nothing below them needs changing for
--- ordinary use.
+-- The values that follow are the configuration and are meant to be edited.
+-- Nothing below them needs changing for ordinary use.
--- Accounts, one per line, as username:hash. Generate a hash with
+-- Accounts live one per line as username:hash. Generate a hash with
-- mkpasswd -m sha-512 -R 300000
-- This file should not be world-readable.
local users_filename = "/etc/cgit-auth/users"
--- Group membership, one per line, as groupname:user1,user2,user3,...
+-- Group membership lives one per line as groupname:user1,user2,user3 and so
+-- on.
local groups_filename = "/etc/cgit-auth/groups"
--- Per-repository access, one per line, as reponame:group1,group2,...
--- A repository listed here is protected. One not listed is public.
+-- Per-repository access lives one per line as reponame:group1,group2 and so
+-- on. A repository named here is protected and one that is not named is
+-- public. The repository name has to match exactly, while group and user names
+-- match whatever their case.
local repos_filename = "/etc/cgit-auth/repos"
--- Where the cookie-signing secret is stored. It is created on first use. It
--- must be persistent and writable by cgit. Prefer a path OUTSIDE the cache
+-- Where the cookie-signing secret is stored, created on first use. It must be
+-- persistent and writable by cgit, and it is worth keeping outside the cache
-- root, because pruning the cache would delete a secret kept inside it and
-- invalidate every live session. This file should not be world-readable.
local secret_filename = "/var/lib/cgit/auth-secret"
--- How long a login stays valid, in seconds. Default one week.
+-- How long a login stays valid, in seconds. The default is one week.
local session_seconds = 7 * 24 * 60 * 60
--- Name of the session cookie.
local cookie_name = "cgitauth"
--- Path the cookie is scoped to. "/" covers the whole host. Set it to the cgit
--- root to scope the cookie more tightly.
+-- "/" covers the whole host. Set this to the cgit root to scope the cookie
+-- more tightly.
local cookie_path = "/"
--- Leave false so the cookie is marked Secure and only travels over HTTPS. Set
--- it true ONLY if cgit is served over plain HTTP with no TLS anywhere, see the
--- HTTPS note in the header.
+-- Leave this false so the cookie is marked Secure and only travels over HTTPS.
+-- Set it true only if cgit is served over plain HTTP with no TLS anywhere.
local cookie_insecure = false
-- A throwaway hash of the documented shape, used only to spend the same work
-- on a missing account as on a present one, so a failed login does not reveal
--- by timing whether the username exists.
+-- by timing whether the user name exists.
local dummy_hash = "$6$rounds=300000$0000000000000000$"
--- Module state shared across the open, write and close calls of one request.
+-- One request reaches this script as an open, a write and a close, so what the
+-- open decodes is kept here for the calls that follow.
local action, http, cgit, post
--- Account and access-list storage. This is the ONLY part that differs from
--- auth-inline.lua. Swap these two functions to change where accounts live.
+-- The two lookups below, account_hash and repo_userset, are the only part of
+-- this script that differs from auth-inline.lua. Replacing them is all it
+-- takes to keep accounts somewhere else.
local function trim(s)
return (string.gsub(s, "^%s*(.-)%s*$", "%1"))
end
--- Return the stored password hash for a user, or nil. Reads the users file
--- fresh each call. A missing or unreadable file, and any unparsable line, are
--- skipped rather than fatal.
+local function add_names(list, set)
+ for name in string.gmatch(list, "([^,]+)") do
+ set[trim(name):lower()] = true
+ end
+end
+
+-- A missing or unreadable users file is not fatal, and neither is a line that
+-- does not parse, so a broken file turns every login down rather than failing
+-- the request outright.
--
--- The hash is trimmed as well as the name. f:lines() strips the newline but
--- not a carriage return, so a users file saved with CRLF endings would
--- otherwise hand crypt a hash with a trailing \r and fail every login with
--- nothing in the log to say why.
+-- The hash is trimmed as well as the name because reading by line strips the
+-- newline but not a carriage return, so a users file saved with CRLF endings
+-- would otherwise hand crypt a hash with a trailing \r and fail every login
+-- with nothing in the log to say why.
function account_hash(user)
if user == nil then
return nil
end
local wanted = user:lower()
- local f = io.open(users_filename, "r")
- if f == nil then
+ local users_file = io.open(users_filename, "r")
+ if users_file == nil then
return nil
end
- for line in f:lines() do
- local u, h = string.match(line, "(.-):(.+)")
- if u ~= nil and trim(u):lower() == wanted then
- f:close()
- return trim(h)
+ for line in users_file:lines() do
+ local name, hash = string.match(line, "(.-):(.+)")
+ if name ~= nil and trim(name):lower() == wanted then
+ users_file:close()
+ return trim(hash)
end
end
- f:close()
+ users_file:close()
return nil
end
--- Return the set of users allowed to access a repository, keyed by lowercased
--- username, or nil if the repository is not protected. A protected repository
--- whose groups resolve to no users returns an EMPTY table, which denies
--- everyone rather than falling through to public.
+-- The users allowed into a repository come back keyed by lowercased name, and
+-- a repository the repos file does not name comes back as nil. A protected
+-- repository whose groups resolve to nobody comes back as an empty table
+-- instead, so it denies everyone rather than falling through to public.
function repo_userset(repo)
if repo == nil then
return nil
end
local groups = nil
- local f = io.open(repos_filename, "r")
- if f ~= nil then
- for line in f:lines() do
- local r, g = string.match(line, "(.-):(.+)")
- if r ~= nil and trim(r) == repo then
+ local repos_file = io.open(repos_filename, "r")
+ if repos_file ~= nil then
+ for line in repos_file:lines() do
+ local name, list = string.match(line, "(.-):(.+)")
+ if name ~= nil and trim(name) == repo then
groups = {}
- for group in string.gmatch(g, "([^,]+)") do
- groups[trim(group):lower()] = true
- end
+ add_names(list, groups)
break
end
end
- f:close()
+ repos_file:close()
end
if groups == nil then
return nil
end
local users = {}
- local gf = io.open(groups_filename, "r")
- if gf ~= nil then
- for line in gf:lines() do
- local g, u = string.match(line, "(.-):(.+)")
- if g ~= nil and groups[trim(g):lower()] then
- for user in string.gmatch(u, "([^,]+)") do
- users[trim(user):lower()] = true
- end
+ local groups_file = io.open(groups_filename, "r")
+ if groups_file ~= nil then
+ for line in groups_file:lines() do
+ local name, list = string.match(line, "(.-):(.+)")
+ if name ~= nil and groups[trim(name):lower()] then
+ add_names(list, users)
end
end
- gf:close()
+ groups_file:close()
end
return users
end
--- Utility functions based on keplerproject/wsapi.
+-- The URL helpers below are adapted from 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, "%%(%x%x)", function(hex)
+ return string.char(tonumber(hex, 16))
+ end)
str = string.gsub(str, "\r\n", "\n")
return str
end
@@ -209,30 +203,31 @@ function url_encode(str)
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, "([^%w ])", function(char)
+ return string.format("%%%02X", string.byte(char))
+ end)
str = string.gsub(str, " ", "+")
return str
end
-- Parse an application/x-www-form-urlencoded body. A value may itself contain
-- '=', for example a base64 password, so the value runs to the next '&'.
-function parse_qs(qs)
- local tab = {}
- for key, val in string.gmatch(qs or "", "([^&=]+)=([^&]*)") do
- tab[url_decode(key)] = url_decode(val)
+function parse_query(query)
+ local params = {}
+ for key, value in string.gmatch(query or "", "([^&=]+)=([^&]*)") do
+ params[url_decode(key)] = url_decode(value)
end
- return tab
+ return params
end
--- Escape the Lua pattern magic characters, so a name is matched literally.
local function pattern_escape(s)
return (string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end
--- Return the value of the named cookie, or nil. The stored token was already
--- url-encoded by secure_value, so it is returned verbatim, which keeps the
--- write path (set_cookie) and the read path symmetric. Decoding it here would
--- break the signature check for any value carrying a percent escape.
+-- The stored token was already URL encoded by secure_value, so it comes back
+-- verbatim and the write path in set_cookie stays symmetric with this read
+-- path. Decoding it here would break the signature check for any value
+-- carrying a percent escape.
--
-- The name is escaped because it lands in a pattern. A cookie_name holding a
-- magic character, say "cgit-auth", would otherwise read as a pattern and stop
@@ -242,16 +237,14 @@ function get_cookie(cookies, name)
return string.match(cookies, ";" .. pattern_escape(name) .. "=(.-);")
end
-function tohex(b)
- local x = ""
- for i = 1, #b do
- x = x .. string.format("%.2x", string.byte(b, i))
+function tohex(bytes)
+ local hex = ""
+ for i = 1, #bytes do
+ hex = hex .. string.format("%.2x", string.byte(bytes, i))
end
- return x
+ return hex
end
--- Cookie construction and validation helpers.
-
local secret = nil
-- Load the cookie-signing secret, creating it on first use. Failures raise,
@@ -263,21 +256,30 @@ function get_secret()
end
local secret_file = io.open(secret_filename, "r")
if secret_file == nil then
+ -- The secret is written under a tightened mask so it is not
+ -- created readable by anyone but the user cgit runs as, and
+ -- the old mask goes back on every way out.
local old_umask = sysstat.umask(63)
- local temporary_filename = secret_filename .. ".tmp." .. tohex(rand.bytes(16))
+ local temporary_filename = secret_filename .. ".tmp." ..
+ tohex(rand.bytes(16))
local temporary_file = io.open(temporary_filename, "w")
if temporary_file == nil then
sysstat.umask(old_umask)
- error("cgit auth: cannot create secret file " .. secret_filename)
+ error("cgit auth: cannot create secret file " ..
+ secret_filename)
end
local wrote = temporary_file:write(tohex(rand.bytes(32)))
local closed = temporary_file:close()
if not wrote or not closed then
os.remove(temporary_filename)
sysstat.umask(old_umask)
- error("cgit auth: failed writing secret file " .. secret_filename)
+ error("cgit auth: failed writing secret file " ..
+ secret_filename)
end
- unistd.link(temporary_filename, secret_filename) -- Intentionally fails if another worker won the race.
+ -- The link is meant to fail when another worker won the race,
+ -- which leaves that worker's secret in place rather than
+ -- replacing it and invalidating the sessions it just signed.
+ unistd.link(temporary_filename, secret_filename)
unistd.unlink(temporary_filename)
sysstat.umask(old_umask)
secret_file = io.open(secret_filename, "r")
@@ -289,7 +291,8 @@ function get_secret()
secret_file:close()
if secret == nil or secret:len() ~= 64 then
secret = nil
- error("cgit auth: secret file " .. secret_filename .. " is malformed, expected 64 hex characters")
+ error("cgit auth: secret file " .. secret_filename ..
+ " is malformed, expected 64 hex characters")
end
return secret
end
@@ -315,7 +318,7 @@ function validate_value(expected_field, cookie)
local field = ""
local expiration = 0
local salt = ""
- local chmac = ""
+ local signature = ""
if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then
return nil
@@ -327,9 +330,10 @@ function validate_value(expected_field, cookie)
elseif i == 1 then
value = component
elseif i == 2 then
- -- The expiration must be a plain integer. Rejecting other forms
- -- keeps the signed bytes canonical, since tonumber and tostring of
- -- "1e9" or "100.0" differ across Lua versions.
+ -- The expiration must be a plain integer, since
+ -- tonumber and tostring of "1e9" or "100.0" differ
+ -- across Lua versions and the signed bytes have to
+ -- come back byte for byte.
if not string.match(component, "^%d+$") then
return nil
end
@@ -337,19 +341,22 @@ function validate_value(expected_field, cookie)
elseif i == 3 then
salt = component
elseif i == 4 then
- chmac = component
+ signature = component
else
break
end
i = i + 1
end
- if chmac == nil or chmac:len() == 0 then
+ if signature == nil or signature:len() == 0 then
return nil
end
- -- Compare the HMAC without short-circuiting on the first mismatch.
- if not constant_equals(chmac, tohex(hmac.new(get_secret(), "sha256"):final(field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt))) then
+ local payload = field .. "|" .. value .. "|" ..
+ tostring(expiration) .. "|" .. salt
+ local expected_signature =
+ tohex(hmac.new(get_secret(), "sha256"):final(payload))
+ if not constant_equals(signature, expected_signature) then
return nil
end
@@ -371,6 +378,8 @@ function validate_value(expected_field, cookie)
return decoded
end
+-- The layout built here is what validate_value takes apart again, so the two
+-- have to move together.
function secure_value(field, value, expiration)
if value == nil or value:len() <= 0 then
return ""
@@ -379,13 +388,15 @@ function secure_value(field, value, expiration)
local salt = tohex(rand.bytes(16))
value = url_encode(value)
field = url_encode(field)
- local authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
- authstr = authstr .. "|" .. tohex(hmac.new(get_secret(), "sha256"):final(authstr))
- return authstr
+ local payload = field .. "|" .. value .. "|" ..
+ tostring(expiration) .. "|" .. salt
+ local signature = tohex(hmac.new(get_secret(), "sha256"):final(payload))
+ return payload .. "|" .. signature
end
--- Strip control characters that could split an HTTP response header.
-function strip_ctl(s)
+-- A control character in a header value would let that value split the
+-- response, so it is dropped rather than escaped.
+function strip_controls(s)
return (string.gsub(s or "", "%c", ""))
end
@@ -397,29 +408,33 @@ function constant_equals(a, b)
end
local diff = 0
for i = 1, #a do
- local d = a:byte(i) - b:byte(i)
- diff = diff + d * d
+ local delta = a:byte(i) - b:byte(i)
+ diff = diff + delta * delta
end
return diff == 0
end
+-- An empty value is how a login is cleared, since Max-Age=0 tells the browser
+-- to drop the cookie it already holds.
function set_cookie(cookie, value)
- local attrs = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path
+ local attributes = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path
if not cookie_insecure then
- attrs = attrs .. "; Secure"
+ attributes = attributes .. "; Secure"
end
if value == "" then
- attrs = attrs .. "; Max-Age=0"
+ attributes = attributes .. "; Max-Age=0"
elseif session_seconds > 0 then
- attrs = attrs .. "; Max-Age=" .. tostring(session_seconds)
+ attributes = attributes ..
+ "; Max-Age=" .. tostring(session_seconds)
end
- html("Set-Cookie: " .. cookie .. "=" .. strip_ctl(value) .. attrs .. "\n")
+ html("Set-Cookie: " .. cookie .. "=" ..
+ strip_controls(value) .. attributes .. "\n")
end
function redirect_to(url)
html("Status: 302 Redirect\n")
html("Cache-Control: no-cache, no-store\n")
- html("Location: " .. strip_ctl(url) .. "\n")
+ html("Location: " .. strip_controls(url) .. "\n")
end
function not_found()
@@ -427,9 +442,12 @@ function not_found()
html("Cache-Control: no-cache, no-store\n\n")
end
--- Authentication actions. Identical to auth-inline.lua from here down.
+-- The three actions cgit can ask for follow, and they are the same in both
+-- variants, so a change to one belongs in the other.
--- Sets HTTP cookie headers based on post and sets up redirection.
+-- The redirect goes out before the password is checked, so a wrong password
+-- and a right one answer with the same status and location and differ only in
+-- the cookie.
function authenticate_post()
local redirect = validate_value("redirect", post["redirect"])
@@ -454,7 +472,8 @@ function authenticate_post()
end
if ok then
- set_cookie(cookie_name, secure_value("username", username, os.time() + session_seconds))
+ set_cookie(cookie_name, secure_value("username", username,
+ os.time() + session_seconds))
else
set_cookie(cookie_name, "")
end
@@ -463,22 +482,26 @@ function authenticate_post()
return 0
end
--- Returns 1 if the cookie is valid and 0 if it is not.
+-- cgit reads the answer, where 1 lets the request through and 0 sends it to
+-- the login form.
function authenticate_cookie()
local accepted_users = repo_userset(cgit["repo"])
if accepted_users == nil then
- -- The repository is not protected.
+ -- A repository nothing lists is public.
return 1
end
- local username = validate_value("username", get_cookie(http["cookie"], cookie_name))
+ local username = validate_value("username",
+ get_cookie(http["cookie"], cookie_name))
if username == nil or not accepted_users[username:lower()] then
return 0
end
return 1
end
--- Prints the html for the login form.
+-- cgit calls this to fill the page body once the cookie has been turned down.
+-- The form carries a signed redirect token, so the browser lands back on the
+-- page that was asked for.
function body()
local target = cgit["url"]
if not is_safe_redirect(target) then
@@ -501,8 +524,10 @@ function body()
return 0
end
--- Wrapper around the filter API, exposing the http, cgit and post tables to
--- the functions above.
+-- cgit calls filter_open with the action name followed by the request fields
+-- in a fixed order, so they are unpacked here into the tables the functions
+-- above read. Only a post reaches filter_write, carrying the form body, and
+-- filter_close is where the action finally runs and answers cgit.
local actions = {}
actions["authenticate-post"] = authenticate_post
@@ -532,12 +557,12 @@ end
function filter_close()
if action == nil then
- -- Unknown action, deny rather than raise.
+ -- An unknown action denies rather than raising.
return 0
end
return action()
end
function filter_write(str)
- post = parse_qs(str)
+ post = parse_query(str)
end
diff --git a/custom/extensions/auth-inline.lua b/custom/extensions/auth-inline.lua
index b50b59a..500d705 100644
--- a/custom/extensions/auth-inline.lua
+++ b/custom/extensions/auth-inline.lua
@@ -1,40 +1,28 @@
--- cgit auth-filter that gates repositories behind a login form and a signed
--- session cookie.
---
--- This is the INLINE variant. The user accounts and the per-repository access
--- lists are written directly in this script, in the two tables among the
--- configuration values below. Edit them here and reload. This suits a small
--- fixed set of users that rarely changes.
---
--- The companion auth-file.lua behaves identically but reads its accounts and
--- access lists from files on disk instead, which suits larger or externally
--- managed user sets.
---
--- Enable it in cgitrc with
--- auth-filter=lua:/path/to/auth-inline.lua
---
--- SUPPORTED LUA
---
--- Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because luaossl
--- has no 5.5 build. Match the runtime to the Lua that cgit is built against.
---
--- HTTPS IS RECOMMENDED
---
--- Serve cgit over HTTPS. Terminate TLS at the web server in front of cgit. The
--- session cookie is marked Secure by default, so a browser only sends it back
--- over HTTPS. If you genuinely run cgit over plain HTTP with no TLS anywhere,
--- set cookie_insecure below, otherwise the cookie is never returned and login
--- appears to loop.
---
--- DEPENDENCIES
+-- A cgit auth filter that puts chosen repositories behind a login form and a
+-- signed session cookie. cgit consults it on every request once cgitrc names
+-- it with auth-filter=lua:/path/to/auth-inline.lua, and it answers the
+-- authenticate-cookie, authenticate-post and body actions the filter API
+-- defines. This variant carries the accounts and the per-repository access
+-- lists in two tables inside the script, which suits a small fixed set of
+-- users. The companion auth-file.lua behaves identically but reads the same
+-- lists from files on disk.
+
+-- The script runs on Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT, and the runtime has to
+-- be the same Lua that cgit was built against. Lua 5.5 will not do, because
+-- luaossl has no 5.5 build.
--
--- luaossl OpenSSL binding, provides openssl.rand and openssl.hmac
--- <https://github.com/wahern/luaossl>
--- luaposix POSIX binding, provides posix.sys.stat and posix.unistd
--- <https://github.com/luaposix/luaposix>
+-- Serve cgit over HTTPS and terminate TLS in the web server in front of it.
+-- The session cookie is marked Secure by default, so a browser only sends it
+-- back over HTTPS, and on an instance served over plain HTTP with no TLS
+-- anywhere the cookie never comes back and login appears to loop until
+-- cookie_insecure below is set.
--
--- The reliable cross-platform install is LuaRocks, matched to your Lua
--- version. luaossl also needs the OpenSSL development headers present.
+-- Two libraries are needed. luaossl provides openssl.rand and openssl.hmac and
+-- lives at <https://github.com/wahern/luaossl>, and luaposix provides
+-- posix.sys.stat and posix.unistd and lives at
+-- <https://github.com/luaposix/luaposix>. The reliable cross-platform way to
+-- install them is LuaRocks matched to your Lua version, and luaossl also needs
+-- the OpenSSL development headers present.
--
-- # Debian and Ubuntu
-- sudo apt install luarocks libssl-dev
@@ -54,95 +42,94 @@
-- luarocks install luaossl OPENSSL_DIR="$(brew --prefix openssl)"
-- luarocks install luaposix
--
--- Some distributions also package these, for example lua-luaossl and lua-posix
--- on Debian. If you use a distribution package, make sure it is built for the
--- same Lua version as cgit.
+-- Some distributions package both as well, for example lua-luaossl and
+-- lua-posix on Debian, and such a package has to be built for the same Lua
+-- version as cgit.
--
--- SECURITY NOTES
---
--- The cookie carries only a username with no server-side session store, so
--- deleting an account does not revoke a cookie already issued until it
--- expires, and instances that share a secret file accept each other's cookies.
--- The login form carries no CSRF token. Both are acceptable for gating read
--- access to a git browser. Weigh them before guarding anything more sensitive.
+-- The cookie carries only a user name and there is no server-side session
+-- store, so deleting an account does not revoke a cookie already issued until
+-- it expires, and instances that share a secret file accept each other's
+-- cookies. The login form carries no CSRF token. Both are acceptable for
+-- gating read access to a git browser, so weigh them before guarding anything
+-- more sensitive.
local sysstat = require("posix.sys.stat")
local unistd = require("posix.unistd")
local rand = require("openssl.rand")
local hmac = require("openssl.hmac")
--- Configuration, edit these values. Nothing below them needs changing for
--- ordinary use.
+-- The values that follow are the configuration and are meant to be edited.
+-- Nothing below them needs changing for ordinary use.
--- Protected repositories and the users allowed into each. A repository listed
--- here is protected. One not listed is public. Keys and user names are matched
--- case-insensitively. REPLACE THE EXAMPLES BELOW, they are commented out so an
--- unedited copy protects nothing and grants no accounts.
+-- Protected repositories and the users allowed into each. A repository named
+-- here is protected and one that is not named is public. The repository key
+-- has to match exactly, while user names match whatever their case. Replace
+-- the examples below with your own. They are commented out, so an unedited
+-- copy protects nothing and grants no accounts.
local protected_repos = {
-- ["secret-repo"] = { alice = true, bob = true },
-- ["another"] = { alice = true },
}
--- Accounts as name = hash. Generate a hash with
+-- Accounts as name and hash. Generate a hash with
-- mkpasswd -m sha-512 -R 300000
--- REPLACE THE EXAMPLES BELOW. The commented lines are not real credentials and
--- must not be deployed as-is.
+-- Replace the examples below. They are not real credentials and must not be
+-- deployed as they stand.
local users = {
-- alice = "$6$rounds=300000$REPLACE_THIS_SALT$REPLACE_THIS_HASH",
-- bob = "$6$rounds=300000$REPLACE_THIS_SALT$REPLACE_THIS_HASH",
}
--- Where the cookie-signing secret is stored. It is created on first use. It
--- must be persistent and writable by cgit. Prefer a path OUTSIDE the cache
+-- Where the cookie-signing secret is stored, created on first use. It must be
+-- persistent and writable by cgit, and it is worth keeping outside the cache
-- root, because pruning the cache would delete a secret kept inside it and
-- invalidate every live session. This file should not be world-readable.
local secret_filename = "/var/lib/cgit/auth-secret"
--- How long a login stays valid, in seconds. Default one week.
+-- How long a login stays valid, in seconds. The default is one week.
local session_seconds = 7 * 24 * 60 * 60
--- Name of the session cookie.
local cookie_name = "cgitauth"
--- Path the cookie is scoped to. "/" covers the whole host. Set it to the cgit
--- root to scope the cookie more tightly.
+-- "/" covers the whole host. Set this to the cgit root to scope the cookie
+-- more tightly.
local cookie_path = "/"
--- Leave false so the cookie is marked Secure and only travels over HTTPS. Set
--- it true ONLY if cgit is served over plain HTTP with no TLS anywhere, see the
--- HTTPS note in the header.
+-- Leave this false so the cookie is marked Secure and only travels over HTTPS.
+-- Set it true only if cgit is served over plain HTTP with no TLS anywhere.
local cookie_insecure = false
-- A throwaway hash of the documented shape, used only to spend the same work
-- on a missing account as on a present one, so a failed login does not reveal
--- by timing whether the username exists.
+-- by timing whether the user name exists.
local dummy_hash = "$6$rounds=300000$0000000000000000$"
--- Module state shared across the open, write and close calls of one request.
+-- One request reaches this script as an open, a write and a close, so what the
+-- open decodes is kept here for the calls that follow.
local action, http, cgit, post
--- Account and access-list storage. This is the ONLY part that differs from
--- auth-file.lua. Swap these two functions to change where accounts live.
+-- The two lookups below, account_hash and repo_userset, are the only part of
+-- this script that differs from auth-file.lua. Replacing them is all it takes
+-- to keep accounts somewhere else.
--- Fold the configured tables to lowercased user names once, so lookups match
--- case-insensitively the same way auth-file.lua does. Repository names keep
--- their case.
+-- The configured tables are folded to lowercased user names once, so that a
+-- lookup matches whatever case the login form was filled in with, the way
+-- auth-file.lua does. Repository names keep their case.
do
- local folded = {}
+ local folded_users = {}
for name, hash in pairs(users) do
- folded[tostring(name):lower()] = hash
+ folded_users[tostring(name):lower()] = hash
end
- users = folded
- for repo, set in pairs(protected_repos) do
- local fs = {}
- for name, allowed in pairs(set) do
- fs[tostring(name):lower()] = allowed
+ users = folded_users
+ for repo, members in pairs(protected_repos) do
+ local folded = {}
+ for name, allowed in pairs(members) do
+ folded[tostring(name):lower()] = allowed
end
- protected_repos[repo] = fs
+ protected_repos[repo] = folded
end
end
--- Return the stored password hash for a user, or nil.
function account_hash(user)
if user == nil then
return nil
@@ -150,8 +137,8 @@ function account_hash(user)
return users[user:lower()]
end
--- Return the set of users allowed to access a repository, keyed by lowercased
--- user name, or nil if the repository is not protected.
+-- The users allowed into a repository come back keyed by lowercased name, and
+-- a repository the table does not name comes back as nil.
function repo_userset(repo)
if repo == nil then
return nil
@@ -159,14 +146,16 @@ function repo_userset(repo)
return protected_repos[repo]
end
--- Utility functions based on keplerproject/wsapi.
+-- The URL helpers below are adapted from 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, "%%(%x%x)", function(hex)
+ return string.char(tonumber(hex, 16))
+ end)
str = string.gsub(str, "\r\n", "\n")
return str
end
@@ -176,30 +165,31 @@ function url_encode(str)
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, "([^%w ])", function(char)
+ return string.format("%%%02X", string.byte(char))
+ end)
str = string.gsub(str, " ", "+")
return str
end
-- Parse an application/x-www-form-urlencoded body. A value may itself contain
-- '=', for example a base64 password, so the value runs to the next '&'.
-function parse_qs(qs)
- local tab = {}
- for key, val in string.gmatch(qs or "", "([^&=]+)=([^&]*)") do
- tab[url_decode(key)] = url_decode(val)
+function parse_query(query)
+ local params = {}
+ for key, value in string.gmatch(query or "", "([^&=]+)=([^&]*)") do
+ params[url_decode(key)] = url_decode(value)
end
- return tab
+ return params
end
--- Escape the Lua pattern magic characters, so a name is matched literally.
local function pattern_escape(s)
return (string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end
--- Return the value of the named cookie, or nil. The stored token was already
--- url-encoded by secure_value, so it is returned verbatim, which keeps the
--- write path (set_cookie) and the read path symmetric. Decoding it here would
--- break the signature check for any value carrying a percent escape.
+-- The stored token was already URL encoded by secure_value, so it comes back
+-- verbatim and the write path in set_cookie stays symmetric with this read
+-- path. Decoding it here would break the signature check for any value
+-- carrying a percent escape.
--
-- The name is escaped because it lands in a pattern. A cookie_name holding a
-- magic character, say "cgit-auth", would otherwise read as a pattern and stop
@@ -209,16 +199,14 @@ function get_cookie(cookies, name)
return string.match(cookies, ";" .. pattern_escape(name) .. "=(.-);")
end
-function tohex(b)
- local x = ""
- for i = 1, #b do
- x = x .. string.format("%.2x", string.byte(b, i))
+function tohex(bytes)
+ local hex = ""
+ for i = 1, #bytes do
+ hex = hex .. string.format("%.2x", string.byte(bytes, i))
end
- return x
+ return hex
end
--- Cookie construction and validation helpers.
-
local secret = nil
-- Load the cookie-signing secret, creating it on first use. Failures raise,
@@ -230,21 +218,30 @@ function get_secret()
end
local secret_file = io.open(secret_filename, "r")
if secret_file == nil then
+ -- The secret is written under a tightened mask so it is not
+ -- created readable by anyone but the user cgit runs as, and
+ -- the old mask goes back on every way out.
local old_umask = sysstat.umask(63)
- local temporary_filename = secret_filename .. ".tmp." .. tohex(rand.bytes(16))
+ local temporary_filename = secret_filename .. ".tmp." ..
+ tohex(rand.bytes(16))
local temporary_file = io.open(temporary_filename, "w")
if temporary_file == nil then
sysstat.umask(old_umask)
- error("cgit auth: cannot create secret file " .. secret_filename)
+ error("cgit auth: cannot create secret file " ..
+ secret_filename)
end
local wrote = temporary_file:write(tohex(rand.bytes(32)))
local closed = temporary_file:close()
if not wrote or not closed then
os.remove(temporary_filename)
sysstat.umask(old_umask)
- error("cgit auth: failed writing secret file " .. secret_filename)
+ error("cgit auth: failed writing secret file " ..
+ secret_filename)
end
- unistd.link(temporary_filename, secret_filename) -- Intentionally fails if another worker won the race.
+ -- The link is meant to fail when another worker won the race,
+ -- which leaves that worker's secret in place rather than
+ -- replacing it and invalidating the sessions it just signed.
+ unistd.link(temporary_filename, secret_filename)
unistd.unlink(temporary_filename)
sysstat.umask(old_umask)
secret_file = io.open(secret_filename, "r")
@@ -256,7 +253,8 @@ function get_secret()
secret_file:close()
if secret == nil or secret:len() ~= 64 then
secret = nil
- error("cgit auth: secret file " .. secret_filename .. " is malformed, expected 64 hex characters")
+ error("cgit auth: secret file " .. secret_filename ..
+ " is malformed, expected 64 hex characters")
end
return secret
end
@@ -282,7 +280,7 @@ function validate_value(expected_field, cookie)
local field = ""
local expiration = 0
local salt = ""
- local chmac = ""
+ local signature = ""
if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then
return nil
@@ -294,9 +292,10 @@ function validate_value(expected_field, cookie)
elseif i == 1 then
value = component
elseif i == 2 then
- -- The expiration must be a plain integer. Rejecting other forms
- -- keeps the signed bytes canonical, since tonumber and tostring of
- -- "1e9" or "100.0" differ across Lua versions.
+ -- The expiration must be a plain integer, since
+ -- tonumber and tostring of "1e9" or "100.0" differ
+ -- across Lua versions and the signed bytes have to
+ -- come back byte for byte.
if not string.match(component, "^%d+$") then
return nil
end
@@ -304,19 +303,22 @@ function validate_value(expected_field, cookie)
elseif i == 3 then
salt = component
elseif i == 4 then
- chmac = component
+ signature = component
else
break
end
i = i + 1
end
- if chmac == nil or chmac:len() == 0 then
+ if signature == nil or signature:len() == 0 then
return nil
end
- -- Compare the HMAC without short-circuiting on the first mismatch.
- if not constant_equals(chmac, tohex(hmac.new(get_secret(), "sha256"):final(field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt))) then
+ local payload = field .. "|" .. value .. "|" ..
+ tostring(expiration) .. "|" .. salt
+ local expected_signature =
+ tohex(hmac.new(get_secret(), "sha256"):final(payload))
+ if not constant_equals(signature, expected_signature) then
return nil
end
@@ -338,6 +340,8 @@ function validate_value(expected_field, cookie)
return decoded
end
+-- The layout built here is what validate_value takes apart again, so the two
+-- have to move together.
function secure_value(field, value, expiration)
if value == nil or value:len() <= 0 then
return ""
@@ -346,13 +350,15 @@ function secure_value(field, value, expiration)
local salt = tohex(rand.bytes(16))
value = url_encode(value)
field = url_encode(field)
- local authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt
- authstr = authstr .. "|" .. tohex(hmac.new(get_secret(), "sha256"):final(authstr))
- return authstr
+ local payload = field .. "|" .. value .. "|" ..
+ tostring(expiration) .. "|" .. salt
+ local signature = tohex(hmac.new(get_secret(), "sha256"):final(payload))
+ return payload .. "|" .. signature
end
--- Strip control characters that could split an HTTP response header.
-function strip_ctl(s)
+-- A control character in a header value would let that value split the
+-- response, so it is dropped rather than escaped.
+function strip_controls(s)
return (string.gsub(s or "", "%c", ""))
end
@@ -364,29 +370,33 @@ function constant_equals(a, b)
end
local diff = 0
for i = 1, #a do
- local d = a:byte(i) - b:byte(i)
- diff = diff + d * d
+ local delta = a:byte(i) - b:byte(i)
+ diff = diff + delta * delta
end
return diff == 0
end
+-- An empty value is how a login is cleared, since Max-Age=0 tells the browser
+-- to drop the cookie it already holds.
function set_cookie(cookie, value)
- local attrs = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path
+ local attributes = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path
if not cookie_insecure then
- attrs = attrs .. "; Secure"
+ attributes = attributes .. "; Secure"
end
if value == "" then
- attrs = attrs .. "; Max-Age=0"
+ attributes = attributes .. "; Max-Age=0"
elseif session_seconds > 0 then
- attrs = attrs .. "; Max-Age=" .. tostring(session_seconds)
+ attributes = attributes ..
+ "; Max-Age=" .. tostring(session_seconds)
end
- html("Set-Cookie: " .. cookie .. "=" .. strip_ctl(value) .. attrs .. "\n")
+ html("Set-Cookie: " .. cookie .. "=" ..
+ strip_controls(value) .. attributes .. "\n")
end
function redirect_to(url)
html("Status: 302 Redirect\n")
html("Cache-Control: no-cache, no-store\n")
- html("Location: " .. strip_ctl(url) .. "\n")
+ html("Location: " .. strip_controls(url) .. "\n")
end
function not_found()
@@ -394,9 +404,12 @@ function not_found()
html("Cache-Control: no-cache, no-store\n\n")
end
--- Authentication actions. Identical to auth-inline.lua from here down.
+-- The three actions cgit can ask for follow, and they are the same in both
+-- variants, so a change to one belongs in the other.
--- Sets HTTP cookie headers based on post and sets up redirection.
+-- The redirect goes out before the password is checked, so a wrong password
+-- and a right one answer with the same status and location and differ only in
+-- the cookie.
function authenticate_post()
local redirect = validate_value("redirect", post["redirect"])
@@ -421,7 +434,8 @@ function authenticate_post()
end
if ok then
- set_cookie(cookie_name, secure_value("username", username, os.time() + session_seconds))
+ set_cookie(cookie_name, secure_value("username", username,
+ os.time() + session_seconds))
else
set_cookie(cookie_name, "")
end
@@ -430,22 +444,26 @@ function authenticate_post()
return 0
end
--- Returns 1 if the cookie is valid and 0 if it is not.
+-- cgit reads the answer, where 1 lets the request through and 0 sends it to
+-- the login form.
function authenticate_cookie()
local accepted_users = repo_userset(cgit["repo"])
if accepted_users == nil then
- -- The repository is not protected.
+ -- A repository nothing lists is public.
return 1
end
- local username = validate_value("username", get_cookie(http["cookie"], cookie_name))
+ local username = validate_value("username",
+ get_cookie(http["cookie"], cookie_name))
if username == nil or not accepted_users[username:lower()] then
return 0
end
return 1
end
--- Prints the html for the login form.
+-- cgit calls this to fill the page body once the cookie has been turned down.
+-- The form carries a signed redirect token, so the browser lands back on the
+-- page that was asked for.
function body()
local target = cgit["url"]
if not is_safe_redirect(target) then
@@ -468,8 +486,10 @@ function body()
return 0
end
--- Wrapper around the filter API, exposing the http, cgit and post tables to
--- the functions above.
+-- cgit calls filter_open with the action name followed by the request fields
+-- in a fixed order, so they are unpacked here into the tables the functions
+-- above read. Only a post reaches filter_write, carrying the form body, and
+-- filter_close is where the action finally runs and answers cgit.
local actions = {}
actions["authenticate-post"] = authenticate_post
@@ -499,12 +519,12 @@ end
function filter_close()
if action == nil then
- -- Unknown action, deny rather than raise.
+ -- An unknown action denies rather than raising.
return 0
end
return action()
end
function filter_write(str)
- post = parse_qs(str)
+ post = parse_query(str)
end
diff --git a/custom/extensions/email-gravatar.lua b/custom/extensions/email-gravatar.lua
index 0e90031..6ce688f 100644
--- a/custom/extensions/email-gravatar.lua
+++ b/custom/extensions/email-gravatar.lua
@@ -1,17 +1,18 @@
--- cgit email-filter that shows a Gravatar icon next to author names. Use it
--- with the email-filter or repo.email-filter setting and the lua: prefix.
+-- cgit email-filter that puts a Gravatar icon next to an author name. Enable
+-- it with the email-filter or repo.email-filter setting and the lua: prefix,
+-- so it runs in cgit's embedded interpreter with no per-request process. The
+-- companion email-libravatar.lua is the same filter pointed at Libravatar
+-- instead.
--
-- email-filter=lua:/path/to/email-gravatar.lua
--
--- SUPPORTED LUA
+-- Runs on Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because
+-- luaossl has no 5.5 build.
--
--- Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because luaossl
--- has no 5.5 build.
---
--- DEPENDENCY
---
--- luaossl OpenSSL binding, provides openssl.digest
--- <https://github.com/wahern/luaossl>
+-- The one dependency is luaossl, the OpenSSL binding that provides
+-- openssl.digest, from <https://github.com/wahern/luaossl>. The reliable
+-- cross-platform install is LuaRocks, matched to the Lua version cgit is built
+-- against, and it needs the OpenSSL development headers present.
--
-- # Debian and Ubuntu
-- sudo apt install luarocks libssl-dev
@@ -25,49 +26,46 @@
-- brew install luarocks openssl
-- luarocks install luaossl OPENSSL_DIR="$(brew --prefix openssl)"
--
--- PRIVACY
---
-- Every page view sends the visitor's IP address and a hash of each
--- committer's email to a third-party service. Leave this filter off if that is
--- not acceptable for your instance.
---
--- Addresses are hashed with MD5, which Gravatar still accepts. Gravatar also
--- supports SHA-256 now, change the digest in hash_hex if you prefer it.
+-- committer's email to a third-party service, so leave this filter off if that
+-- is not acceptable for your instance. Addresses are hashed with MD5, which
+-- Gravatar still accepts. Gravatar also supports SHA-256 now, so change the
+-- digest in hash_hex if you prefer it.
local digest = require("openssl.digest")
--- Pixel size of the avatar.
+-- These are the values to change. The size is in pixels and serves both as the
+-- image asked of the service and as the width and height attributes. The
+-- default image is the style Gravatar draws for an address it has never seen,
+-- and its documented choices include retro, identicon, monsterid and mp. The
+-- endpoint is https so the icon is not blocked as mixed content on an https
+-- page.
local avatar_size = 13
-
--- Fallback style for an address with no avatar. See the Gravatar docs for the
--- choices, for example retro, identicon, monsterid or mp.
local default_image = "retro"
-
--- Avatar endpoint. Kept https so the image is not blocked as mixed content on
--- an https page.
local base_url = "https://www.gravatar.com/avatar/"
-
--- Text for the image alt attribute.
local alt_text = "Gravatar"
--- State shared across the open, write and close calls of one invocation.
+-- cgit calls filter_open once, then filter_write for each piece of the name,
+-- then filter_close, so what one call works out has to be left here for the
+-- next one.
local buffer = ""
-local avatar = nil
+local avatar_hash = nil
local function hash_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))
+ local raw = digest.new("md5"):final(input)
+ local hex = ""
+ for i = 1, #raw do
+ hex = hex .. string.format("%.2x", string.byte(raw, i))
end
- return x
+ return hex
end
--- Take the address, strip the angle brackets if present, then trim and
--- lowercase as the avatar services expect. Returns nil for a missing or empty
--- address.
+-- cgit can hand over the address still wrapped in angle brackets, and the
+-- service hashes the trimmed lowercase form, so an address that skipped this
+-- would hash to something the service has never heard of. A missing or empty
+-- address becomes nil, which is how the caller knows to draw no icon.
local function normalize_email(email)
if email == nil then
return nil
@@ -85,25 +83,28 @@ end
function filter_open(email, page)
buffer = ""
- local addr = normalize_email(email)
- if addr == nil then
- avatar = nil
+ local address = normalize_email(email)
+ if address == nil then
+ avatar_hash = nil
else
- avatar = hash_hex(addr)
+ avatar_hash = hash_hex(address)
end
end
+function filter_write(text)
+ buffer = buffer .. text
+end
+
function filter_close()
- if avatar == nil then
- -- No usable address, render the name without an icon.
+ if avatar_hash == nil then
html(buffer)
else
- html("<img src='" .. base_url .. avatar .. "?s=" .. avatar_size .. "&amp;d=" .. default_image ..
- "' width='" .. avatar_size .. "' height='" .. avatar_size .. "' alt='" .. alt_text .. "' /> " .. buffer)
+ html("<img src='" .. base_url .. avatar_hash ..
+ "?s=" .. avatar_size ..
+ "&amp;d=" .. default_image ..
+ "' width='" .. avatar_size ..
+ "' height='" .. avatar_size ..
+ "' alt='" .. alt_text .. "' /> " .. buffer)
end
return 0
end
-
-function filter_write(str)
- buffer = buffer .. str
-end
diff --git a/custom/extensions/email-libravatar.lua b/custom/extensions/email-libravatar.lua
index 3538fe9..ec35bc9 100644
--- a/custom/extensions/email-libravatar.lua
+++ b/custom/extensions/email-libravatar.lua
@@ -1,17 +1,17 @@
--- cgit email-filter that shows a Libravatar icon next to author names. Use it
--- with the email-filter or repo.email-filter setting and the lua: prefix.
+-- cgit email-filter that puts a Libravatar icon next to an author name. Enable
+-- it with the email-filter or repo.email-filter setting and the lua: prefix,
+-- so it runs in cgit's embedded interpreter with no per-request process. The
+-- companion email-gravatar.lua is the same filter pointed at Gravatar instead.
--
-- email-filter=lua:/path/to/email-libravatar.lua
--
--- SUPPORTED LUA
+-- Runs on Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because
+-- luaossl has no 5.5 build.
--
--- Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because luaossl
--- has no 5.5 build.
---
--- DEPENDENCY
---
--- luaossl OpenSSL binding, provides openssl.digest
--- <https://github.com/wahern/luaossl>
+-- The one dependency is luaossl, the OpenSSL binding that provides
+-- openssl.digest, from <https://github.com/wahern/luaossl>. The reliable
+-- cross-platform install is LuaRocks, matched to the Lua version cgit is built
+-- against, and it needs the OpenSSL development headers present.
--
-- # Debian and Ubuntu
-- sudo apt install luarocks libssl-dev
@@ -25,48 +25,44 @@
-- brew install luarocks openssl
-- luarocks install luaossl OPENSSL_DIR="$(brew --prefix openssl)"
--
--- PRIVACY
---
-- Every page view sends the visitor's IP address and a hash of each
--- committer's email to a third-party service. Leave this filter off if that is
--- not acceptable for your instance.
---
--- The secure CDN is always used, so the icon loads over https and is never
--- blocked as mixed content. Addresses are hashed with MD5.
+-- committer's email to a third-party service, so leave this filter off if that
+-- is not acceptable for your instance. Addresses are hashed with MD5.
local digest = require("openssl.digest")
--- Pixel size of the avatar.
+-- These are the values to change. The size is in pixels and serves both as the
+-- image asked of the service and as the width and height attributes. The
+-- default image is the style Libravatar draws for an address it has never
+-- seen, and its documented choices include retro, identicon, monsterid and mm.
+-- The endpoint is the secure CDN so the icon loads over https and is not
+-- blocked as mixed content on an https page.
local avatar_size = 13
-
--- Fallback style for an address with no avatar. See the Libravatar docs for
--- the choices, for example retro, identicon, monsterid or mm.
local default_image = "retro"
-
--- Avatar endpoint. The secure CDN is used so the image loads over https.
local base_url = "https://seccdn.libravatar.org/avatar/"
-
--- Text for the image alt attribute.
local alt_text = "Libravatar"
--- State shared across the open, write and close calls of one invocation.
+-- cgit calls filter_open once, then filter_write for each piece of the name,
+-- then filter_close, so what one call works out has to be left here for the
+-- next one.
local buffer = ""
-local avatar = nil
+local avatar_hash = nil
local function hash_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))
+ local raw = digest.new("md5"):final(input)
+ local hex = ""
+ for i = 1, #raw do
+ hex = hex .. string.format("%.2x", string.byte(raw, i))
end
- return x
+ return hex
end
--- Take the address, strip the angle brackets if present, then trim and
--- lowercase as the avatar services expect. Returns nil for a missing or empty
--- address.
+-- cgit can hand over the address still wrapped in angle brackets, and the
+-- service hashes the trimmed lowercase form, so an address that skipped this
+-- would hash to something the service has never heard of. A missing or empty
+-- address becomes nil, which is how the caller knows to draw no icon.
local function normalize_email(email)
if email == nil then
return nil
@@ -84,25 +80,28 @@ end
function filter_open(email, page)
buffer = ""
- local addr = normalize_email(email)
- if addr == nil then
- avatar = nil
+ local address = normalize_email(email)
+ if address == nil then
+ avatar_hash = nil
else
- avatar = hash_hex(addr)
+ avatar_hash = hash_hex(address)
end
end
+function filter_write(text)
+ buffer = buffer .. text
+end
+
function filter_close()
- if avatar == nil then
- -- No usable address, render the name without an icon.
+ if avatar_hash == nil then
html(buffer)
else
- html("<img src='" .. base_url .. avatar .. "?s=" .. avatar_size .. "&amp;d=" .. default_image ..
- "' width='" .. avatar_size .. "' height='" .. avatar_size .. "' alt='" .. alt_text .. "' /> " .. buffer)
+ html("<img src='" .. base_url .. avatar_hash ..
+ "?s=" .. avatar_size ..
+ "&amp;d=" .. default_image ..
+ "' width='" .. avatar_size ..
+ "' height='" .. avatar_size ..
+ "' alt='" .. alt_text .. "' /> " .. buffer)
end
return 0
end
-
-function filter_write(str)
- buffer = buffer .. str
-end
diff --git a/custom/extensions/link-commits.lua b/custom/extensions/link-commits.lua
index fabd05b..3f2429d 100644
--- a/custom/extensions/link-commits.lua
+++ b/custom/extensions/link-commits.lua
@@ -1,51 +1,45 @@
-- cgit commit-filter that turns git object names and configurable text
--- references in commit messages into links. Use it with the commit-filter or
--- repo.commit-filter setting and the lua: prefix.
+-- references in a commit message into links, named by the commit-filter or
+-- repo.commit-filter setting in cgitrc. cgit hands over the message already
+-- HTML-escaped, so all this does is wrap matches in anchors. Every match is
+-- resolved in one left-to-right pass, so nothing is ever linked twice. The
+-- two tables below are the whole configuration, and the filter runs on Lua
+-- 5.1 through 5.4 and LuaJIT with nothing outside the standard library.
--
-- commit-filter=lua:/path/to/link-commits.lua
---
--- cgit hands the filter the message already HTML-escaped, so this only wraps
--- matches in anchors. No external dependencies. Runs on Lua 5.1 through 5.4
--- and LuaJIT.
---
--- Two kinds of thing are linked, object names (runs of hex that look like git
--- hashes) and any number of text-reference rules you define, each a pattern
--- and a URL. Both are configured among the values below. All matches are resolved
--- in a single left-to-right pass, so nothing is ever linked twice.
--- Object names (git hashes). Handled specially, because the length rule cannot
--- be written as a plain Lua pattern.
---
--- Recognition is by shape, since a commit-filter cannot ask the repository
--- whether a hash is real. Any hex run within the length bounds is linked,
--- whatever mix of digits and letters it has, so abbreviated and all-digit
--- hashes are both caught. The cost is that a long hex-looking number can now
--- and then link to an object that does not exist, which cgit renders as a
--- harmless "bad object name" page. Shape matching is inherently approximate,
--- the length bounds are the only filter.
+-- Object names are handled apart from the rules below because the length
+-- bound on them cannot be written as a plain Lua pattern. Recognition is by
+-- shape, since a commit-filter cannot ask the repository whether a hash is
+-- real, so any hex run within the bounds is linked whatever mix of digits and
+-- letters it has and abbreviated and all-digit names are both caught. The
+-- cost is that a long hex-looking number now and then links to an object that
+-- does not exist, which cgit renders as a harmless "bad object name" page.
local objects = {
-- Set false to stop linking bare hashes.
enabled = true,
- -- A hex run within these lengths is linked. Git abbreviations run about 7
- -- to 12 characters, full names are 40 (sha1) or 64 (sha256).
+ -- Git abbreviations run about 7 to 12 characters, and a full name is 40
+ -- characters for sha1 or 64 for sha256.
min_length = 7,
max_length = 64,
- -- Link target, %s is replaced with the matched hash. "./?id=%s" is relative
- -- to the current page and works for the common virtual-root layout.
+ -- Link target, where %s is replaced with the matched hash. The relative
+ -- form is resolved against the current page and works for the common
+ -- virtual-root layout.
url = "./?id=%s",
}
--- Text-reference rules. Each rule is a Lua pattern with ONE capture and a URL
--- where %s is replaced by that capture, percent-encoded. The whole match is
--- shown, the capture is what goes in the URL. Rules are tried in order and the
--- leftmost match on the line wins, so put more specific patterns first. Leave
--- the list empty to link only object names.
+-- Text-reference rules, each one a Lua pattern with a single capture and a
+-- URL where %s is replaced by that capture, percent-encoded. The whole match
+-- is what gets shown and the capture is only what goes into the URL. Rules are
+-- tried in order and the leftmost match on the line wins, so put the more
+-- specific patterns first, and an empty list leaves only object names linked.
--
-- Lua patterns are not regular expressions. There is no alternation and no
--- {n,m} repetition. %d is a digit, %a a letter, %w a letter or digit, %x a hex
--- digit, and a literal magic character is escaped with %, so a literal '-' is
--- '%-'. Reference: https://www.lua.org/manual/5.1/manual.html#5.4.1
+-- {n,m} repetition, %d is a digit, %a a letter, %w a letter or digit, %x a
+-- hex digit, and a literal magic character is escaped with %, so a literal
+-- dash is '%-'. The whole set is in the reference manual at
+-- https://www.lua.org/manual/5.1/manual.html#5.4.1
--
-- Patterns run against the escaped message, so '&', '<' and '>' reach them as
-- '&amp;', '&lt;' and '&gt;'. Match those entity spellings rather than the
@@ -61,62 +55,67 @@ local rules = {
local chunks = {}
--- Percent-encode everything but the URL-unreserved characters, so a captured
--- value cannot break out of the href attribute or the URL.
+-- Percent-encode everything but the URL unreserved characters, so a captured
+-- value cannot break out of the href attribute or out of the URL itself.
local function url_encode(s)
return (string.gsub(s, "[^%w._~-]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
--- Build one anchor. url_template has %s where the encoded capture goes, display
--- is the text shown. A function replacement is used so a '%' in the encoded
--- value is not treated as a gsub reference.
+-- Build one anchor from a URL template holding %s and the text to show. The
+-- replacement is a function so that a '%' in the encoded value is not taken
+-- for a gsub reference.
local function make_link(url_template, capture, display)
local encoded = url_encode(capture)
local href = string.gsub(url_template, "%%s", function() return encoded end)
- return '<a href="' .. href .. '">' .. display .. '</a>'
+ return "<a href='" .. href .. "'>" .. display .. "</a>"
end
--- Collect every candidate match as {s, e, pri, link}. A lower pri wins a tie on
--- the same start position.
+-- Collect every candidate match in the message. Priority records which rule
+-- found it, and the lower priority wins a tie on the same start position.
local function collect(text)
- local cands = {}
- for pri, rule in ipairs(rules) do
- -- A malformed pattern is an operator error, skip that rule rather than
- -- failing the whole page.
+ local candidates = {}
+ for priority, rule in ipairs(rules) do
+ -- A malformed pattern is an operator error, so skip that
+ -- rule rather than fail the whole page.
pcall(function()
local init = 1
while init <= #text do
- local s, e, cap = string.find(text, rule.pattern, init)
- if not s then break end
- if cap == nil then
- cap = string.sub(text, s, e)
+ local start, stop, capture =
+ string.find(text, rule.pattern, init)
+ if not start then break end
+ if capture == nil then
+ capture = string.sub(text, start, stop)
end
- cands[#cands + 1] = {
- s = s, e = e, pri = pri,
- link = make_link(rule.url, cap, string.sub(text, s, e)),
+ candidates[#candidates + 1] = {
+ start = start, stop = stop, priority = priority,
+ link = make_link(rule.url, capture,
+ string.sub(text, start, stop)),
}
- init = (e >= s) and e + 1 or s + 1
+ -- An empty match still has to advance the
+ -- scan, or it never reaches the end.
+ init = (stop >= start) and stop + 1 or start + 1
end
end)
end
if objects.enabled then
- local objpri = #rules + 1
+ local priority = #rules + 1
local init = 1
while init <= #text do
- local s, e, run = string.find(text, "%f[%w](%x+)%f[%W]", init)
- if not s then break end
+ local start, stop, run =
+ string.find(text, "%f[%w](%x+)%f[%W]", init)
+ if not start then break end
if #run >= objects.min_length and #run <= objects.max_length then
- cands[#cands + 1] = {
- s = s, e = e, pri = objpri,
+ candidates[#candidates + 1] = {
+ start = start, stop = stop, priority = priority,
link = make_link(objects.url, run, run),
}
end
- init = e + 1
+ init = stop + 1
end
end
- return cands
+ return candidates
end
function filter_open(...)
@@ -129,24 +128,25 @@ end
function filter_close()
local text = table.concat(chunks)
- local cands = collect(text)
- table.sort(cands, function(a, b)
- if a.s ~= b.s then
- return a.s < b.s
+ local candidates = collect(text)
+ table.sort(candidates, function(a, b)
+ if a.start ~= b.start then
+ return a.start < b.start
end
- return a.pri < b.pri
+ return a.priority < b.priority
end)
local out = {}
- local i = 1
- for _, c in ipairs(cands) do
- -- Skip a candidate that overlaps one already emitted.
- if c.s >= i then
- out[#out + 1] = string.sub(text, i, c.s - 1)
- out[#out + 1] = c.link
- i = c.e + 1
+ local pos = 1
+ for _, candidate in ipairs(candidates) do
+ -- A candidate reaching back into one already emitted is
+ -- dropped, so no run of text is ever wrapped twice.
+ if candidate.start >= pos then
+ out[#out + 1] = string.sub(text, pos, candidate.start - 1)
+ out[#out + 1] = candidate.link
+ pos = candidate.stop + 1
end
end
- out[#out + 1] = string.sub(text, i)
+ out[#out + 1] = string.sub(text, pos)
html(table.concat(out))
return 0
end
diff --git a/custom/extensions/syntax-highlight.lua b/custom/extensions/syntax-highlight.lua
index b7ed822..862965a 100644
--- a/custom/extensions/syntax-highlight.lua
+++ b/custom/extensions/syntax-highlight.lua
@@ -1,94 +1,64 @@
--- Server-side syntax highlighting for the tree and blob views, used with the
--- source-filter setting in cgitrc and the lua: prefix so it runs in cgit's
--- embedded interpreter with no per-request process.
+-- Server-side syntax highlighting for cgit's tree and blob views, run inside
+-- cgit's embedded Lua interpreter so a coloured blob costs no extra process
+-- per request. Colouring is deliberately left out of cgit itself, which
+-- serves plain escaped text on its own, so this filter is named by the
+-- source-filter setting and any other program could take its place. Tokens
+-- come from the Scintillua lexers and reach the page wrapped in span elements
+-- carrying the hl- classes that assets/cgit.css styles. Whenever a piece is
+-- missing or will not load, from lpeg down to a single lexer, the file falls
+-- back to plain escaped text instead of failing, so uncoloured code means a
+-- missing dependency rather than an error. It runs on Lua 5.1 through 5.5 and
+-- LuaJIT.
--
-- source-filter=lua:/usr/lib/cgit/extensions/syntax-highlight.lua
---
--- Highlighting is deliberately not built into cgit itself. Without this filter
--- cgit serves plain escaped text, and any other program can take its place.
---
--- SUPPORTED LUA
---
--- Lua 5.1 through 5.5 and LuaJIT. Scintillua 6.7 loads all of its lexers on
--- LuaJIT, so the two do not have to be matched up. A lexer that will not load
--- is skipped and that file falls back to plain escaped text, so a mismatched
--- pair degrades rather than breaking the page.
---
--- REQUIREMENTS
---
--- Two pieces, and BOTH must be installed. When either is missing the filter
--- serves plain escaped text by design, so uncolored code means a missing
--- dependency, not an error.
---
--- 1. lpeg, the parsing module, for the Lua cgit is linked against. Scintillua
--- does NOT bundle it, it must come from the system, and forgetting it is the
--- usual reason nothing happens.
---
--- # Debian and Ubuntu
--- sudo apt install lua-lpeg
--- # Fedora
--- sudo dnf install lua-lpeg
--- # Alpine
--- sudo apk add lua5.1-lpeg
--- # or with LuaRocks, matched to your Lua version
--- sudo luarocks --lua-version 5.1 install lpeg
---
--- 2. Scintillua, the lexer collection from the Textadept editor. Around 160
--- languages as plain .lua files, nothing to compile. Download a release and
--- unpack it anywhere. Only the lexers directory is needed.
---
--- https://orbitalquark.github.io/scintillua/
---
--- The lexers are found by probing, in order
---
--- $CGIT_SCINTILLUA_PATH (used alone when set, no fallback)
--- <dir of $CGIT_CONFIG>/scintillua/lexers
--- the scintillua_dirs list among the configuration values below
---
--- so either set the variable in the web server environment, or place (or
--- symlink) the scintillua directory next to your cgitrc.
---
--- SECURITY
---
--- Every probed directory is placed on package.path and its Lua is executed in
--- cgit's process. Make sure none of them is writable by other users, or someone
--- who can write there gains code execution as the web server. On macOS in
--- particular, /opt/homebrew/share is group-writable by default.
---
--- LIMITATIONS
---
--- cgit sends the filter output through a C string sink that stops at the first
--- NUL byte, so a blob containing a NUL is truncated there. This affects binary
--- files that slip past cgit's text detection, not ordinary source.
---
--- OUTPUT
---
--- Tokens are wrapped in <span> elements carrying the hl- classes that
--- assets/cgit.css styles. Every input byte up to the first NUL is preserved, so
--- the line number gutter stays aligned.
--- Files larger than this many bytes are served escaped but unhighlighted, so a
--- huge blob does not cost a lexing pass. Kept well below cgit's max-blob-size.
+-- Files larger than this many bytes are served escaped but unhighlighted, so
+-- a huge blob does not cost a lexing pass. Kept well below cgit's
+-- max-blob-size.
local max_bytes = 512 * 1024
--- Environment variable that, when set, points straight at the Scintillua
--- lexers directory and is used alone.
+-- Size of the pieces the unhighlighted fallback is written in, so a large
+-- blob does not cost a full-size second copy all at once.
+local slice_bytes = 64 * 1024
+
+-- Set this in the web server environment to point straight at the Scintillua
+-- lexers directory, in which case nothing else is probed.
local scintillua_env = "CGIT_SCINTILLUA_PATH"
--- Directories probed for the lexers when that variable is not set. The
--- directory of $CGIT_CONFIG, when set, is tried ahead of these. Keep every one
--- of these unwritable by others, see the SECURITY note above.
+-- Directories probed for the lexers when that variable is not set, tried
+-- after the directory of $CGIT_CONFIG, so placing or symlinking a scintillua
+-- directory next to cgitrc is enough to be found. Scintillua is the lexer
+-- collection from the Textadept editor, around 160 languages as plain .lua
+-- files with nothing to compile, from
+-- https://orbitalquark.github.io/scintillua/. It does not bundle lpeg, which
+-- it needs and which has to be built for the Lua cgit is linked against, and
+-- forgetting that is the usual reason nothing is coloured.
+--
+-- # Debian and Ubuntu
+-- sudo apt install lua-lpeg
+-- # Fedora
+-- sudo dnf install lua-lpeg
+-- # Alpine
+-- sudo apk add lua5.1-lpeg
+-- # or with LuaRocks, matched to your Lua version
+-- sudo luarocks --lua-version 5.1 install lpeg
+--
+-- Every directory probed goes on package.path and its Lua is executed in
+-- cgit's process, so one that other users can write to hands them code
+-- execution as the web server. On macOS /opt/homebrew/share is group-writable
+-- by default, so check it before leaving it in this list.
local scintillua_dirs = {
"/usr/local/share/scintillua/lexers",
"/usr/share/scintillua/lexers",
"/opt/homebrew/share/scintillua/lexers",
}
--- Scintillua tag name (its first dotted component) to a cgit css class. Only
--- the six classes below exist in assets/cgit.css. Add a class there and a row
--- here to style more token kinds. Tokens with no row render as plain text,
--- which is what most themes want for operators and identifiers.
+-- A Scintillua tag name, meaning its first dotted component, mapped to a cgit
+-- css class. Only the six classes named here exist in assets/cgit.css, so
+-- styling another kind of token means adding a class there and a row here. A
+-- tag with no row renders as plain text, which is what most themes want for
+-- operators and identifiers.
local css = {
comment = "hl-comment",
string = "hl-string",
@@ -106,10 +76,10 @@ local css = {
["function"] = "hl-func",
}
--- Extension to lexer-name fixes for the fallback path, used only when this
--- Scintillua has no detect(). Most extensions already equal their lexer name,
--- these are the frequent exceptions. A wrong guess just falls back to plain
--- text, so there is no harm in listing best-effort entries.
+-- Extension to lexer name fixes for the fallback path, reached only when this
+-- Scintillua has no detect(). Most extensions already equal their lexer name
+-- and these are the frequent exceptions. A wrong guess only falls back to
+-- plain text, so a best-effort entry costs nothing.
local ext_lexer = {
py = "python", js = "javascript", ts = "typescript",
rb = "ruby", pl = "perl", pm = "perl", sh = "bash",
@@ -118,13 +88,12 @@ local ext_lexer = {
}
-local lexer_mod = nil
+local scintillua = nil
local filename = ""
local chunks = {}
local escape_map = { ["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;" }
--- Escape the three HTML metacharacters in a single pass.
local function escape(s)
return (string.gsub(s, "[&<>]", escape_map))
end
@@ -142,9 +111,12 @@ local function scintillua_path()
candidates[#candidates + 1] = dir .. "/scintillua/lexers"
end
end
- for _, d in ipairs(scintillua_dirs) do
- candidates[#candidates + 1] = d
+ for _, dir in ipairs(scintillua_dirs) do
+ candidates[#candidates + 1] = dir
end
+ -- A candidate counts only when lexer.lua is actually in it, so a
+ -- directory that exists but holds no lexers does not shadow a
+ -- later one.
for _, dir in ipairs(candidates) do
local f = io.open(dir .. "/lexer.lua", "r")
if f then
@@ -160,38 +132,43 @@ local function load_scintillua()
if not dir then
return nil
end
+ -- cgit keeps this interpreter alive across requests, so package.path is
+ -- only extended when the directory is not already on it.
if not string.find(package.path, dir, 1, true) then
package.path = dir .. "/?.lua;" .. package.path
end
local ok, mod = pcall(require, "lexer")
- -- A real Scintillua exposes load(). Anything else on the path that happens
- -- to be called lexer is not usable.
+ -- A real Scintillua exposes load(), so anything else on the path that
+ -- happens to be called lexer is rejected rather than used.
if ok and type(mod) == "table" and type(mod.load) == "function" then
return mod
end
return nil
end
+-- Loading a lexer runs its Lua, and one written for another Scintillua can
+-- raise, so a failure here just leaves this file uncoloured.
local function load_lexer_name(name)
if name == nil then
return nil
end
- local ok, lex = pcall(lexer_mod.load, name)
- if ok and lex then
- return lex
+ local ok, lexer = pcall(scintillua.load, name)
+ if ok and lexer then
+ return lexer
end
return nil
end
--- Resolve a lexer for the file, preferring Scintillua's own filename detection
--- when this version provides it, then an extension map, then the raw extension.
+-- Resolve a lexer for the file, preferring Scintillua's own filename
+-- detection where this version provides it, then the extension map above,
+-- then the raw extension.
local function lexer_for(name)
- if type(lexer_mod.detect) == "function" then
- local ok, lang = pcall(lexer_mod.detect, name)
+ if type(scintillua.detect) == "function" then
+ local ok, lang = pcall(scintillua.detect, name)
if ok and lang then
- local lex = load_lexer_name(lang)
- if lex then
- return lex
+ local lexer = load_lexer_name(lang)
+ if lexer then
+ return lexer
end
end
end
@@ -204,28 +181,32 @@ local function lexer_for(name)
end
local function highlight(text)
- local lex = lexer_for(filename)
- if not lex then
+ local lexer = lexer_for(filename)
+ if not lexer then
return nil
end
- local ok, tokens = pcall(lex.lex, lex, text)
+ local ok, tokens = pcall(lexer.lex, lexer, text)
if not ok or type(tokens) ~= "table" then
return nil
end
local out = {}
local pos = 1
+ -- Scintillua returns one flat list of a tag name and the position
+ -- just past the token it names, so a token is the text from where
+ -- the one before it ended.
for i = 1, #tokens, 2 do
local tag = tokens[i]
- local fin = tokens[i + 1]
- local part = escape(string.sub(text, pos, fin - 1))
+ local stop = tokens[i + 1]
+ local part = escape(string.sub(text, pos, stop - 1))
local class = css[string.match(tag, "^[%w_]+")]
if class and part ~= "" then
part = "<span class='" .. class .. "'>" .. part .. "</span>"
end
out[#out + 1] = part
- pos = fin
+ pos = stop
end
- -- Anything the lexer left unconsumed is kept, escaped.
+ -- A lexer can stop short of the end, and every byte still has to reach
+ -- the page or the line number gutter beside it drifts out of step.
if pos <= #text then
out[#out + 1] = escape(string.sub(text, pos))
end
@@ -241,14 +222,17 @@ function filter_write(str)
chunks[#chunks + 1] = str
end
+-- cgit takes filter output through a C string sink that stops at the first
+-- NUL byte, so a blob holding one is truncated there. That reaches binary
+-- files which slip past cgit's text detection, not ordinary source.
function filter_close()
local text = table.concat(chunks)
chunks = {}
if #text <= max_bytes then
- if lexer_mod == nil then
- lexer_mod = load_scintillua() or false
+ if scintillua == nil then
+ scintillua = load_scintillua() or false
end
- if lexer_mod then
+ if scintillua then
local ok, marked = pcall(highlight, text)
if ok and marked then
html(marked)
@@ -256,8 +240,6 @@ function filter_close()
end
end
end
- -- Fallback, escaped plain text emitted in slices so a large blob does not
- -- cost a full-size second copy all at once.
local n = #text
if n == 0 then
html("")
@@ -265,8 +247,8 @@ function filter_close()
end
local pos = 1
while pos <= n do
- html(escape(string.sub(text, pos, pos + 65535)))
- pos = pos + 65536
+ html(escape(string.sub(text, pos, pos + slice_bytes - 1)))
+ pos = pos + slice_bytes
end
return 0
end