diff options
| author | Bryce Kwon <bryce@brycekwon.com> | |
|---|---|---|
| committer | Bryce Kwon <bryce@brycekwon.com> | |
| commit | ||
| parent | ||
| tree | ||
| download | ||
Restyle the filter extension headers and markup
Diffstat (limited to 'custom/extensions')
| -rw-r--r-- | custom/extensions/about-render.lua | 585 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | custom/extensions/auth-file.lua | 341 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | custom/extensions/auth-inline.lua | 296 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | custom/extensions/email-gravatar.lua | 97 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | custom/extensions/email-libravatar.lua | 93 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | custom/extensions/link-commits.lua | 146 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -rw-r--r-- | custom/extensions/syntax-highlight.lua | 204 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 .. "&d=" .. default_image .. - "' width='" .. avatar_size .. "' height='" .. avatar_size .. "' alt='" .. alt_text .. "' /> " .. buffer) + html("<img src='" .. base_url .. avatar_hash .. + "?s=" .. avatar_size .. + "&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 .. "&d=" .. default_image .. - "' width='" .. avatar_size .. "' height='" .. avatar_size .. "' alt='" .. alt_text .. "' /> " .. buffer) + html("<img src='" .. base_url .. avatar_hash .. + "?s=" .. avatar_size .. + "&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 -- '&', '<' and '>'. 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 = { ["&"] = "&", ["<"] = "<", [">"] = ">" } --- 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 |
