mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
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]>
294 lines
9.8 KiB
Go
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),
|
|
})
|
|
}
|