Files
forge/modules/setting/markup.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

294 lines
9.8 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"regexp"
"strings"
"sync"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/util"
)
// ExternalMarkupRenderers represents the external markup renderers
var (
ExternalMarkupRenderers []*MarkupRenderer
ExternalSanitizerRules []MarkupSanitizerRule
MermaidMaxSourceCharacters int
)
const (
RenderContentModeSanitized = "sanitized"
RenderContentModeNoSanitizer = "no-sanitizer"
RenderContentModeIframe = "iframe"
)
type MarkdownRenderOptions struct {
NewLineHardBreak bool
ShortIssuePattern bool // Actually it is a "markup" option because it is used in "post processor"
}
type MarkdownMathCodeBlockOptions struct {
ParseInlineDollar bool
ParseInlineParentheses bool
ParseBlockDollar bool
ParseBlockSquareBrackets bool
}
// Markdown settings
var Markdown = struct {
RenderOptionsComment MarkdownRenderOptions `ini:"-"`
RenderOptionsWiki MarkdownRenderOptions `ini:"-"`
RenderOptionsRepoFile MarkdownRenderOptions `ini:"-"`
CustomURLSchemes []string `ini:"CUSTOM_URL_SCHEMES"` // Actually it is a "markup" option because it is used in "post processor"
FileNamePatterns []string `ini:"-"`
EnableMath bool
MathCodeBlockDetection []string
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
}{
EnableMath: true,
}
// MarkupRenderer defines the external parser configured in ini
type MarkupRenderer struct {
MarkupName string
Command string
FilePatterns []string
IsInputFile bool
NeedPostProcess bool
MarkupSanitizerRules []MarkupSanitizerRule
RenderContentMode string
RenderContentSandbox string
}
// MarkupSanitizerRule defines the policy for whitelisting attributes on
// certain elements.
type MarkupSanitizerRule struct {
Element string
AllowAttr string
Regexp string
AllowDataURIImages bool
}
func loadMarkupFrom(rootCfg ConfigProvider) {
mustMapSetting(rootCfg, "markdown", &Markdown)
markdownFileExtensions := rootCfg.Section("markdown").Key("FILE_EXTENSIONS").Strings(",")
if len(markdownFileExtensions) == 0 || len(markdownFileExtensions) == 1 && markdownFileExtensions[0] == "" {
markdownFileExtensions = []string{".md", ".markdown", ".mdown", ".mkd", ".livemd"}
}
Markdown.FileNamePatterns = fileExtensionsToPatterns("markdown", markdownFileExtensions)
const none = "none"
const renderOptionShortIssuePattern = "short-issue-pattern"
const renderOptionNewLineHardBreak = "new-line-hard-break"
cfgMarkdown := rootCfg.Section("markdown")
parseMarkdownRenderOptions := func(key string, defaults []string) (ret MarkdownRenderOptions) {
options := cfgMarkdown.Key(key).Strings(",")
options = util.IfEmpty(options, defaults)
for _, opt := range options {
switch opt {
case renderOptionShortIssuePattern:
ret.ShortIssuePattern = true
case renderOptionNewLineHardBreak:
ret.NewLineHardBreak = true
case none:
ret = MarkdownRenderOptions{}
case "":
default:
log.Error("Unknown markdown render option in %s: %s", key, opt)
}
}
return ret
}
Markdown.RenderOptionsComment = parseMarkdownRenderOptions("RENDER_OPTIONS_COMMENT", []string{renderOptionShortIssuePattern, renderOptionNewLineHardBreak})
Markdown.RenderOptionsWiki = parseMarkdownRenderOptions("RENDER_OPTIONS_WIKI", []string{renderOptionShortIssuePattern})
Markdown.RenderOptionsRepoFile = parseMarkdownRenderOptions("RENDER_OPTIONS_REPO_FILE", nil)
const mathCodeInlineDollar = "inline-dollar"
const mathCodeInlineParentheses = "inline-parentheses"
const mathCodeBlockDollar = "block-dollar"
const mathCodeBlockSquareBrackets = "block-square-brackets"
Markdown.MathCodeBlockDetection = util.IfEmpty(Markdown.MathCodeBlockDetection, []string{mathCodeInlineDollar, mathCodeBlockDollar})
Markdown.MathCodeBlockOptions = MarkdownMathCodeBlockOptions{}
for _, s := range Markdown.MathCodeBlockDetection {
switch s {
case mathCodeInlineDollar:
Markdown.MathCodeBlockOptions.ParseInlineDollar = true
case mathCodeInlineParentheses:
Markdown.MathCodeBlockOptions.ParseInlineParentheses = true
case mathCodeBlockDollar:
Markdown.MathCodeBlockOptions.ParseBlockDollar = true
case mathCodeBlockSquareBrackets:
Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets = true
case none:
Markdown.MathCodeBlockOptions = MarkdownMathCodeBlockOptions{}
case "":
default:
log.Error("Unknown math code block detection option: %s", s)
}
}
MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(50000)
ExternalMarkupRenderers = make([]*MarkupRenderer, 0, 10)
ExternalSanitizerRules = make([]MarkupSanitizerRule, 0, 10)
for _, sec := range rootCfg.Section("markup").ChildSections() {
name := strings.TrimPrefix(sec.Name(), "markup.")
if name == "" {
log.Warn("name is empty, markup " + sec.Name() + "ignored")
continue
}
if name == "sanitizer" || strings.HasPrefix(name, "sanitizer.") {
newMarkupSanitizer(name, sec)
} else {
newMarkupRenderer(name, sec)
}
}
}
func newMarkupSanitizer(name string, sec ConfigSection) {
rule, ok := createMarkupSanitizerRule(name, sec)
if ok {
if after, found := strings.CutPrefix(name, "sanitizer."); found {
names := strings.SplitN(after, ".", 2)
name = names[0]
}
for _, renderer := range ExternalMarkupRenderers {
if name == renderer.MarkupName {
renderer.MarkupSanitizerRules = append(renderer.MarkupSanitizerRules, rule)
return
}
}
ExternalSanitizerRules = append(ExternalSanitizerRules, rule)
}
}
func createMarkupSanitizerRule(name string, sec ConfigSection) (MarkupSanitizerRule, bool) {
var rule MarkupSanitizerRule
ok := false
if sec.HasKey("ALLOW_DATA_URI_IMAGES") {
rule.AllowDataURIImages = sec.Key("ALLOW_DATA_URI_IMAGES").MustBool(false)
ok = true
}
if sec.HasKey("ELEMENT") || sec.HasKey("ALLOW_ATTR") {
rule.Element = sec.Key("ELEMENT").Value()
rule.AllowAttr = sec.Key("ALLOW_ATTR").Value()
if rule.Element == "" || rule.AllowAttr == "" {
log.Error("Missing required values from markup.%s. Must have ELEMENT and ALLOW_ATTR defined!", name)
return rule, false
}
regexpStr := sec.Key("REGEXP").Value()
if regexpStr != "" {
hasPrefix := strings.HasPrefix(regexpStr, "^")
hasSuffix := strings.HasSuffix(regexpStr, "$")
if !hasPrefix || !hasSuffix {
log.Error("In markup.%s: REGEXP must start with ^ and end with $ to be strict", name)
// to avoid breaking existing user configurations and satisfy the strict requirement in addSanitizerRules
if !hasPrefix {
regexpStr = "^.*" + regexpStr
}
if !hasSuffix {
regexpStr += ".*$"
}
}
_, err := regexp.Compile(regexpStr)
if err != nil {
log.Error("In markup.%s: REGEXP (%s) failed to compile: %v", name, regexpStr, err)
return rule, false
}
rule.Regexp = regexpStr
}
ok = true
}
if !ok {
log.Error("Missing required keys from markup.%s. Must have ELEMENT and ALLOW_ATTR or ALLOW_DATA_URI_IMAGES defined!", name)
return rule, false
}
return rule, true
}
var extensionReg = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`^(\.[-\w]+)+$`)
})
func fileExtensionsToPatterns(sectionName string, extensions []string) []string {
patterns := make([]string, 0, len(extensions))
for _, extension := range extensions {
if !extensionReg().MatchString(extension) {
log.Warn("Config section %s file extension %s is invalid. Extension ignored", sectionName, extension)
} else {
patterns = append(patterns, "*"+extension)
}
}
return patterns
}
// MarkupRenderDefaultSandbox only contains a safe set of "sandbox allow" values, it is used to protect users from XSS attack,
// DO NOT USE "allow-same-origin" by default: if there is XSS in rendered content, same-origin makes the frame page can access parent window and send requests with user's credentials.
const MarkupRenderDefaultSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
func newMarkupRenderer(name string, sec ConfigSection) {
if !sec.Key("ENABLED").MustBool(false) {
return
}
fileNamePatterns := fileExtensionsToPatterns(name, sec.Key("FILE_EXTENSIONS").Strings(","))
if len(fileNamePatterns) == 0 {
log.Warn("Config section %s file extension is empty, markup render is ignored", name)
return
}
command := sec.Key("RENDER_COMMAND").MustString("")
if command == "" {
log.Warn(" RENDER_COMMAND is empty, markup " + name + " ignored")
return
}
if sec.HasKey("DISABLE_SANITIZER") {
log.Error("Deprecated setting `[markup.*]` `DISABLE_SANITIZER` present. This fallback will be removed in v1.18.0")
}
renderContentMode := sec.Key("RENDER_CONTENT_MODE").MustString(RenderContentModeSanitized)
if !sec.HasKey("RENDER_CONTENT_MODE") && sec.Key("DISABLE_SANITIZER").MustBool(false) {
renderContentMode = RenderContentModeNoSanitizer // if only the legacy DISABLE_SANITIZER exists, use it
}
if renderContentMode != RenderContentModeSanitized &&
renderContentMode != RenderContentModeNoSanitizer &&
renderContentMode != RenderContentModeIframe {
log.Error("invalid RENDER_CONTENT_MODE: %q, default to %q", renderContentMode, RenderContentModeSanitized)
renderContentMode = RenderContentModeSanitized
}
renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString(MarkupRenderDefaultSandbox)
if renderContentSandbox == "disabled" {
renderContentSandbox = ""
}
ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
MarkupName: name,
FilePatterns: fileNamePatterns,
Command: command,
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
RenderContentMode: renderContentMode,
RenderContentSandbox: renderContentSandbox,
// if no sanitizer is needed, no post process is needed
NeedPostProcess: sec.Key("NEED_POST_PROCESS").MustBool(renderContentMode == RenderContentModeSanitized),
})
}