-- 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. 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/local/lib/cgit/filters/about-render.lua -- Markdown and man pages need lpeg, built for the Lua cgit is linked -- against. Without it both fall back to escaped plain text. -- -- # 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 local has_lpeg, lpeg = pcall(require, "lpeg") -- 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 -- markers cannot drive the recursive parser into a stack overflow. local max_depth = 24 local filename, tree_base, tree_query, chunks = "", "", "", {} -- Lua patterns backtrack, so "%s+$" costs the square of a run of spaces in -- the middle of a line. Finding each end directly stays linear. local function trim(text) local first = text:find("%S") if first == nil then return "" end local last = #text while text:find("^%s", last) do last = last - 1 end return text:sub(first, last) end -- A heading may close with a run of hashes, which only counts as markup when -- a space comes before it, so "# C#" keeps its sharp. local function heading_text(title) title = trim(title) local last = #title while last > 0 and title:sub(last, last) == "#" do last = last - 1 end if last == 0 or title:find("^%s", last) then return trim(title:sub(1, last)) end return title end -- Normalise CRLF and CR to newlines, then split. 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 newline = text:find("\n", start, true) if not newline then lines[#lines + 1] = text:sub(start) return lines end lines[#lines + 1] = text:sub(start, newline - 1) start = newline + 1 end end local function split_cells(row) row = trim(row):gsub("^|", ""):gsub("|$", "") local cells = {} for cell in (row .. "|"):gmatch("(.-)|") do cells[#cells + 1] = trim(cell) end return cells end -- Control and whitespace bytes are stripped before the scheme check because -- a browser ignores them when resolving it, 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. if url:sub(1, 2) == "//" or url:sub(1, 2) == "/\\" then return nil end local scheme = url:match("^(%a[%w%+%.%-]*):") if scheme then scheme = scheme:lower() if scheme ~= "http" and scheme ~= "https" and scheme ~= "mailto" then return nil end end return url end -- A readme percent-encodes what a link path cannot carry, and the path is -- encoded again on its way out, so it is decoded first. An encoded NUL stays -- encoded, since cgit's sinks would stop at a real one. local function unescape_path(path) return (path:gsub("%%(%x%x)", function(hex) local byte = tonumber(hex, 16) if byte > 0 then return string.char(byte) end end)) end -- The path in the tree that a relative link names. Like GitHub, it resolves -- against the readme's own directory, or against the root when it starts -- with a slash, and dot segments fold without climbing above the root. local function tree_path(path) if path:sub(1, 1) ~= "/" then path = (filename:match("^(.*/)") or "") .. path end local segments = {} for segment in path:gmatch("[^/]+") do if segment == ".." then segments[#segments] = nil elseif segment ~= "." then segments[#segments + 1] = segment end end return table.concat(segments, "/") end -- Writes a link target that safe_url passed. A relative one leads into the -- tree at the ref the readme came from, keeping its fragment but not a -- query, which the tree page would read as its own. A readme with no tree -- behind it keeps its links as written. local function emit_href(url) if tree_base == "" or url:find("^#") or url:find("^%a[%w%+%.%-]*:") then html_attr(url) return end html_attr(tree_base) html_url_path(tree_path(unescape_path(url:match("^[^?#]*")))) html_attr(tree_query) html_attr(url:match("#.*$") or "") end -- The page renders untrusted repository content. Every run of text goes out -- through cgit's html_txt, every attribute value through html_attr or, for a -- path in the tree, html_url_path, and every link target through safe_url -- first. html() only ever gets literal tag scaffolding and the character -- references whose shape the markdown grammar checked. local emit_inline emit_inline = function(list) for _, node in ipairs(list) do local kind = node.kind if kind == "text" then html_txt(node.text) elseif kind == "reference" then html(node.text) elseif kind == "break" then html("
") 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(node.kids) end end end end -- Whether a paragraph has anything to show once images are left out, so a -- row of badges leaves no empty paragraph behind. local function shows_something(lines) for _, line in ipairs(lines) do for _, node in ipairs(line) do local blank = node.kind == "text" and not node.text:find("%S") if node.kind ~= "image" and not blank then return true end end end return false end local function emit_lines(lines) for i, line in ipairs(lines) do if i > 1 then html("\n") end emit_inline(line) end end local emit_blocks emit_blocks = function(blocks) 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(block.text)
			html("
") elseif kind == "blockquote" then html("
") emit_blocks(block.blocks) html("
") elseif kind == "table" then html("") for _, cell in ipairs(block.head) do html("") end html("") for _, row in ipairs(block.rows) do html("") for _, cell in ipairs(row) do html("") end html("") end html("
") emit_inline(cell) html("
") emit_inline(cell) html("
") elseif kind == "list" then local tag = block.ordered and "ol" or "ul" html("<" .. tag .. ">") for _, item in ipairs(block.items) do html("
  • ") emit_lines(item) html("
  • ") end html("") elseif kind == "para" and shows_something(block.lines) then html("

    ") emit_lines(block.lines) html("

    ") end end end local function render_plaintext(text) html("
    ")
    	html_txt(text)
    	html("
    ") end -- Markdown is a deliberate subset rather than CommonMark, leaving out -- reference links, raw HTML passthrough, nested lists and setext headings, -- and inline markup does not span the end of a line. Man rendering covers the -- common macros and drops the rest. Both parse into a node tree before -- anything is emitted, so a failed parse falls back to plain text without -- leaving half a page behind. local render_markdown, render_man if has_lpeg then local P, R, S, C, Ct = lpeg.P, lpeg.R, lpeg.S, lpeg.C, lpeg.Ct local space = S(" \t") local whitespace = S(" \t\r\n\f\v") local eol = P(-1) local marker = S("`![*_<\\&") local punctuation = S("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~") 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. local max_scan = 512 local parse_inline local inline_depth = 0 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() return { kind = "image" } end local function node_reference(text) return { kind = "reference", text = text } end local function node_autolink(url) return { kind = "link", kids = { node_text(url) }, url = url } end local function node_mail(address) return { kind = "link", kids = { node_text(address) }, url = "mailto:" .. address } end -- CommonMark's autolinks, an absolute url or an address between angle -- brackets. Anything else in brackets stays escaped text. local alnum = R("az", "AZ", "09") local scheme_char = alnum + S("+.-") local uri = R("az", "AZ") * scheme_char * scheme_char ^ -30 * P(":") * (1 - S("<>") - R("\0 ") - P("\127")) ^ 0 local address = (alnum + S(".!#$%&'*+/=?^_`{|}~-")) ^ 1 * P("@") * alnum * (alnum + S(".-")) ^ 0 -- A character reference passes through for the browser to decode, -- within CommonMark's lengths. Its shape leaves no room for markup, and -- a name the browser does not know shows as written. local digit, hex = R("09"), R("09", "af", "AF") local reference = P("&") * (R("az", "AZ") * alnum ^ -31 + P("#") * digit * digit ^ -6 + P("#") * S("xX") * hex * hex ^ -5) * P(";") local label = P("[") * C((1 - P("]")) ^ (-max_scan)) * P("]") local target = P("(") * space ^ 0 * C(url_char * url_char ^ (-max_scan)) * (1 - P(")")) ^ (-max_scan) * P(")") -- Images are parsed only to be left out, along with the link around a -- badge. The about page serves no files for a relative one to load, and -- a remote one would have every visitor's browser call a third party. local inline_grammar = Ct(( (P("`") * C((1 - P("`")) ^ 1) * P("`")) / node_code + (P("\\") * C(punctuation)) / node_text + C(reference) / node_reference + (P("<") * C(uri) * P(">")) / node_autolink + (P("<") * C(address) * P(">")) / node_mail + (P("[!") * label * target * P("]") * target) / node_image + (P("!") * label * target) / node_image + (label * target) / node_link + (P("**") * C((1 - P("**")) ^ 1) * P("**")) / node_strong + (P("__") * C((1 - P("__")) ^ 1) * P("__")) / node_strong + (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) parse_inline = function(s) if inline_depth >= max_depth then return { node_text(s) } end inline_depth = inline_depth + 1 local nodes = inline_grammar:match(s) or { node_text(s) } inline_depth = inline_depth - 1 return nodes end 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 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 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 local function table_starts(lines, i) return i < #lines and lines[i]:find("|", 1, true) ~= nil and table_delimiter:match(lines[i + 1]) ~= nil end -- A blank line ends a paragraph, and so does the start of any other -- block. That includes a table, which GitHub's own parser lets cut a -- paragraph short. local function ends_paragraph(lines, i) return lines[i]:match("^%s*$") or block_start:match(lines[i]) or table_starts(lines, i) end -- A paragraph's lines run together the way GitHub shows them, unless one -- ends in two spaces or a backslash, which breaks the line there. The -- last line of a paragraph has nothing to break before. local function paragraph_lines(raw) local lines = {} for i, line in ipairs(raw) do local hard = false if i < #raw then if line:sub(-1) == "\\" then line, hard = line:sub(1, -2), true else hard = line:sub(-2) == " " end end lines[i] = parse_inline(trim(line)) if hard then lines[i][#lines[i] + 1] = { kind = "break" } end end return lines end local parse_blocks parse_blocks = function(lines, depth) local blocks, i, n = {}, 1, #lines while i <= n do local line = lines[i] 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 close_backtick or close_tilde local code = {} i = i + 1 while i <= n and not close:match(lines[i]) do code[#code + 1] = lines[i] i = i + 1 end i = i + 1 local body = table.concat(code, "\n") if #code > 0 then body = body .. "\n" end blocks[#blocks + 1] = { kind = "code", lang = lang, text = body, } elseif hashes then blocks[#blocks + 1] = { kind = "heading", level = #hashes, kids = parse_inline(heading_text(title)), } i = i + 1 elseif break_line:match(line) then blocks[#blocks + 1] = { kind = "hr" } i = i + 1 elseif quote_line:match(line) then local inner = {} 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] = { kind = "blockquote", blocks = parse_blocks(inner, depth + 1), } else blocks[#blocks + 1] = { kind = "para", lines = paragraph_lines(inner), } end elseif table_starts(lines, i) then local head = {} 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 local row = {} 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] = { kind = "table", head = head, rows = rows, } elseif mark then local is_ordered = ordered(mark) local items = {} while i <= n do local item_mark, content = item_line:match(lines[i]) if not item_mark or ordered(item_mark) ~= is_ordered then break end local item = { content } i = i + 1 -- A wrapped line carries the item on, whatever its -- indent, while a rule still ends the list. while i <= n and not ends_paragraph(lines, i) and not break_line:match(lines[i]) do item[#item + 1] = lines[i] i = i + 1 end items[#items + 1] = paragraph_lines(item) end blocks[#blocks + 1] = { kind = "list", ordered = is_ordered, items = items, } else local paragraph = { line } i = i + 1 while i <= n and not ends_paragraph(lines, i) do paragraph[#paragraph + 1] = lines[i] i = i + 1 end blocks[#blocks + 1] = { kind = "para", lines = paragraph_lines(paragraph), } end end return blocks end render_markdown = function(text) if #text > max_bytes then return render_plaintext(text) end local ok, blocks = pcall(parse_blocks, split_lines(text), 0) if not ok then return render_plaintext(text) end html("
    ") -- 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 -- Roff fonts are a current state carried across a run rather than -- opened and closed, so the inline pass tokenises to a flat list -- and folds it into the nodes markdown emits, instead of nesting. 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" } local man_chars = { aq = "'", cq = "'", oq = "'", dq = '"', lq = '"', rq = '"', hy = "-", en = "-", em = "-", } 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(( (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(text) local tokens = man_inline_grammar:match(text) or {} local nodes, parts, font = {}, {}, "R" local function flush() if #parts == 0 then return end local run = table.concat(parts) parts = {} if run == "" then return end if font == "B" then nodes[#nodes + 1] = { kind = "strong", kids = { { kind = "text", text = run } }, } elseif font == "I" then nodes[#nodes + 1] = { kind = "em", kids = { { kind = "text", text = run } }, } else nodes[#nodes + 1] = { kind = "text", text = run } end end 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() return nodes end local function parse_man(text) local lines = split_lines(text) local blocks = {} 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 paragraph = nil end 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) paragraph = paragraph or {} paragraph[#paragraph + 1] = nodes end for _, line in ipairs(lines) do 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_nofill() else nofill_lines[#nofill_lines + 1] = line end elseif line:match("^%s*$") then flush_paragraph() elseif macro and macro:sub(1, 1) == "\\" then -- A roff comment, dropped. elseif macro == "SH" or macro == "SS" then flush_paragraph() blocks[#blocks + 1] = { kind = "heading", level = (macro == "SH") and 2 or 3, kids = man_inline(rest), } elseif macro == "nf" then flush_paragraph() nofill = true nofill_lines = {} elseif macro == "fi" then flush_nofill() elseif macro == "B" or macro == "BR" or macro == "RB" or macro == "BI" or macro == "IB" then add_line({ { kind = "strong", kids = man_inline(rest) } }) elseif macro == "I" or macro == "IR" or macro == "RI" then 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_paragraph() elseif macro then if rest ~= "" then add_line(man_inline(rest)) end else add_line(man_inline(line)) end end flush_paragraph() flush_nofill() return blocks end render_man = function(text) if #text > max_bytes then return render_plaintext(text) end local ok, blocks = pcall(parse_man, text) if not ok then return render_plaintext(text) end html("
    ") pcall(emit_blocks, blocks) html("
    ") end else -- No lpeg, so markdown and man readmes read as escaped source, not -- nothing. render_markdown = render_plaintext render_man = render_plaintext end local handlers = {} for _, ext in ipairs({ "md", "markdown", "mkd", "mdown" }) do handlers[ext] = render_markdown end for _, ext in ipairs({ "1", "2", "3", "4", "5", "6", "7", "8", "9", "man" }) do handlers[ext] = render_man end -- A readme tracked in the repository comes with the url of the tree page at -- its ref and the query that follows a path there, both escaped for a url. function filter_open(name, base, query) filename = name or "" tree_base = base or "" tree_query = query or "" chunks = {} end 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 write holding one loses everything from the NUL on. function filter_close() local text = table.concat(chunks) chunks = {} local ext = (filename:match("%.([^.]+)$") or ""):lower() local render = handlers[ext] or render_plaintext local ok = pcall(render, text) if not ok then render_plaintext(text) end return 0 end