From eb414248680936e61fee2d97905ef2c609e8c318 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Fri, 14 Aug 2026 12:18:33 -1000 Subject: Restyle the filter extension headers and markup --- custom/extensions/about-render.lua | 585 ++++++++++++++++++--------------- custom/extensions/auth-file.lua | 341 ++++++++++--------- custom/extensions/auth-inline.lua | 296 +++++++++-------- custom/extensions/email-gravatar.lua | 97 +++--- custom/extensions/email-libravatar.lua | 93 +++--- custom/extensions/link-commits.lua | 146 ++++---- 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 @@ -1,29 +1,20 @@ --- Server-side about-page rendering for cgit, used with the about-filter setting --- in cgitrc and the lua: prefix so it runs in cgit's embedded interpreter with --- no per-request process. +-- Server-side rendering of a repository's about page, named by the +-- about-filter setting in cgitrc and run inside cgit's embedded Lua +-- interpreter so a readme costs no extra process per request. +-- Markdown, man pages and plain text are the three formats, chosen from the +-- readme's file extension, and anything that fails to parse falls back to +-- escaped plain text. Adding a format takes a render function and a row in +-- the handler table at the foot of this file, and nothing else. The wrappers +-- it emits carry the classes that assets/cgit.css styles. It runs on Lua 5.1 +-- through 5.4 and LuaJIT. -- -- about-filter=lua:/usr/lib/cgit/filters/about-render.lua --- --- One filter renders the three baseline about formats, chosen by the readme's --- file extension. --- --- markdown .md .markdown .mkd .mdown --- man page .1 through .9 and .man --- plain text everything else, and the fallback for anything that fails --- --- Add a format by giving it a render function and a row in the handler table --- near the foot of this file. Nothing else needs to change. --- --- SUPPORTED LUA --- --- Lua 5.1 through 5.4 and LuaJIT. --- --- REQUIREMENTS --- --- lpeg, the parsing module, for the Lua cgit is linked against. Markdown and --- man pages are parsed with lpeg grammars, so without lpeg both fall back to --- escaped plain text. It ships with cgit's syntax highlighter too, so a cgit --- that colours source already has it. + +-- Markdown and man pages are parsed with lpeg grammars, so where the parsing +-- module is missing both fall back to escaped plain text and only plain text +-- still renders. It has to be built for the Lua cgit is linked against. +-- cgit's syntax highlighter needs it too, so a cgit that colours source +-- already has it. -- -- # Debian and Ubuntu -- sudo apt install lua-lpeg @@ -33,40 +24,11 @@ -- sudo apk add lua5.1-lpeg -- # or with LuaRocks, matched to your Lua version -- sudo luarocks --lua-version 5.1 install lpeg --- --- Plain text needs only Lua itself. --- --- SECURITY --- --- The about page renders untrusted repository content, so the safety rule is --- that every run of text reaches the page through cgit's own html_txt, every --- attribute value through html_attr, and every link or image target through --- the safe_url scheme allowlist below before html_attr. Only http, https, --- mailto and relative targets survive, so a hostile readme cannot inject markup --- or a javascript: url. Because all output is routed through those sinks by --- construction, a bug in the parser can only mis-render, never inject. --- --- LIMITATIONS --- --- Markdown is a deliberate subset, not CommonMark. Headings, thematic breaks, --- fenced and inline code, blockquotes, pipe tables, single-level lists, links, --- images and emphasis. No reference links, no raw HTML passthrough, no nested --- lists, no setext headings. Emphasis does not span a hard line break inside a --- paragraph. Man rendering covers the common macros (section headings, filled --- and no-fill paragraphs, bold and italic, font escapes) and drops the rest. --- cgit sends filter output through a C string sink that stops at the first NUL --- byte, so content past a NUL is truncated. --- --- OUTPUT --- --- Markdown is wrapped in
, man pages in ---
, plain text in
, all
--- styled by assets/cgit.css.
+local has_lpeg, lpeg = pcall(require, "lpeg")
 
-local ok_lpeg, lpeg = pcall(require, "lpeg")
 
-
--- Readmes larger than this are served as escaped plain text rather than parsed.
+-- Readmes larger than this are served as escaped plain text rather than
+-- parsed.
 local max_bytes = 512 * 1024
 
 -- Ceiling on blockquote and emphasis nesting, so a hostile readme of stacked
@@ -76,47 +38,48 @@ local max_depth = 24
 local filename, chunks = "", {}
 
 
-local function trim(s)
-	return (s:gsub("^%s+", ""):gsub("%s+$", ""))
+local function trim(text)
+	return (text:gsub("^%s+", ""):gsub("%s+$", ""))
 end
 
--- Split on newlines after normalising CRLF and CR, returning the lines without
--- their terminators. A trailing newline yields a final empty line, which every
--- caller treats as blank.
-local function split_lines(s)
-	s = s:gsub("\r\n?", "\n")
+-- Split on newlines after normalising CRLF and CR, returning the lines
+-- without their terminators. A trailing newline yields a final empty line,
+-- which every caller treats as blank.
+local function split_lines(text)
+	text = text:gsub("\r\n?", "\n")
 	local lines, start = {}, 1
 	while true do
-		local nl = s:find("\n", start, true)
-		if not nl then
-			lines[#lines + 1] = s:sub(start)
+		local newline = text:find("\n", start, true)
+		if not newline then
+			lines[#lines + 1] = text:sub(start)
 			return lines
 		end
-		lines[#lines + 1] = s:sub(start, nl - 1)
-		start = nl + 1
+		lines[#lines + 1] = text:sub(start, newline - 1)
+		start = newline + 1
 	end
 end
 
--- Split a table row into trimmed cells, dropping one optional leading and
--- trailing pipe.
+-- Split a table row into trimmed cells, dropping one optional leading and one
+-- optional trailing pipe.
 local function split_cells(row)
 	row = trim(row):gsub("^|", ""):gsub("|$", "")
 	local cells = {}
-	for c in (row .. "|"):gmatch("(.-)|") do
-		cells[#cells + 1] = trim(c)
+	for cell in (row .. "|"):gmatch("(.-)|") do
+		cells[#cells + 1] = trim(cell)
 	end
 	return cells
 end
 
--- Return the url if its scheme is safe, else nil. Control and whitespace bytes
--- are stripped anywhere first because a browser ignores them when resolving the
--- scheme, so "java\nscript:" must still be caught as javascript. The class is
--- %c%s rather than a literal 0x00 range so it is safe on Lua 5.1, where an
--- embedded zero byte ends a pattern.
+-- Return the URL if its scheme is safe, else nil. Control and whitespace
+-- bytes are stripped anywhere first because a browser ignores them when
+-- resolving the scheme, so "java\nscript:" must still be caught as
+-- javascript. The class is %c%s rather than a literal 0x00 range so it is
+-- safe on Lua 5.1, where an embedded zero byte ends a pattern.
 local function safe_url(url)
 	url = url:gsub("[%c%s]", "")
-	-- A leading "//" or "/\" is scheme-relative, which a browser resolves to
-	-- another origin, so it is not the local target the parser meant to allow.
+	-- A leading "//" or "/\" is scheme-relative, which a browser
+	-- resolves to another origin, so it is not the local target the
+	-- parser meant to allow.
 	if url:sub(1, 2) == "//" or url:sub(1, 2) == "/\\" then
 		return nil
 	end
@@ -131,39 +94,44 @@ local function safe_url(url)
 end
 
 
--- html, html_txt and html_attr are injected by cgit's lua filter host. Tag
--- scaffolding is a literal argument to html; every value that came from the
--- repository goes through html_txt, html_attr or safe_url.
+-- The about page renders untrusted repository content, so the rule the two
+-- functions below keep is that every run of text reaches the page through
+-- cgit's own html_txt, every attribute value through html_attr, and every
+-- link or image target through safe_url before html_attr. Those three come
+-- from cgit's Lua filter host rather than from here, and the only thing
+-- passed to html is literal tag scaffolding. Because all output is routed
+-- through those sinks by construction, a bug in the parser can only
+-- mis-render, never inject markup or a URL with a javascript scheme.
 
 local emit_inline
 
 emit_inline = function(list)
-	for _, nd in ipairs(list) do
-		local t = nd.t
-		if t == "text" then
-			html_txt(nd.v)
-		elseif t == "code" then
-			html(""); html_txt(nd.v); html("")
-		elseif t == "strong" then
-			html(""); emit_inline(nd.kids); html("")
-		elseif t == "em" then
-			html(""); emit_inline(nd.kids); html("")
-		elseif t == "link" then
-			local u = safe_url(nd.url)
-			if u then
-				html("")
-				emit_inline(nd.kids)
+	for _, node in ipairs(list) do
+		local kind = node.kind
+		if kind == "text" then
+			html_txt(node.text)
+		elseif kind == "code" then
+			html(""); html_txt(node.text); html("")
+		elseif kind == "strong" then
+			html(""); emit_inline(node.kids); html("")
+		elseif kind == "em" then
+			html(""); emit_inline(node.kids); html("")
+		elseif kind == "link" then
+			local target = safe_url(node.url)
+			if target then
+				html("")
+				emit_inline(node.kids)
 				html("")
 			else
-				emit_inline(nd.kids)
+				emit_inline(node.kids)
 			end
-		elseif t == "image" then
-			local u = safe_url(nd.url)
-			if u then
-				html(""); html_attr(nd.alt); html("")
+		elseif kind == "image" then
+			local target = safe_url(node.url)
+			if target then
+				html(""); html_attr(node.alt); html("")
 			else
-				html_txt("![" .. nd.alt .. "]")
+				html_txt("![" .. node.alt .. "]")
 			end
 		end
 	end
@@ -172,47 +140,49 @@ end
 local emit_blocks
 
 emit_blocks = function(blocks)
-	for _, b in ipairs(blocks) do
-		local t = b.t
-		if t == "heading" then
-			html("")
-			emit_inline(b.kids)
-			html("")
-		elseif t == "hr" then
-			html("
") - elseif t == "code" then + for _, block in ipairs(blocks) do + local kind = block.kind + if kind == "heading" then + html("") + emit_inline(block.kids) + html("") + elseif kind == "hr" then + html("
") + elseif kind == "code" then html("
"); html_txt(b.text); html("
") - elseif t == "blockquote" then - html("
"); emit_blocks(b.blocks); html("
") - elseif t == "table" then + html(">"); html_txt(block.text); html("
") + elseif kind == "blockquote" then + html("
") + emit_blocks(block.blocks) + html("
") + elseif kind == "table" then html("") - for _, c in ipairs(b.head) do - html("") + for _, cell in ipairs(block.head) do + html("") end html("") - for _, row in ipairs(b.rows) do + for _, row in ipairs(block.rows) do html("") - for _, c in ipairs(row) do - html("") + for _, cell in ipairs(row) do + html("") end html("") end html("
"); emit_inline(c); html(""); emit_inline(cell); html("
"); emit_inline(c); html(""); emit_inline(cell); html("
") - elseif t == "list" then - local tag = b.ordered and "ol" or "ul" + elseif kind == "list" then + local tag = block.ordered and "ol" or "ul" html("<" .. tag .. ">") - for _, item in ipairs(b.items) do + for _, item in ipairs(block.items) do html("
  • "); emit_inline(item); html("
  • ") end html("") - elseif t == "para" then + elseif kind == "para" then html("

    ") - for k, line in ipairs(b.lines) do - if k > 1 then html("
    ") end + for i, line in ipairs(block.lines) do + if i > 1 then html("
    ") end emit_inline(line) end html("

    ") @@ -228,48 +198,69 @@ local function render_plaintext(text) end --- Both are parsed with lpeg into the block/inline node trees emit_blocks and --- emit_inline above consume. Parsing is pure and builds a tree; emit is the --- only step that writes output, so a parse failure falls back to plain text --- without leaving half a page behind. +-- Markdown here is a deliberate subset rather than CommonMark, covering +-- headings, thematic breaks, fenced and inline code, blockquotes, pipe +-- tables, single-level lists, links, images and emphasis, and leaving out +-- reference links, raw HTML passthrough, nested lists and setext headings. +-- Emphasis does not span a hard line break inside a paragraph. Man rendering +-- covers the common macros, that is section headings, filled and no-fill +-- paragraphs, bold and italic and the font escapes, and drops the rest. Both +-- are parsed with lpeg into the node trees emit_blocks and emit_inline above +-- consume, and parsing only builds a tree, so a parse that fails falls back +-- to plain text without leaving half a page behind. local render_markdown, render_man -if ok_lpeg then +if has_lpeg then local P, R, S, C, Ct = lpeg.P, lpeg.R, lpeg.S, lpeg.C, lpeg.Ct - local sp = S(" \t") - local ws = S(" \t\r\n\f\v") + local space = S(" \t") + local whitespace = S(" \t\r\n\f\v") local eol = P(-1) local marker = S("`![*_") - local urlchar = 1 - S(")") - ws - -- Bound the link and image inner scans. Without a cap a readme of unclosed - -- brackets ("[[[[...") makes each position scan to end of line for a "]" - -- that never comes, which is quadratic. Real link text and urls sit far - -- under this, and anything longer simply renders as plain text. - local CAP = 512 + local url_char = 1 - S(")") - whitespace + -- Bound the link and image inner scans. Without a cap a readme of + -- unclosed brackets ("[[[[...") makes every position scan to the + -- end of the line for a "]" that never comes, which is quadratic. + -- Real link text and urls sit far under this, and anything longer + -- simply renders as plain text. + local max_scan = 512 local parse_inline local inline_depth = 0 - local function node_text(s) return { t = "text", v = s } end - local function node_code(s) return { t = "code", v = s } end - local function node_strong(s) return { t = "strong", kids = parse_inline(s) } end - local function node_em(s) return { t = "em", kids = parse_inline(s) } end - local function node_link(text, url) return { t = "link", kids = parse_inline(text), url = url } end - local function node_image(alt, url) return { t = "image", alt = alt, url = url } end + local function node_text(text) + return { kind = "text", text = text } + end + local function node_code(text) + return { kind = "code", text = text } + end + local function node_strong(text) + return { kind = "strong", kids = parse_inline(text) } + end + local function node_em(text) + return { kind = "em", kids = parse_inline(text) } + end + local function node_link(text, url) + return { kind = "link", kids = parse_inline(text), url = url } + end + local function node_image(alt, url) + return { kind = "image", alt = alt, url = url } + end local inline_grammar = Ct(( (P("`") * C((1 - P("`")) ^ 1) * P("`")) / node_code - + (P("![") * C((1 - P("]")) ^ (-CAP)) * P("]") * P("(") * sp ^ 0 - * C(urlchar * urlchar ^ (-CAP)) * (1 - P(")")) ^ (-CAP) * P(")")) / node_image - + (P("[") * C((1 - P("]")) ^ (-CAP)) * P("]") * P("(") * sp ^ 0 - * C(urlchar * urlchar ^ (-CAP)) * (1 - P(")")) ^ (-CAP) * P(")")) / node_link + + (P("![") * C((1 - P("]")) ^ (-max_scan)) * P("]") * P("(") + * space ^ 0 * C(url_char * url_char ^ (-max_scan)) + * (1 - P(")")) ^ (-max_scan) * P(")")) / node_image + + (P("[") * C((1 - P("]")) ^ (-max_scan)) * P("]") * P("(") + * space ^ 0 * C(url_char * url_char ^ (-max_scan)) + * (1 - P(")")) ^ (-max_scan) * P(")")) / node_link + (P("**") * C((1 - P("**")) ^ 1) * P("**")) / node_strong + (P("__") * C((1 - P("__")) ^ 1) * P("__")) / node_strong - + (P("*") * C((1 - ws) * (1 - P("*")) ^ 0) * P("*")) / node_em - + (P("_") * C((1 - ws) * (1 - P("_")) ^ 0) * P("_")) / node_em + + (P("*") * C((1 - whitespace) * (1 - P("*")) ^ 0) * P("*")) / node_em + + (P("_") * C((1 - whitespace) * (1 - P("_")) ^ 0) * P("_")) / node_em + C((1 - marker) ^ 1) / node_text + C(P(1)) / node_text ) ^ 0) @@ -284,30 +275,36 @@ if ok_lpeg then return nodes end - -- Line matchers. Each is anchored at the start of a single line and returns - -- its captures, or nil when the line is not of that kind. - local langchar = R("az", "AZ", "09") + S("_.+#-") - local m_heading = C(P("#") * P("#") ^ -5) * sp ^ 1 * C(P(1) ^ 0) - local function rule(c) return sp ^ 0 * P(c) * (sp ^ 0 * P(c)) ^ 2 * sp ^ 0 * eol end - local m_hr = rule("-") + rule("*") + rule("_") - local m_fence = sp ^ 0 * (C(P("`") ^ 3) + C(P("~") ^ 3)) * sp ^ 0 * C(langchar ^ 0) - local m_close_bt = sp ^ 0 * P("`") ^ 3 * sp ^ 0 * eol - local m_close_ti = sp ^ 0 * P("~") ^ 3 * sp ^ 0 * eol - local m_bq = sp ^ 0 * P(">") + -- Line matchers. Each is anchored at the start of a single line and + -- returns its captures, or nil when the line is not of that kind. + local lang_char = R("az", "AZ", "09") + S("_.+#-") + local heading_line = C(P("#") * P("#") ^ -5) * space ^ 1 * C(P(1) ^ 0) + local function thematic(mark) + return space ^ 0 * P(mark) * (space ^ 0 * P(mark)) ^ 2 + * space ^ 0 * eol + end + local break_line = thematic("-") + thematic("*") + thematic("_") + local fence_line = space ^ 0 * (C(P("`") ^ 3) + C(P("~") ^ 3)) + * space ^ 0 * C(lang_char ^ 0) + local close_backtick = space ^ 0 * P("`") ^ 3 * space ^ 0 * eol + local close_tilde = space ^ 0 * P("~") ^ 3 * space ^ 0 * eol + local quote_line = space ^ 0 * P(">") local bullet = S("-*+") local number = R("09") ^ 1 * S(".)") - local m_item = sp ^ 0 * C(bullet + number) * sp ^ 1 * C(P(1) ^ 0) - local dcell = sp ^ 0 * P(":") ^ -1 * P("-") ^ 1 * P(":") ^ -1 * sp ^ 0 - -- A delimiter row is dash-cells joined by pipes. Require at least one pipe, - -- a leading one or one between cells, so a bare rule of dashes stays a - -- thematic break and an ordinary paragraph line is never taken for a table. + local item_line = space ^ 0 * C(bullet + number) * space ^ 1 * C(P(1) ^ 0) + local dash_cell = space ^ 0 * P(":") ^ -1 * P("-") ^ 1 * P(":") ^ -1 + * space ^ 0 + -- A delimiter row is dash-cells joined by pipes. Require at least one + -- pipe, a leading one or one between cells, so a bare rule of dashes + -- stays a thematic break and an ordinary paragraph line is never taken + -- for a table. local pipe = P("|") - local m_tdelim = sp ^ 0 * ( - pipe * dcell * (pipe * dcell) ^ 0 * pipe ^ -1 - + dcell * (pipe * dcell) ^ 1 * pipe ^ -1 - ) * sp ^ 0 * eol - local m_blockstart = sp ^ 0 * ((P("#") * P("#") ^ -5 * sp) + P(">") - + P("`") ^ 3 + P("~") ^ 3 + ((bullet + number) * sp)) + local table_delimiter = space ^ 0 * ( + pipe * dash_cell * (pipe * dash_cell) ^ 0 * pipe ^ -1 + + dash_cell * (pipe * dash_cell) ^ 1 * pipe ^ -1 + ) * space ^ 0 * eol + local block_start = space ^ 0 * ((P("#") * P("#") ^ -5 * space) + P(">") + + P("`") ^ 3 + P("~") ^ 3 + ((bullet + number) * space)) local function ordered(mark) return mark:match("%d") ~= nil end @@ -317,13 +314,14 @@ if ok_lpeg then local blocks, i, n = {}, 1, #lines while i <= n do local line = lines[i] - local fence, lang = m_fence:match(line) - local hashes, htext = m_heading:match(line) - local mark = m_item:match(line) + local fence, lang = fence_line:match(line) + local hashes, title = heading_line:match(line) + local mark = item_line:match(line) if line:match("^%s*$") then i = i + 1 elseif fence then - local close = (fence:sub(1, 1) == "`") and m_close_bt or m_close_ti + local close = (fence:sub(1, 1) == "`") + and close_backtick or close_tilde local code = {} i = i + 1 while i <= n and not close:match(lines[i]) do @@ -332,59 +330,89 @@ if ok_lpeg then i = i + 1 local body = table.concat(code, "\n") if #code > 0 then body = body .. "\n" end - blocks[#blocks + 1] = { t = "code", lang = lang, text = body } + blocks[#blocks + 1] = { + kind = "code", + lang = lang, + text = body, + } elseif hashes then blocks[#blocks + 1] = { - t = "heading", + kind = "heading", level = #hashes, - kids = parse_inline((htext:gsub("%s*#*%s*$", ""))), + kids = parse_inline((title:gsub("%s*#*%s*$", ""))), } i = i + 1 - elseif m_hr:match(line) then - blocks[#blocks + 1] = { t = "hr" } + elseif break_line:match(line) then + blocks[#blocks + 1] = { kind = "hr" } i = i + 1 - elseif m_bq:match(line) then + elseif quote_line:match(line) then local inner = {} - while i <= n and m_bq:match(lines[i]) do + while i <= n and quote_line:match(lines[i]) do inner[#inner + 1] = (lines[i]:gsub("^%s*>%s?", "")) i = i + 1 end if depth < max_depth then - blocks[#blocks + 1] = { t = "blockquote", blocks = parse_blocks(inner, depth + 1) } + blocks[#blocks + 1] = { + kind = "blockquote", + blocks = parse_blocks(inner, depth + 1), + } else - local para = {} - for _, l in ipairs(inner) do para[#para + 1] = parse_inline(l) end - blocks[#blocks + 1] = { t = "para", lines = para } + local paragraph = {} + for _, quoted in ipairs(inner) do + paragraph[#paragraph + 1] = parse_inline(quoted) + end + blocks[#blocks + 1] = { + kind = "para", + lines = paragraph, + } end - elseif i + 1 <= n and line:find("|", 1, true) and m_tdelim:match(lines[i + 1]) then + elseif i + 1 <= n and line:find("|", 1, true) + and table_delimiter:match(lines[i + 1]) then local head = {} - for _, c in ipairs(split_cells(line)) do head[#head + 1] = parse_inline(c) end + for _, cell in ipairs(split_cells(line)) do + head[#head + 1] = parse_inline(cell) + end local rows = {} i = i + 2 - while i <= n and lines[i]:find("|", 1, true) and not lines[i]:match("^%s*$") do + while i <= n and lines[i]:find("|", 1, true) + and not lines[i]:match("^%s*$") do local row = {} - for _, c in ipairs(split_cells(lines[i])) do row[#row + 1] = parse_inline(c) end + for _, cell in ipairs(split_cells(lines[i])) do + row[#row + 1] = parse_inline(cell) + end rows[#rows + 1] = row i = i + 1 end - blocks[#blocks + 1] = { t = "table", head = head, rows = rows } + blocks[#blocks + 1] = { + kind = "table", + head = head, + rows = rows, + } elseif mark then - local is_ol = ordered(mark) + local is_ordered = ordered(mark) local items = {} while i <= n do - local mk, ct = m_item:match(lines[i]) - if not mk or ordered(mk) ~= is_ol then break end - items[#items + 1] = parse_inline(ct) + local item_mark, content = item_line:match(lines[i]) + if not item_mark or ordered(item_mark) ~= is_ordered then + break + end + items[#items + 1] = parse_inline(content) i = i + 1 end - blocks[#blocks + 1] = { t = "list", ordered = is_ol, items = items } + blocks[#blocks + 1] = { + kind = "list", + ordered = is_ordered, + items = items, + } else - local para = { parse_inline(line) } + local paragraph = { parse_inline(line) } i = i + 1 - while i <= n and not lines[i]:match("^%s*$") and not m_blockstart:match(lines[i]) do - para[#para + 1] = parse_inline(lines[i]); i = i + 1 + while i <= n and not lines[i]:match("^%s*$") + and not block_start:match(lines[i]) do + paragraph[#paragraph + 1] = parse_inline(lines[i]) + i = i + 1 end - blocks[#blocks + 1] = { t = "para", lines = para } + blocks[#blocks + 1] = { kind = "para", lines = paragraph } end end return blocks @@ -399,18 +427,21 @@ if ok_lpeg then return render_plaintext(text) end html("
    ") - -- Emit is wrapped so a parser bug can at worst truncate the page, never - -- error out and let the outer fallback duplicate what was already sent. + -- Emitting is wrapped so a bug there can at worst + -- truncate the page, never raise and let the outer + -- fallback duplicate what has already been sent. pcall(emit_blocks, blocks) html("
    ") end - -- Macro lines are matched with lpeg, and the roff inline font and character - -- escapes are an lpeg grammar producing a flat token list that a fold turns - -- into the same inline nodes markdown emits. Bold and italic are the current - -- font, which carries across a run, so the inline pass tokenises and folds - -- rather than nesting the way markdown does. - local m_macro = S(".'") * sp ^ 0 * C((1 - sp) ^ 1) * sp ^ 0 * C(P(1) ^ 0) + -- Macro lines are matched with lpeg, and the roff inline font and + -- character escapes are a grammar producing a flat token list that + -- a fold turns into the same inline nodes markdown emits. Bold and + -- italic are the current font, which carries across a run rather + -- than being opened and closed, so the inline pass tokenises and + -- folds instead of nesting the way markdown does. + local macro_line = S(".'") * space ^ 0 * C((1 - space) ^ 1) + * space ^ 0 * C(P(1) ^ 0) local one_font = { B = "B", I = "I" } local two_font = { CB = "B", BI = "B", CI = "I" } @@ -418,46 +449,57 @@ if ok_lpeg then aq = "'", cq = "'", oq = "'", dq = '"', lq = '"', rq = '"', hy = "-", en = "-", em = "-", } - local esc = P("\\") - local function font_tok(f) return { k = "font", f = f } end - local function text_tok(v) return { k = "text", v = v } end - local skip_tok = { k = "skip" } + local backslash = P("\\") + local function font_token(name) return { kind = "font", font = name } end + local function text_token(text) return { kind = "text", text = text } end + local skip_token = { kind = "skip" } local man_inline_grammar = Ct(( - (esc * P("f") * P("(") * C(P(1) * P(1))) / function(nm) return font_tok(two_font[nm] or "R") end - + (esc * P("f") * P("[") * C((1 - P("]")) ^ 0) * P("]")) / function(nm) return font_tok(one_font[nm] or "R") end - + (esc * P("f") * C(P(1))) / function(f) return font_tok(one_font[f] or "R") end - + (esc * P("-")) / function() return text_tok("-") end - + (esc * P("e")) / function() return text_tok("\\") end - + (esc * P(" ")) / function() return text_tok(" ") end - + (esc * S("&|^")) / function() return skip_tok end - + (esc * P("(") * C(P(1) * P(1))) / function(nm) return text_tok(man_chars[nm] or "") end - + (esc * P("*") * (P("(") * P(1) * P(1) + P(1))) / function() return skip_tok end - + (esc * C(P(1))) / text_tok - + esc / function() return skip_tok end - + C((1 - esc) ^ 1) / text_tok + (backslash * P("f") * P("(") * C(P(1) * P(1))) + / function(name) return font_token(two_font[name] or "R") end + + (backslash * P("f") * P("[") * C((1 - P("]")) ^ 0) * P("]")) + / function(name) return font_token(one_font[name] or "R") end + + (backslash * P("f") * C(P(1))) + / function(name) return font_token(one_font[name] or "R") end + + (backslash * P("-")) / function() return text_token("-") end + + (backslash * P("e")) / function() return text_token("\\") end + + (backslash * P(" ")) / function() return text_token(" ") end + + (backslash * S("&|^")) / function() return skip_token end + + (backslash * P("(") * C(P(1) * P(1))) + / function(name) return text_token(man_chars[name] or "") end + + (backslash * P("*") * (P("(") * P(1) * P(1) + P(1))) + / function() return skip_token end + + (backslash * C(P(1))) / text_token + + backslash / function() return skip_token end + + C((1 - backslash) ^ 1) / text_token ) ^ 0) - local function man_inline(s) - local toks = man_inline_grammar:match(s) or {} - local nodes, buf, font = {}, {}, "R" + local function man_inline(text) + local tokens = man_inline_grammar:match(text) or {} + local nodes, parts, font = {}, {}, "R" local function flush() - if #buf == 0 then return end - local txt = table.concat(buf); buf = {} - if txt == "" then return end + if #parts == 0 then return end + local run = table.concat(parts); parts = {} + if run == "" then return end if font == "B" then - nodes[#nodes + 1] = { t = "strong", kids = { { t = "text", v = txt } } } + nodes[#nodes + 1] = { + kind = "strong", + kids = { { kind = "text", text = run } }, + } elseif font == "I" then - nodes[#nodes + 1] = { t = "em", kids = { { t = "text", v = txt } } } + nodes[#nodes + 1] = { + kind = "em", + kids = { { kind = "text", text = run } }, + } else - nodes[#nodes + 1] = { t = "text", v = txt } + nodes[#nodes + 1] = { kind = "text", text = run } end end - for _, tk in ipairs(toks) do - if tk.k == "text" then - buf[#buf + 1] = tk.v - elseif tk.k == "font" then - flush(); font = tk.f + for _, token in ipairs(tokens) do + if token.kind == "text" then + parts[#parts + 1] = token.text + elseif token.kind == "font" then + flush(); font = token.font end end flush() @@ -467,66 +509,66 @@ if ok_lpeg then local function parse_man(text) local lines = split_lines(text) local blocks = {} - local para, pre, nofill = nil, nil, false - local function flush_para() - if para and #para > 0 then - blocks[#blocks + 1] = { t = "para", lines = para } + local paragraph, nofill_lines, nofill = nil, nil, false + local function flush_paragraph() + if paragraph and #paragraph > 0 then + blocks[#blocks + 1] = { kind = "para", lines = paragraph } end - para = nil + paragraph = nil end - local function flush_pre() - if pre then - local body = table.concat(pre, "\n") - if #pre > 0 then body = body .. "\n" end - blocks[#blocks + 1] = { t = "code", text = body } - pre = nil + local function flush_nofill() + if nofill_lines then + local body = table.concat(nofill_lines, "\n") + if #nofill_lines > 0 then body = body .. "\n" end + blocks[#blocks + 1] = { kind = "code", text = body } + nofill_lines = nil end end local function add_line(nodes) - para = para or {} - para[#para + 1] = nodes + paragraph = paragraph or {} + paragraph[#paragraph + 1] = nodes end for _, line in ipairs(lines) do - local macro, rest = m_macro:match(line) + local macro, rest = macro_line:match(line) if rest then rest = (rest:gsub('^"(.*)"$', "%1")) end if nofill then if macro == "fi" then - nofill = false; flush_pre() + nofill = false; flush_nofill() else - pre[#pre + 1] = line + nofill_lines[#nofill_lines + 1] = line end elseif line:match("^%s*$") then - flush_para() + flush_paragraph() elseif macro and macro:sub(1, 1) == "\\" then - -- roff comment, dropped + -- A roff comment, dropped. elseif macro == "SH" or macro == "SS" then - flush_para() + flush_paragraph() blocks[#blocks + 1] = { - t = "heading", + kind = "heading", level = (macro == "SH") and 2 or 3, kids = man_inline(rest), } elseif macro == "nf" then - flush_para(); nofill = true; pre = {} + flush_paragraph(); nofill = true; nofill_lines = {} elseif macro == "fi" then - flush_pre() + flush_nofill() elseif macro == "B" or macro == "BR" or macro == "RB" or macro == "BI" or macro == "IB" then - add_line({ { t = "strong", kids = man_inline(rest) } }) + add_line({ { kind = "strong", kids = man_inline(rest) } }) elseif macro == "I" or macro == "IR" or macro == "RI" then - add_line({ { t = "em", kids = man_inline(rest) } }) + add_line({ { kind = "em", kids = man_inline(rest) } }) elseif macro == "TH" or macro == "PP" or macro == "LP" or macro == "P" or macro == "TP" or macro == "IP" or macro == "HP" or macro == "RS" or macro == "RE" or macro == "sp" or macro == "br" then - flush_para() + flush_paragraph() elseif macro then if rest ~= "" then add_line(man_inline(rest)) end else add_line(man_inline(line)) end end - flush_para(); flush_pre() + flush_paragraph(); flush_nofill() return blocks end @@ -543,7 +585,8 @@ if ok_lpeg then html("
    ") end else - -- No lpeg, so markdown and man readmes read as escaped source, not nothing. + -- No lpeg, so markdown and man readmes read as escaped source, not + -- nothing. render_markdown = render_plaintext render_man = render_plaintext end @@ -566,6 +609,8 @@ 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 readme holding one is truncated there. function filter_close() local text = table.concat(chunks) chunks = {} 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. +-- 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. -- --- 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. +-- 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 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 --- --- luaossl OpenSSL binding, provides openssl.rand and openssl.hmac --- --- luaposix POSIX binding, provides posix.sys.stat and posix.unistd --- --- --- 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 , and luaposix provides +-- posix.sys.stat and posix.unistd and lives at +-- . 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. --- --- SECURITY NOTES +-- 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. -- --- 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. +-- 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. -- --- 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. +-- 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 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 --- --- luaossl OpenSSL binding, provides openssl.rand and openssl.hmac --- --- luaposix POSIX binding, provides posix.sys.stat and posix.unistd --- --- --- 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 , and luaposix provides +-- posix.sys.stat and posix.unistd and lives at +-- . 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 --- +-- The one dependency is luaossl, the OpenSSL binding that provides +-- openssl.digest, from . 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("" .. alt_text .. " " .. buffer) + html("" .. 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 --- +-- The one dependency is luaossl, the OpenSSL binding that provides +-- openssl.digest, from . 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("" .. alt_text .. " " .. buffer) + html("" .. 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 '' .. display .. '' + return "" .. display .. "" 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) --- /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 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 = "" .. part .. "" 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 -- cgit v2.8.0