summaryrefslogtreecommitdiffstats
path: root/modules/templates
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /modules/templates
parentInitial commit. (diff)
downloadforgejo-upstream.tar.xz
forgejo-upstream.zip
Adding upstream version 9.0.0.HEADupstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--modules/templates/base.go40
-rw-r--r--modules/templates/dynamic.go15
-rw-r--r--modules/templates/eval/eval.go344
-rw-r--r--modules/templates/eval/eval_test.go94
-rw-r--r--modules/templates/helper.go269
-rw-r--r--modules/templates/helper_test.go67
-rw-r--r--modules/templates/htmlrenderer.go287
-rw-r--r--modules/templates/htmlrenderer_test.go107
-rw-r--r--modules/templates/mailer.go110
-rw-r--r--modules/templates/main_test.go24
-rw-r--r--modules/templates/scopedtmpl/scopedtmpl.go239
-rw-r--r--modules/templates/scopedtmpl/scopedtmpl_test.go99
-rw-r--r--modules/templates/static.go22
-rw-r--r--modules/templates/templates_bindata.go8
-rw-r--r--modules/templates/util_avatar.go81
-rw-r--r--modules/templates/util_dict.go121
-rw-r--r--modules/templates/util_json.go35
-rw-r--r--modules/templates/util_misc.go193
-rw-r--r--modules/templates/util_render.go264
-rw-r--r--modules/templates/util_render_test.go223
-rw-r--r--modules/templates/util_slice.go35
-rw-r--r--modules/templates/util_string.go68
-rw-r--r--modules/templates/util_string_test.go20
-rw-r--r--modules/templates/util_test.go79
-rw-r--r--modules/templates/vars/vars.go92
-rw-r--r--modules/templates/vars/vars_test.go72
26 files changed, 3008 insertions, 0 deletions
diff --git a/modules/templates/base.go b/modules/templates/base.go
new file mode 100644
index 0000000..2c2f35b
--- /dev/null
+++ b/modules/templates/base.go
@@ -0,0 +1,40 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "slices"
+ "strings"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func AssetFS() *assetfs.LayeredFS {
+ return assetfs.Layered(CustomAssets(), BuiltinAssets())
+}
+
+func CustomAssets() *assetfs.Layer {
+ return assetfs.Local("custom", setting.CustomPath, "templates")
+}
+
+func ListWebTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
+ files, err := assets.ListAllFiles(".", true)
+ if err != nil {
+ return nil, err
+ }
+ return slices.DeleteFunc(files, func(file string) bool {
+ return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
+ }), nil
+}
+
+func ListMailTemplateAssetNames(assets *assetfs.LayeredFS) ([]string, error) {
+ files, err := assets.ListAllFiles(".", true)
+ if err != nil {
+ return nil, err
+ }
+ return slices.DeleteFunc(files, func(file string) bool {
+ return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
+ }), nil
+}
diff --git a/modules/templates/dynamic.go b/modules/templates/dynamic.go
new file mode 100644
index 0000000..e1babd8
--- /dev/null
+++ b/modules/templates/dynamic.go
@@ -0,0 +1,15 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !bindata
+
+package templates
+
+import (
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Local("builtin(static)", setting.StaticRootPath, "templates")
+}
diff --git a/modules/templates/eval/eval.go b/modules/templates/eval/eval.go
new file mode 100644
index 0000000..5d4ac91
--- /dev/null
+++ b/modules/templates/eval/eval.go
@@ -0,0 +1,344 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eval
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+type Num struct {
+ Value any // int64 or float64, nil on error
+}
+
+var opPrecedence = map[string]int{
+ // "(": 1, this is for low precedence like function calls, they are handled separately
+ "or": 2,
+ "and": 3,
+ "not": 4,
+ "==": 5, "!=": 5, "<": 5, "<=": 5, ">": 5, ">=": 5,
+ "+": 6, "-": 6,
+ "*": 7, "/": 7,
+}
+
+type stack[T any] struct {
+ name string
+ elems []T
+}
+
+func (s *stack[T]) push(t T) {
+ s.elems = append(s.elems, t)
+}
+
+func (s *stack[T]) pop() T {
+ if len(s.elems) == 0 {
+ panic(s.name + " stack is empty")
+ }
+ t := s.elems[len(s.elems)-1]
+ s.elems = s.elems[:len(s.elems)-1]
+ return t
+}
+
+func (s *stack[T]) peek() T {
+ if len(s.elems) == 0 {
+ panic(s.name + " stack is empty")
+ }
+ return s.elems[len(s.elems)-1]
+}
+
+type operator string
+
+type eval struct {
+ stackNum stack[Num]
+ stackOp stack[operator]
+ funcMap map[string]func([]Num) Num
+}
+
+func newEval() *eval {
+ e := &eval{}
+ e.stackNum.name = "num"
+ e.stackOp.name = "op"
+ return e
+}
+
+func toNum(v any) (Num, error) {
+ switch v := v.(type) {
+ case string:
+ if strings.Contains(v, ".") {
+ n, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return Num{n}, err
+ }
+ return Num{n}, nil
+ }
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return Num{n}, err
+ }
+ return Num{n}, nil
+ case float32, float64:
+ n, _ := util.ToFloat64(v)
+ return Num{n}, nil
+ default:
+ n, err := util.ToInt64(v)
+ if err != nil {
+ return Num{n}, err
+ }
+ return Num{n}, nil
+ }
+}
+
+func truth(b bool) int64 {
+ if b {
+ return int64(1)
+ }
+ return int64(0)
+}
+
+func applyOp2Generic[T int64 | float64](op operator, n1, n2 T) Num {
+ switch op {
+ case "+":
+ return Num{n1 + n2}
+ case "-":
+ return Num{n1 - n2}
+ case "*":
+ return Num{n1 * n2}
+ case "/":
+ return Num{n1 / n2}
+ case "==":
+ return Num{truth(n1 == n2)}
+ case "!=":
+ return Num{truth(n1 != n2)}
+ case "<":
+ return Num{truth(n1 < n2)}
+ case "<=":
+ return Num{truth(n1 <= n2)}
+ case ">":
+ return Num{truth(n1 > n2)}
+ case ">=":
+ return Num{truth(n1 >= n2)}
+ case "and":
+ t1, _ := util.ToFloat64(n1)
+ t2, _ := util.ToFloat64(n2)
+ return Num{truth(t1 != 0 && t2 != 0)}
+ case "or":
+ t1, _ := util.ToFloat64(n1)
+ t2, _ := util.ToFloat64(n2)
+ return Num{truth(t1 != 0 || t2 != 0)}
+ }
+ panic("unknown operator: " + string(op))
+}
+
+func applyOp2(op operator, n1, n2 Num) Num {
+ float := false
+ if _, ok := n1.Value.(float64); ok {
+ float = true
+ } else if _, ok = n2.Value.(float64); ok {
+ float = true
+ }
+ if float {
+ f1, _ := util.ToFloat64(n1.Value)
+ f2, _ := util.ToFloat64(n2.Value)
+ return applyOp2Generic(op, f1, f2)
+ }
+ return applyOp2Generic(op, n1.Value.(int64), n2.Value.(int64))
+}
+
+func toOp(v any) (operator, error) {
+ if v, ok := v.(string); ok {
+ return operator(v), nil
+ }
+ return "", fmt.Errorf(`unsupported token type "%T"`, v)
+}
+
+func (op operator) hasOpenBracket() bool {
+ return strings.HasSuffix(string(op), "(") // it's used to support functions like "sum("
+}
+
+func (op operator) isComma() bool {
+ return op == ","
+}
+
+func (op operator) isCloseBracket() bool {
+ return op == ")"
+}
+
+type ExprError struct {
+ msg string
+ tokens []any
+ err error
+}
+
+func (err ExprError) Error() string {
+ sb := strings.Builder{}
+ sb.WriteString(err.msg)
+ sb.WriteString(" [ ")
+ for _, token := range err.tokens {
+ _, _ = fmt.Fprintf(&sb, `"%v" `, token)
+ }
+ sb.WriteString("]")
+ if err.err != nil {
+ sb.WriteString(": ")
+ sb.WriteString(err.err.Error())
+ }
+ return sb.String()
+}
+
+func (err ExprError) Unwrap() error {
+ return err.err
+}
+
+func (e *eval) applyOp() {
+ op := e.stackOp.pop()
+ if op == "not" {
+ num := e.stackNum.pop()
+ i, _ := util.ToInt64(num.Value)
+ e.stackNum.push(Num{truth(i == 0)})
+ } else if op.hasOpenBracket() || op.isCloseBracket() || op.isComma() {
+ panic(fmt.Sprintf("incomplete sub-expression with operator %q", op))
+ } else {
+ num2 := e.stackNum.pop()
+ num1 := e.stackNum.pop()
+ e.stackNum.push(applyOp2(op, num1, num2))
+ }
+}
+
+func (e *eval) exec(tokens ...any) (ret Num, err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ rErr, ok := r.(error)
+ if !ok {
+ rErr = fmt.Errorf("%v", r)
+ }
+ err = ExprError{"invalid expression", tokens, rErr}
+ }
+ }()
+ for _, token := range tokens {
+ n, err := toNum(token)
+ if err == nil {
+ e.stackNum.push(n)
+ continue
+ }
+
+ op, err := toOp(token)
+ if err != nil {
+ return Num{}, ExprError{"invalid expression", tokens, err}
+ }
+
+ switch {
+ case op.hasOpenBracket():
+ e.stackOp.push(op)
+ case op.isCloseBracket(), op.isComma():
+ var stackTopOp operator
+ for len(e.stackOp.elems) > 0 {
+ stackTopOp = e.stackOp.peek()
+ if stackTopOp.hasOpenBracket() || stackTopOp.isComma() {
+ break
+ }
+ e.applyOp()
+ }
+ if op.isCloseBracket() {
+ nums := []Num{e.stackNum.pop()}
+ for !e.stackOp.peek().hasOpenBracket() {
+ stackTopOp = e.stackOp.pop()
+ if !stackTopOp.isComma() {
+ return Num{}, ExprError{"bracket doesn't match", tokens, nil}
+ }
+ nums = append(nums, e.stackNum.pop())
+ }
+ for i, j := 0, len(nums)-1; i < j; i, j = i+1, j-1 {
+ nums[i], nums[j] = nums[j], nums[i] // reverse nums slice, to get the right order for arguments
+ }
+ stackTopOp = e.stackOp.pop()
+ fn := string(stackTopOp[:len(stackTopOp)-1])
+ if fn == "" {
+ if len(nums) != 1 {
+ return Num{}, ExprError{"too many values in one bracket", tokens, nil}
+ }
+ e.stackNum.push(nums[0])
+ } else if f, ok := e.funcMap[fn]; ok {
+ e.stackNum.push(f(nums))
+ } else {
+ return Num{}, ExprError{"unknown function: " + fn, tokens, nil}
+ }
+ } else {
+ e.stackOp.push(op)
+ }
+ default:
+ for len(e.stackOp.elems) > 0 && len(e.stackNum.elems) > 0 {
+ stackTopOp := e.stackOp.peek()
+ if stackTopOp.hasOpenBracket() || stackTopOp.isComma() || precedence(stackTopOp, op) < 0 {
+ break
+ }
+ e.applyOp()
+ }
+ e.stackOp.push(op)
+ }
+ }
+ for len(e.stackOp.elems) > 0 && !e.stackOp.peek().isComma() {
+ e.applyOp()
+ }
+ if len(e.stackNum.elems) != 1 {
+ return Num{}, ExprError{fmt.Sprintf("expect 1 value as final result, but there are %d", len(e.stackNum.elems)), tokens, nil}
+ }
+ return e.stackNum.pop(), nil
+}
+
+func precedence(op1, op2 operator) int {
+ p1 := opPrecedence[string(op1)]
+ p2 := opPrecedence[string(op2)]
+ if p1 == 0 {
+ panic("unknown operator precedence: " + string(op1))
+ } else if p2 == 0 {
+ panic("unknown operator precedence: " + string(op2))
+ }
+ return p1 - p2
+}
+
+func castFloat64(nums []Num) bool {
+ hasFloat := false
+ for _, num := range nums {
+ if _, hasFloat = num.Value.(float64); hasFloat {
+ break
+ }
+ }
+ if hasFloat {
+ for i, num := range nums {
+ if _, ok := num.Value.(float64); !ok {
+ f, _ := util.ToFloat64(num.Value)
+ nums[i] = Num{f}
+ }
+ }
+ }
+ return hasFloat
+}
+
+func fnSum(nums []Num) Num {
+ if castFloat64(nums) {
+ var sum float64
+ for _, num := range nums {
+ sum += num.Value.(float64)
+ }
+ return Num{sum}
+ }
+ var sum int64
+ for _, num := range nums {
+ sum += num.Value.(int64)
+ }
+ return Num{sum}
+}
+
+// Expr evaluates the given expression tokens and returns the result.
+// It supports the following operators: +, -, *, /, and, or, not, ==, !=, >, >=, <, <=.
+// Non-zero values are treated as true, zero values are treated as false.
+// If no error occurs, the result is either an int64 or a float64.
+// If all numbers are integer, the result is an int64, otherwise if there is any float number, the result is a float64.
+func Expr(tokens ...any) (Num, error) {
+ e := newEval()
+ e.funcMap = map[string]func([]Num) Num{"sum": fnSum}
+ return e.exec(tokens...)
+}
diff --git a/modules/templates/eval/eval_test.go b/modules/templates/eval/eval_test.go
new file mode 100644
index 0000000..3e68203
--- /dev/null
+++ b/modules/templates/eval/eval_test.go
@@ -0,0 +1,94 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package eval
+
+import (
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func tokens(s string) (a []any) {
+ for _, v := range strings.Fields(s) {
+ a = append(a, v)
+ }
+ return a
+}
+
+func TestEval(t *testing.T) {
+ n, err := Expr(0, "/", 0.0)
+ require.NoError(t, err)
+ assert.True(t, math.IsNaN(n.Value.(float64)))
+
+ _, err = Expr(nil)
+ require.ErrorContains(t, err, "unsupported token type")
+ _, err = Expr([]string{})
+ require.ErrorContains(t, err, "unsupported token type")
+ _, err = Expr(struct{}{})
+ require.ErrorContains(t, err, "unsupported token type")
+
+ cases := []struct {
+ expr string
+ want any
+ }{
+ {"-1", int64(-1)},
+ {"1 + 2", int64(3)},
+ {"3 - 2 + 4", int64(5)},
+ {"1 + 2 * 3", int64(7)},
+ {"1 + ( 2 * 3 )", int64(7)},
+ {"( 1 + 2 ) * 3", int64(9)},
+ {"( 1 + 2.0 ) / 3", float64(1)},
+ {"sum( 1 , 2 , 3 , 4 )", int64(10)},
+ {"100 + sum( 1 , 2 + 3 , 0.0 ) / 2", float64(103)},
+ {"100 * 5 / ( 5 + 15 )", int64(25)},
+ {"9 == 5", int64(0)},
+ {"5 == 5", int64(1)},
+ {"9 != 5", int64(1)},
+ {"5 != 5", int64(0)},
+ {"9 > 5", int64(1)},
+ {"5 > 9", int64(0)},
+ {"5 >= 9", int64(0)},
+ {"9 >= 9", int64(1)},
+ {"9 < 5", int64(0)},
+ {"5 < 9", int64(1)},
+ {"9 <= 5", int64(0)},
+ {"5 <= 5", int64(1)},
+ {"1 and 2", int64(1)}, // Golang template definition: non-zero values are all truth
+ {"1 and 0", int64(0)},
+ {"0 and 0", int64(0)},
+ {"1 or 2", int64(1)},
+ {"1 or 0", int64(1)},
+ {"0 or 1", int64(1)},
+ {"0 or 0", int64(0)},
+ {"not 2 == 1", int64(1)},
+ {"not not ( 9 < 5 )", int64(0)},
+ }
+
+ for _, c := range cases {
+ n, err := Expr(tokens(c.expr)...)
+ require.NoError(t, err, "expr: %s", c.expr)
+ assert.Equal(t, c.want, n.Value)
+ }
+
+ bads := []struct {
+ expr string
+ errMsg string
+ }{
+ {"0 / 0", "integer divide by zero"},
+ {"1 +", "num stack is empty"},
+ {"+ 1", "num stack is empty"},
+ {"( 1", "incomplete sub-expression"},
+ {"1 )", "op stack is empty"}, // can not find the corresponding open bracket after the stack becomes empty
+ {"1 , 2", "expect 1 value as final result"},
+ {"( 1 , 2 )", "too many values in one bracket"},
+ {"1 a 2", "unknown operator"},
+ }
+ for _, c := range bads {
+ _, err = Expr(tokens(c.expr)...)
+ require.ErrorContains(t, err, c.errMsg, "expr: %s", c.expr)
+ }
+}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
new file mode 100644
index 0000000..aeae820
--- /dev/null
+++ b/modules/templates/helper.go
@@ -0,0 +1,269 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "html"
+ "html/template"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/svg"
+ "code.gitea.io/gitea/modules/templates/eval"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/gitdiff"
+)
+
+// NewFuncMap returns functions for injecting to templates
+func NewFuncMap() template.FuncMap {
+ return map[string]any{
+ "ctx": func() any { return nil }, // template context function
+
+ "DumpVar": dumpVar,
+
+ // -----------------------------------------------------------------
+ // html/template related functions
+ "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
+ "Eval": Eval,
+ "SafeHTML": SafeHTML,
+ "HTMLFormat": HTMLFormat,
+ "HTMLEscape": HTMLEscape,
+ "QueryEscape": QueryEscape,
+ "JSEscape": JSEscapeSafe,
+ "SanitizeHTML": SanitizeHTML,
+ "URLJoin": util.URLJoin,
+ "DotEscape": DotEscape,
+
+ "PathEscape": url.PathEscape,
+ "PathEscapeSegments": util.PathEscapeSegments,
+
+ // utils
+ "StringUtils": NewStringUtils,
+ "SliceUtils": NewSliceUtils,
+ "JsonUtils": NewJsonUtils,
+
+ // -----------------------------------------------------------------
+ // svg / avatar / icon / color
+ "svg": svg.RenderHTML,
+ "EntryIcon": base.EntryIcon,
+ "MigrationIcon": MigrationIcon,
+ "ActionIcon": ActionIcon,
+ "SortArrow": SortArrow,
+ "ContrastColor": util.ContrastColor,
+
+ // -----------------------------------------------------------------
+ // time / number / format
+ "FileSize": FileSizePanic,
+ "CountFmt": base.FormatNumberSI,
+ "TimeSince": timeutil.TimeSince,
+ "TimeSinceUnix": timeutil.TimeSinceUnix,
+ "DateTime": timeutil.DateTime,
+ "Sec2Time": util.SecToTime,
+ "LoadTimes": func(startTime time.Time) string {
+ return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
+ },
+
+ // -----------------------------------------------------------------
+ // setting
+ "AppName": func() string {
+ return setting.AppName
+ },
+ "AppSlogan": func() string {
+ return setting.AppSlogan
+ },
+ "AppDisplayName": func() string {
+ return setting.AppDisplayName
+ },
+ "AppSubUrl": func() string {
+ return setting.AppSubURL
+ },
+ "AssetUrlPrefix": func() string {
+ return setting.StaticURLPrefix + "/assets"
+ },
+ "AppUrl": func() string {
+ // The usage of AppUrl should be avoided as much as possible,
+ // because the AppURL(ROOT_URL) may not match user's visiting site and the ROOT_URL in app.ini may be incorrect.
+ // And it's difficult for Gitea to guess absolute URL correctly with zero configuration,
+ // because Gitea doesn't know whether the scheme is HTTP or HTTPS unless the reverse proxy could tell Gitea.
+ return setting.AppURL
+ },
+ "AppVer": func() string {
+ return setting.AppVer
+ },
+ "AppDomain": func() string { // documented in mail-templates.md
+ return setting.Domain
+ },
+ "RepoFlagsEnabled": func() bool {
+ return setting.Repository.EnableFlags
+ },
+ "AssetVersion": func() string {
+ return setting.AssetVersion
+ },
+ "DefaultShowFullName": func() bool {
+ return setting.UI.DefaultShowFullName
+ },
+ "ShowFooterTemplateLoadTime": func() bool {
+ return setting.Other.ShowFooterTemplateLoadTime
+ },
+ "ShowFooterPoweredBy": func() bool {
+ return setting.Other.ShowFooterPoweredBy
+ },
+ "AllowedReactions": func() []string {
+ return setting.UI.Reactions
+ },
+ "CustomEmojis": func() map[string]string {
+ return setting.UI.CustomEmojisMap
+ },
+ "MetaAuthor": func() string {
+ return setting.UI.Meta.Author
+ },
+ "MetaDescription": func() string {
+ return setting.UI.Meta.Description
+ },
+ "MetaKeywords": func() string {
+ return setting.UI.Meta.Keywords
+ },
+ "EnableTimetracking": func() bool {
+ return setting.Service.EnableTimetracking
+ },
+ "DisableGitHooks": func() bool {
+ return setting.DisableGitHooks
+ },
+ "DisableWebhooks": func() bool {
+ return setting.DisableWebhooks
+ },
+ "DisableImportLocal": func() bool {
+ return !setting.ImportLocalPaths
+ },
+ "ThemeName": func(user *user_model.User) string {
+ if user == nil || user.Theme == "" {
+ return setting.UI.DefaultTheme
+ }
+ return user.Theme
+ },
+ "NotificationSettings": func() map[string]any {
+ return map[string]any{
+ "MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
+ "TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
+ "MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
+ "EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond),
+ }
+ },
+ "MermaidMaxSourceCharacters": func() int {
+ return setting.MermaidMaxSourceCharacters
+ },
+ "FederationEnabled": func() bool {
+ return setting.Federation.Enabled
+ },
+
+ // -----------------------------------------------------------------
+ // render
+ "RenderCommitMessage": RenderCommitMessage,
+ "RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject,
+
+ "RenderCommitBody": RenderCommitBody,
+ "RenderCodeBlock": RenderCodeBlock,
+ "RenderIssueTitle": RenderIssueTitle,
+ "RenderRefIssueTitle": RenderRefIssueTitle,
+ "RenderEmoji": RenderEmoji,
+ "ReactionToEmoji": ReactionToEmoji,
+
+ "RenderMarkdownToHtml": RenderMarkdownToHtml,
+ "RenderLabel": RenderLabel,
+ "RenderLabels": RenderLabels,
+
+ // -----------------------------------------------------------------
+ // misc
+ "ShortSha": base.ShortSha,
+ "ActionContent2Commits": ActionContent2Commits,
+ "IsMultilineCommitMessage": IsMultilineCommitMessage,
+ "CommentMustAsDiff": gitdiff.CommentMustAsDiff,
+ "MirrorRemoteAddress": mirrorRemoteAddress,
+
+ "FilenameIsImage": FilenameIsImage,
+ "TabSizeClass": TabSizeClass,
+ }
+}
+
+func HTMLFormat(s string, rawArgs ...any) template.HTML {
+ args := slices.Clone(rawArgs)
+ for i, v := range args {
+ switch v := v.(type) {
+ case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
+ // for most basic types (including template.HTML which is safe), just do nothing and use it
+ case string:
+ args[i] = template.HTMLEscapeString(v)
+ case fmt.Stringer:
+ args[i] = template.HTMLEscapeString(v.String())
+ default:
+ args[i] = template.HTMLEscapeString(fmt.Sprint(v))
+ }
+ }
+ return template.HTML(fmt.Sprintf(s, args...))
+}
+
+// SafeHTML render raw as HTML
+func SafeHTML(s any) template.HTML {
+ switch v := s.(type) {
+ case string:
+ return template.HTML(v)
+ case template.HTML:
+ return v
+ }
+ panic(fmt.Sprintf("unexpected type %T", s))
+}
+
+// SanitizeHTML sanitizes the input by pre-defined markdown rules
+func SanitizeHTML(s string) template.HTML {
+ return template.HTML(markup.Sanitize(s))
+}
+
+func HTMLEscape(s any) template.HTML {
+ switch v := s.(type) {
+ case string:
+ return template.HTML(html.EscapeString(v))
+ case template.HTML:
+ return v
+ }
+ panic(fmt.Sprintf("unexpected type %T", s))
+}
+
+func JSEscapeSafe(s string) template.HTML {
+ return template.HTML(template.JSEscapeString(s))
+}
+
+func QueryEscape(s string) template.URL {
+ return template.URL(url.QueryEscape(s))
+}
+
+// DotEscape wraps a dots in names with ZWJ [U+200D] in order to prevent autolinkers from detecting these as urls
+func DotEscape(raw string) string {
+ return strings.ReplaceAll(raw, ".", "\u200d.\u200d")
+}
+
+// Eval the expression and return the result, see the comment of eval.Expr for details.
+// To use this helper function in templates, pass each token as a separate parameter.
+//
+// {{ $int64 := Eval $var "+" 1 }}
+// {{ $float64 := Eval $var "+" 1.0 }}
+//
+// Golang's template supports comparable int types, so the int64 result can be used in later statements like {{if lt $int64 10}}
+func Eval(tokens ...any) (any, error) {
+ n, err := eval.Expr(tokens...)
+ return n.Value, err
+}
+
+func FileSizePanic(s int64) string {
+ panic("Usage of FileSize in templates is deprecated in Forgejo. Locale.TrSize should be used instead.")
+}
diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go
new file mode 100644
index 0000000..0cefb7a
--- /dev/null
+++ b/modules/templates/helper_test.go
@@ -0,0 +1,67 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "html/template"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSubjectBodySeparator(t *testing.T) {
+ test := func(input, subject, body string) {
+ loc := mailSubjectSplit.FindIndex([]byte(input))
+ if loc == nil {
+ assert.Empty(t, subject, "no subject found, but one expected")
+ assert.Equal(t, body, input)
+ } else {
+ assert.Equal(t, subject, input[0:loc[0]])
+ assert.Equal(t, body, input[loc[1]:])
+ }
+ }
+
+ test("Simple\n---------------\nCase",
+ "Simple\n",
+ "\nCase")
+ test("Only\nBody",
+ "",
+ "Only\nBody")
+ test("Minimal\n---\nseparator",
+ "Minimal\n",
+ "\nseparator")
+ test("False --- separator",
+ "",
+ "False --- separator")
+ test("False\n--- separator",
+ "",
+ "False\n--- separator")
+ test("False ---\nseparator",
+ "",
+ "False ---\nseparator")
+ test("With extra spaces\n----- \t \nBody",
+ "With extra spaces\n",
+ "\nBody")
+ test("With leading spaces\n -------\nOnly body",
+ "",
+ "With leading spaces\n -------\nOnly body")
+ test("Multiple\n---\n-------\n---\nSeparators",
+ "Multiple\n",
+ "\n-------\n---\nSeparators")
+ test("Insufficient\n--\nSeparators",
+ "",
+ "Insufficient\n--\nSeparators")
+}
+
+func TestJSEscapeSafe(t *testing.T) {
+ assert.EqualValues(t, `\u0026\u003C\u003E\'\"`, JSEscapeSafe(`&<>'"`))
+}
+
+func TestHTMLFormat(t *testing.T) {
+ assert.Equal(t, template.HTML("<a>&lt; < 1</a>"), HTMLFormat("<a>%s %s %d</a>", "<", template.HTML("<"), 1))
+}
+
+func TestSanitizeHTML(t *testing.T) {
+ assert.Equal(t, template.HTML(`<a href="/" rel="nofollow">link</a> xss <div>inline</div>`), SanitizeHTML(`<a href="/">link</a> <a href="javascript:">xss</a> <div style="dangerous">inline</div>`))
+}
diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go
new file mode 100644
index 0000000..55a55dd
--- /dev/null
+++ b/modules/templates/htmlrenderer.go
@@ -0,0 +1,287 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ texttemplate "text/template"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates/scopedtmpl"
+ "code.gitea.io/gitea/modules/util"
+)
+
+type TemplateExecutor scopedtmpl.TemplateExecutor
+
+type HTMLRender struct {
+ templates atomic.Pointer[scopedtmpl.ScopedTemplate]
+}
+
+var (
+ htmlRender *HTMLRender
+ htmlRenderOnce sync.Once
+)
+
+var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
+
+func (h *HTMLRender) HTML(w io.Writer, status int, name string, data any, ctx context.Context) error { //nolint:revive
+ if respWriter, ok := w.(http.ResponseWriter); ok {
+ if respWriter.Header().Get("Content-Type") == "" {
+ respWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
+ }
+ respWriter.WriteHeader(status)
+ }
+ t, err := h.TemplateLookup(name, ctx)
+ if err != nil {
+ return texttemplate.ExecError{Name: name, Err: err}
+ }
+ return t.Execute(w, data)
+}
+
+func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive
+ tmpls := h.templates.Load()
+ if tmpls == nil {
+ return nil, ErrTemplateNotInitialized
+ }
+ m := NewFuncMap()
+ m["ctx"] = func() any { return ctx }
+ return tmpls.Executor(name, m)
+}
+
+func (h *HTMLRender) CompileTemplates() error {
+ assets := AssetFS()
+ extSuffix := ".tmpl"
+ tmpls := scopedtmpl.NewScopedTemplate()
+ tmpls.Funcs(NewFuncMap())
+ files, err := ListWebTemplateAssetNames(assets)
+ if err != nil {
+ return nil
+ }
+ for _, file := range files {
+ if !strings.HasSuffix(file, extSuffix) {
+ continue
+ }
+ name := strings.TrimSuffix(file, extSuffix)
+ tmpl := tmpls.New(filepath.ToSlash(name))
+ buf, err := assets.ReadFile(file)
+ if err != nil {
+ return err
+ }
+ if _, err = tmpl.Parse(string(buf)); err != nil {
+ return err
+ }
+ }
+ tmpls.Freeze()
+ h.templates.Store(tmpls)
+ return nil
+}
+
+// HTMLRenderer init once and returns the globally shared html renderer
+func HTMLRenderer() *HTMLRender {
+ htmlRenderOnce.Do(initHTMLRenderer)
+ return htmlRender
+}
+
+func ReloadHTMLTemplates() error {
+ log.Trace("Reloading HTML templates")
+ if err := htmlRender.CompileTemplates(); err != nil {
+ log.Error("Template error: %v\n%s", err, log.Stack(2))
+ return err
+ }
+ return nil
+}
+
+func initHTMLRenderer() {
+ rendererType := "static"
+ if !setting.IsProd {
+ rendererType = "auto-reloading"
+ }
+ log.Debug("Creating %s HTML Renderer", rendererType)
+
+ htmlRender = &HTMLRender{}
+ if err := htmlRender.CompileTemplates(); err != nil {
+ p := &templateErrorPrettier{assets: AssetFS()}
+ wrapTmplErrMsg(p.handleFuncNotDefinedError(err))
+ wrapTmplErrMsg(p.handleUnexpectedOperandError(err))
+ wrapTmplErrMsg(p.handleExpectedEndError(err))
+ wrapTmplErrMsg(p.handleGenericTemplateError(err))
+ wrapTmplErrMsg(fmt.Sprintf("CompileTemplates error: %v", err))
+ }
+
+ if !setting.IsProd {
+ go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
+ _ = ReloadHTMLTemplates()
+ })
+ }
+}
+
+func wrapTmplErrMsg(msg string) {
+ if msg == "" {
+ return
+ }
+ if setting.IsProd {
+ // in prod mode, Forgejo must have correct templates to run
+ log.Fatal("Forgejo can't run with template errors: %s", msg)
+ }
+ // in dev mode, do not need to really exit, because the template errors could be fixed by developer soon and the templates get reloaded
+ log.Error("There are template errors but Forgejo continues to run in dev mode: %s", msg)
+}
+
+type templateErrorPrettier struct {
+ assets *assetfs.LayeredFS
+}
+
+var reGenericTemplateError = regexp.MustCompile(`^template: (.*):([0-9]+): (.*)`)
+
+func (p *templateErrorPrettier) handleGenericTemplateError(err error) string {
+ groups := reGenericTemplateError.FindStringSubmatch(err.Error())
+ if len(groups) != 4 {
+ return ""
+ }
+ tmplName, lineStr, message := groups[1], groups[2], groups[3]
+ return p.makeDetailedError(message, tmplName, lineStr, -1, "")
+}
+
+var reFuncNotDefinedError = regexp.MustCompile(`^template: (.*):([0-9]+): (function "(.*)" not defined)`)
+
+func (p *templateErrorPrettier) handleFuncNotDefinedError(err error) string {
+ groups := reFuncNotDefinedError.FindStringSubmatch(err.Error())
+ if len(groups) != 5 {
+ return ""
+ }
+ tmplName, lineStr, message, funcName := groups[1], groups[2], groups[3], groups[4]
+ funcName, _ = strconv.Unquote(`"` + funcName + `"`)
+ return p.makeDetailedError(message, tmplName, lineStr, -1, funcName)
+}
+
+var reUnexpectedOperandError = regexp.MustCompile(`^template: (.*):([0-9]+): (unexpected "(.*)" in operand)`)
+
+func (p *templateErrorPrettier) handleUnexpectedOperandError(err error) string {
+ groups := reUnexpectedOperandError.FindStringSubmatch(err.Error())
+ if len(groups) != 5 {
+ return ""
+ }
+ tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
+ unexpected, _ = strconv.Unquote(`"` + unexpected + `"`)
+ return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
+}
+
+var reExpectedEndError = regexp.MustCompile(`^template: (.*):([0-9]+): (expected end; found (.*))`)
+
+func (p *templateErrorPrettier) handleExpectedEndError(err error) string {
+ groups := reExpectedEndError.FindStringSubmatch(err.Error())
+ if len(groups) != 5 {
+ return ""
+ }
+ tmplName, lineStr, message, unexpected := groups[1], groups[2], groups[3], groups[4]
+ return p.makeDetailedError(message, tmplName, lineStr, -1, unexpected)
+}
+
+var (
+ reTemplateExecutingError = regexp.MustCompile(`^template: (.*):([1-9][0-9]*):([1-9][0-9]*): (executing .*)`)
+ reTemplateExecutingErrorMsg = regexp.MustCompile(`^executing "(.*)" at <(.*)>: `)
+)
+
+func (p *templateErrorPrettier) handleTemplateRenderingError(err error) string {
+ if groups := reTemplateExecutingError.FindStringSubmatch(err.Error()); len(groups) > 0 {
+ tmplName, lineStr, posStr, msgPart := groups[1], groups[2], groups[3], groups[4]
+ target := ""
+ if groups = reTemplateExecutingErrorMsg.FindStringSubmatch(msgPart); len(groups) > 0 {
+ target = groups[2]
+ }
+ return p.makeDetailedError(msgPart, tmplName, lineStr, posStr, target)
+ } else if execErr, ok := err.(texttemplate.ExecError); ok {
+ layerName := p.assets.GetFileLayerName(execErr.Name + ".tmpl")
+ return fmt.Sprintf("asset from: %s, %s", layerName, err.Error())
+ }
+ return err.Error()
+}
+
+func HandleTemplateRenderingError(err error) string {
+ p := &templateErrorPrettier{assets: AssetFS()}
+ return p.handleTemplateRenderingError(err)
+}
+
+const dashSeparator = "----------------------------------------------------------------------"
+
+func (p *templateErrorPrettier) makeDetailedError(errMsg, tmplName string, lineNum, posNum any, target string) string {
+ code, layer, err := p.assets.ReadLayeredFile(tmplName + ".tmpl")
+ if err != nil {
+ return fmt.Sprintf("template error: %s, and unable to find template file %q", errMsg, tmplName)
+ }
+ line, err := util.ToInt64(lineNum)
+ if err != nil {
+ return fmt.Sprintf("template error: %s, unable to parse template %q line number %q", errMsg, tmplName, lineNum)
+ }
+ pos, err := util.ToInt64(posNum)
+ if err != nil {
+ return fmt.Sprintf("template error: %s, unable to parse template %q pos number %q", errMsg, tmplName, posNum)
+ }
+ detail := extractErrorLine(code, int(line), int(pos), target)
+
+ var msg string
+ if pos >= 0 {
+ msg = fmt.Sprintf("template error: %s:%s:%d:%d : %s", layer, tmplName, line, pos, errMsg)
+ } else {
+ msg = fmt.Sprintf("template error: %s:%s:%d : %s", layer, tmplName, line, errMsg)
+ }
+ return msg + "\n" + dashSeparator + "\n" + detail + "\n" + dashSeparator
+}
+
+func extractErrorLine(code []byte, lineNum, posNum int, target string) string {
+ b := bufio.NewReader(bytes.NewReader(code))
+ var line []byte
+ var err error
+ for i := 0; i < lineNum; i++ {
+ if line, err = b.ReadBytes('\n'); err != nil {
+ if i == lineNum-1 && errors.Is(err, io.EOF) {
+ err = nil
+ }
+ break
+ }
+ }
+ if err != nil {
+ return fmt.Sprintf("unable to find target line %d", lineNum)
+ }
+
+ line = bytes.TrimRight(line, "\r\n")
+ var indicatorLine []byte
+ targetBytes := []byte(target)
+ targetLen := len(targetBytes)
+ for i := 0; i < len(line); {
+ if posNum == -1 && target != "" && bytes.HasPrefix(line[i:], targetBytes) {
+ for j := 0; j < targetLen && i < len(line); j++ {
+ indicatorLine = append(indicatorLine, '^')
+ i++
+ }
+ } else if i == posNum {
+ indicatorLine = append(indicatorLine, '^')
+ i++
+ } else {
+ if line[i] == '\t' {
+ indicatorLine = append(indicatorLine, '\t')
+ } else {
+ indicatorLine = append(indicatorLine, ' ')
+ }
+ i++
+ }
+ }
+ // if the indicatorLine only contains spaces, trim it together
+ return strings.TrimRight(string(line)+"\n"+string(indicatorLine), " \t\r\n")
+}
diff --git a/modules/templates/htmlrenderer_test.go b/modules/templates/htmlrenderer_test.go
new file mode 100644
index 0000000..a1d3783
--- /dev/null
+++ b/modules/templates/htmlrenderer_test.go
@@ -0,0 +1,107 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "errors"
+ "html/template"
+ "os"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/assetfs"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExtractErrorLine(t *testing.T) {
+ cases := []struct {
+ code string
+ line int
+ pos int
+ target string
+ expect string
+ }{
+ {"hello world\nfoo bar foo bar\ntest", 2, -1, "bar", `
+foo bar foo bar
+ ^^^ ^^^
+`},
+
+ {"hello world\nfoo bar foo bar\ntest", 2, 4, "bar", `
+foo bar foo bar
+ ^
+`},
+
+ {
+ "hello world\nfoo bar foo bar\ntest", 2, 4, "",
+ `
+foo bar foo bar
+ ^
+`,
+ },
+
+ {
+ "hello world\nfoo bar foo bar\ntest", 5, 0, "",
+ `unable to find target line 5`,
+ },
+ }
+
+ for _, c := range cases {
+ actual := extractErrorLine([]byte(c.code), c.line, c.pos, c.target)
+ assert.Equal(t, strings.TrimSpace(c.expect), strings.TrimSpace(actual))
+ }
+}
+
+func TestHandleError(t *testing.T) {
+ dir := t.TempDir()
+
+ p := &templateErrorPrettier{assets: assetfs.Layered(assetfs.Local("tmp", dir))}
+
+ test := func(s string, h func(error) string, expect string) {
+ err := os.WriteFile(dir+"/test.tmpl", []byte(s), 0o644)
+ require.NoError(t, err)
+ tmpl := template.New("test")
+ _, err = tmpl.Parse(s)
+ require.Error(t, err)
+ msg := h(err)
+ assert.EqualValues(t, strings.TrimSpace(expect), strings.TrimSpace(msg))
+ }
+
+ test("{{", p.handleGenericTemplateError, `
+template error: tmp:test:1 : unclosed action
+----------------------------------------------------------------------
+{{
+----------------------------------------------------------------------
+`)
+
+ test("{{Func}}", p.handleFuncNotDefinedError, `
+template error: tmp:test:1 : function "Func" not defined
+----------------------------------------------------------------------
+{{Func}}
+ ^^^^
+----------------------------------------------------------------------
+`)
+
+ test("{{'x'3}}", p.handleUnexpectedOperandError, `
+template error: tmp:test:1 : unexpected "3" in operand
+----------------------------------------------------------------------
+{{'x'3}}
+ ^
+----------------------------------------------------------------------
+`)
+
+ // no idea about how to trigger such strange error, so mock an error to test it
+ err := os.WriteFile(dir+"/test.tmpl", []byte("god knows XXX"), 0o644)
+ require.NoError(t, err)
+ expectedMsg := `
+template error: tmp:test:1 : expected end; found XXX
+----------------------------------------------------------------------
+god knows XXX
+ ^^^
+----------------------------------------------------------------------
+`
+ actualMsg := p.handleExpectedEndError(errors.New("template: test:1: expected end; found XXX"))
+ assert.EqualValues(t, strings.TrimSpace(expectedMsg), strings.TrimSpace(actualMsg))
+}
diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go
new file mode 100644
index 0000000..ee79755
--- /dev/null
+++ b/modules/templates/mailer.go
@@ -0,0 +1,110 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "regexp"
+ "strings"
+ texttmpl "text/template"
+
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
+
+// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
+func mailSubjectTextFuncMap() texttmpl.FuncMap {
+ return texttmpl.FuncMap{
+ "dict": dict,
+ "Eval": Eval,
+
+ "EllipsisString": base.EllipsisString,
+ "AppName": func() string {
+ return setting.AppName
+ },
+ "AppSlogan": func() string {
+ return setting.AppSlogan
+ },
+ "AppDisplayName": func() string {
+ return setting.AppDisplayName
+ },
+ "AppDomain": func() string { // documented in mail-templates.md
+ return setting.Domain
+ },
+ }
+}
+
+func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) error {
+ // Split template into subject and body
+ var subjectContent []byte
+ bodyContent := content
+ loc := mailSubjectSplit.FindIndex(content)
+ if loc != nil {
+ subjectContent = content[0:loc[0]]
+ bodyContent = content[loc[1]:]
+ }
+ if _, err := stpl.New(name).Parse(string(subjectContent)); err != nil {
+ return fmt.Errorf("failed to parse template [%s/subject]: %w", name, err)
+ }
+ if _, err := btpl.New(name).Parse(string(bodyContent)); err != nil {
+ return fmt.Errorf("failed to parse template [%s/body]: %w", name, err)
+ }
+ return nil
+}
+
+// Mailer provides the templates required for sending notification mails.
+func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
+ subjectTemplates := texttmpl.New("")
+ bodyTemplates := template.New("")
+
+ subjectTemplates.Funcs(mailSubjectTextFuncMap())
+ bodyTemplates.Funcs(NewFuncMap())
+
+ assetFS := AssetFS()
+ refreshTemplates := func(firstRun bool) {
+ if !firstRun {
+ log.Trace("Reloading mail templates")
+ }
+ assetPaths, err := ListMailTemplateAssetNames(assetFS)
+ if err != nil {
+ log.Error("Failed to list mail templates: %v", err)
+ return
+ }
+
+ for _, assetPath := range assetPaths {
+ content, layerName, err := assetFS.ReadLayeredFile(assetPath)
+ if err != nil {
+ log.Warn("Failed to read mail template %s by %s: %v", assetPath, layerName, err)
+ continue
+ }
+ tmplName := strings.TrimPrefix(strings.TrimSuffix(assetPath, ".tmpl"), "mail/")
+ if firstRun {
+ log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName)
+ }
+ if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil {
+ if firstRun {
+ log.Fatal("Failed to parse mail template, err: %v", err)
+ }
+ log.Error("Failed to parse mail template, err: %v", err)
+ }
+ }
+ }
+
+ refreshTemplates(true)
+
+ if !setting.IsProd {
+ // Now subjectTemplates and bodyTemplates are both synchronized
+ // thus it is safe to call refresh from a different goroutine
+ go assetFS.WatchLocalChanges(ctx, func() {
+ refreshTemplates(false)
+ })
+ }
+
+ return subjectTemplates, bodyTemplates
+}
diff --git a/modules/templates/main_test.go b/modules/templates/main_test.go
new file mode 100644
index 0000000..bbdf5d2
--- /dev/null
+++ b/modules/templates/main_test.go
@@ -0,0 +1,24 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates_test
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/markup"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/issues"
+)
+
+func TestMain(m *testing.M) {
+ markup.Init(&markup.ProcessorHelper{
+ IsUsernameMentionable: func(ctx context.Context, username string) bool {
+ return username == "mention-user"
+ },
+ })
+ unittest.MainTest(m)
+}
diff --git a/modules/templates/scopedtmpl/scopedtmpl.go b/modules/templates/scopedtmpl/scopedtmpl.go
new file mode 100644
index 0000000..2722ba9
--- /dev/null
+++ b/modules/templates/scopedtmpl/scopedtmpl.go
@@ -0,0 +1,239 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package scopedtmpl
+
+import (
+ "fmt"
+ "html/template"
+ "io"
+ "reflect"
+ "sync"
+ texttemplate "text/template"
+ "text/template/parse"
+ "unsafe"
+)
+
+type TemplateExecutor interface {
+ Execute(wr io.Writer, data any) error
+}
+
+type ScopedTemplate struct {
+ all *template.Template
+ parseFuncs template.FuncMap // this func map is only used for parsing templates
+ frozen bool
+
+ scopedMu sync.RWMutex
+ scopedTemplateSets map[string]*scopedTemplateSet
+}
+
+func NewScopedTemplate() *ScopedTemplate {
+ return &ScopedTemplate{
+ all: template.New(""),
+ parseFuncs: template.FuncMap{},
+ scopedTemplateSets: map[string]*scopedTemplateSet{},
+ }
+}
+
+func (t *ScopedTemplate) Funcs(funcMap template.FuncMap) {
+ if t.frozen {
+ panic("cannot add new functions to frozen template set")
+ }
+ t.all.Funcs(funcMap)
+ for k, v := range funcMap {
+ t.parseFuncs[k] = v
+ }
+}
+
+func (t *ScopedTemplate) New(name string) *template.Template {
+ if t.frozen {
+ panic("cannot add new template to frozen template set")
+ }
+ return t.all.New(name)
+}
+
+func (t *ScopedTemplate) Freeze() {
+ t.frozen = true
+ // reset the exec func map, then `escapeTemplate` is safe to call `Execute` to do escaping
+ m := template.FuncMap{}
+ for k := range t.parseFuncs {
+ m[k] = func(v ...any) any { return nil }
+ }
+ t.all.Funcs(m)
+}
+
+func (t *ScopedTemplate) Executor(name string, funcMap template.FuncMap) (TemplateExecutor, error) {
+ t.scopedMu.RLock()
+ scopedTmplSet, ok := t.scopedTemplateSets[name]
+ t.scopedMu.RUnlock()
+
+ if !ok {
+ var err error
+ t.scopedMu.Lock()
+ if scopedTmplSet, ok = t.scopedTemplateSets[name]; !ok {
+ if scopedTmplSet, err = newScopedTemplateSet(t.all, name); err == nil {
+ t.scopedTemplateSets[name] = scopedTmplSet
+ }
+ }
+ t.scopedMu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if scopedTmplSet == nil {
+ return nil, fmt.Errorf("template %s not found", name)
+ }
+ return scopedTmplSet.newExecutor(funcMap), nil
+}
+
+type scopedTemplateSet struct {
+ name string
+ htmlTemplates map[string]*template.Template
+ textTemplates map[string]*texttemplate.Template
+ execFuncs map[string]reflect.Value
+}
+
+func escapeTemplate(t *template.Template) error {
+ // force the Golang HTML template to complete the escaping work
+ err := t.Execute(io.Discard, nil)
+ if _, ok := err.(*template.Error); ok {
+ return err
+ }
+ return nil
+}
+
+//nolint:unused
+type htmlTemplate struct {
+ escapeErr error
+ text *texttemplate.Template
+}
+
+//nolint:unused
+type textTemplateCommon struct {
+ tmpl map[string]*template.Template // Map from name to defined templates.
+ muTmpl sync.RWMutex // protects tmpl
+ option struct {
+ missingKey int
+ }
+ muFuncs sync.RWMutex // protects parseFuncs and execFuncs
+ parseFuncs texttemplate.FuncMap
+ execFuncs map[string]reflect.Value
+}
+
+//nolint:unused
+type textTemplate struct {
+ name string
+ *parse.Tree
+ *textTemplateCommon
+ leftDelim string
+ rightDelim string
+}
+
+func ptr[T, P any](ptr *P) *T {
+ // https://pkg.go.dev/unsafe#Pointer
+ // (1) Conversion of a *T1 to Pointer to *T2.
+ // Provided that T2 is no larger than T1 and that the two share an equivalent memory layout,
+ // this conversion allows reinterpreting data of one type as data of another type.
+ return (*T)(unsafe.Pointer(ptr))
+}
+
+func newScopedTemplateSet(all *template.Template, name string) (*scopedTemplateSet, error) {
+ targetTmpl := all.Lookup(name)
+ if targetTmpl == nil {
+ return nil, fmt.Errorf("template %q not found", name)
+ }
+ if err := escapeTemplate(targetTmpl); err != nil {
+ return nil, fmt.Errorf("template %q has an error when escaping: %w", name, err)
+ }
+
+ ts := &scopedTemplateSet{
+ name: name,
+ htmlTemplates: map[string]*template.Template{},
+ textTemplates: map[string]*texttemplate.Template{},
+ }
+
+ htmlTmpl := ptr[htmlTemplate](all)
+ textTmpl := htmlTmpl.text
+ textTmplPtr := ptr[textTemplate](textTmpl)
+
+ textTmplPtr.muFuncs.Lock()
+ ts.execFuncs = map[string]reflect.Value{}
+ for k, v := range textTmplPtr.execFuncs {
+ ts.execFuncs[k] = v
+ }
+ textTmplPtr.muFuncs.Unlock()
+
+ var collectTemplates func(nodes []parse.Node)
+ var collectErr error // only need to collect the one error
+ collectTemplates = func(nodes []parse.Node) {
+ for _, node := range nodes {
+ if node.Type() == parse.NodeTemplate {
+ nodeTemplate := node.(*parse.TemplateNode)
+ subName := nodeTemplate.Name
+ if ts.htmlTemplates[subName] == nil {
+ subTmpl := all.Lookup(subName)
+ if subTmpl == nil {
+ // HTML template will add some internal templates like "$delimDoubleQuote" into the text template
+ ts.textTemplates[subName] = textTmpl.Lookup(subName)
+ } else if subTmpl.Tree == nil || subTmpl.Tree.Root == nil {
+ collectErr = fmt.Errorf("template %q has no tree, it's usually caused by broken templates", subName)
+ } else {
+ ts.htmlTemplates[subName] = subTmpl
+ if err := escapeTemplate(subTmpl); err != nil {
+ collectErr = fmt.Errorf("template %q has an error when escaping: %w", subName, err)
+ return
+ }
+ collectTemplates(subTmpl.Tree.Root.Nodes)
+ }
+ }
+ } else if node.Type() == parse.NodeList {
+ nodeList := node.(*parse.ListNode)
+ collectTemplates(nodeList.Nodes)
+ } else if node.Type() == parse.NodeIf {
+ nodeIf := node.(*parse.IfNode)
+ collectTemplates(nodeIf.BranchNode.List.Nodes)
+ if nodeIf.BranchNode.ElseList != nil {
+ collectTemplates(nodeIf.BranchNode.ElseList.Nodes)
+ }
+ } else if node.Type() == parse.NodeRange {
+ nodeRange := node.(*parse.RangeNode)
+ collectTemplates(nodeRange.BranchNode.List.Nodes)
+ if nodeRange.BranchNode.ElseList != nil {
+ collectTemplates(nodeRange.BranchNode.ElseList.Nodes)
+ }
+ } else if node.Type() == parse.NodeWith {
+ nodeWith := node.(*parse.WithNode)
+ collectTemplates(nodeWith.BranchNode.List.Nodes)
+ if nodeWith.BranchNode.ElseList != nil {
+ collectTemplates(nodeWith.BranchNode.ElseList.Nodes)
+ }
+ }
+ }
+ }
+ ts.htmlTemplates[name] = targetTmpl
+ collectTemplates(targetTmpl.Tree.Root.Nodes)
+ return ts, collectErr
+}
+
+func (ts *scopedTemplateSet) newExecutor(funcMap map[string]any) TemplateExecutor {
+ tmpl := texttemplate.New("")
+ tmplPtr := ptr[textTemplate](tmpl)
+ tmplPtr.execFuncs = map[string]reflect.Value{}
+ for k, v := range ts.execFuncs {
+ tmplPtr.execFuncs[k] = v
+ }
+ if funcMap != nil {
+ tmpl.Funcs(funcMap)
+ }
+ // after escapeTemplate, the html templates are also escaped text templates, so it could be added to the text template directly
+ for _, t := range ts.htmlTemplates {
+ _, _ = tmpl.AddParseTree(t.Name(), t.Tree)
+ }
+ for _, t := range ts.textTemplates {
+ _, _ = tmpl.AddParseTree(t.Name(), t.Tree)
+ }
+
+ // now the text template has all necessary escaped templates, so we can safely execute, just like what the html template does
+ return tmpl.Lookup(ts.name)
+}
diff --git a/modules/templates/scopedtmpl/scopedtmpl_test.go b/modules/templates/scopedtmpl/scopedtmpl_test.go
new file mode 100644
index 0000000..9bbd0c7
--- /dev/null
+++ b/modules/templates/scopedtmpl/scopedtmpl_test.go
@@ -0,0 +1,99 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package scopedtmpl
+
+import (
+ "bytes"
+ "html/template"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestScopedTemplateSetFuncMap(t *testing.T) {
+ all := template.New("")
+
+ all.Funcs(template.FuncMap{"CtxFunc": func(s string) string {
+ return "default"
+ }})
+
+ _, err := all.New("base").Parse(`{{CtxFunc "base"}}`)
+ require.NoError(t, err)
+
+ _, err = all.New("test").Parse(strings.TrimSpace(`
+{{template "base"}}
+{{CtxFunc "test"}}
+{{template "base"}}
+{{CtxFunc "test"}}
+`))
+ require.NoError(t, err)
+
+ ts, err := newScopedTemplateSet(all, "test")
+ require.NoError(t, err)
+
+ // try to use different CtxFunc to render concurrently
+
+ funcMap1 := template.FuncMap{
+ "CtxFunc": func(s string) string {
+ time.Sleep(100 * time.Millisecond)
+ return s + "1"
+ },
+ }
+
+ funcMap2 := template.FuncMap{
+ "CtxFunc": func(s string) string {
+ time.Sleep(100 * time.Millisecond)
+ return s + "2"
+ },
+ }
+
+ out1 := bytes.Buffer{}
+ out2 := bytes.Buffer{}
+ wg := sync.WaitGroup{}
+ wg.Add(2)
+ go func() {
+ err := ts.newExecutor(funcMap1).Execute(&out1, nil)
+ require.NoError(t, err)
+ wg.Done()
+ }()
+ go func() {
+ err := ts.newExecutor(funcMap2).Execute(&out2, nil)
+ require.NoError(t, err)
+ wg.Done()
+ }()
+ wg.Wait()
+ assert.Equal(t, "base1\ntest1\nbase1\ntest1", out1.String())
+ assert.Equal(t, "base2\ntest2\nbase2\ntest2", out2.String())
+}
+
+func TestScopedTemplateSetEscape(t *testing.T) {
+ all := template.New("")
+ _, err := all.New("base").Parse(`<a href="?q={{.param}}">{{.text}}</a>`)
+ require.NoError(t, err)
+
+ _, err = all.New("test").Parse(`{{template "base" .}}<form action="?q={{.param}}">{{.text}}</form>`)
+ require.NoError(t, err)
+
+ ts, err := newScopedTemplateSet(all, "test")
+ require.NoError(t, err)
+
+ out := bytes.Buffer{}
+ err = ts.newExecutor(nil).Execute(&out, map[string]string{"param": "/", "text": "<"})
+ require.NoError(t, err)
+
+ assert.Equal(t, `<a href="?q=%2f">&lt;</a><form action="?q=%2f">&lt;</form>`, out.String())
+}
+
+func TestScopedTemplateSetUnsafe(t *testing.T) {
+ all := template.New("")
+ _, err := all.New("test").Parse(`<a href="{{if true}}?{{end}}a={{.param}}"></a>`)
+ require.NoError(t, err)
+
+ _, err = newScopedTemplateSet(all, "test")
+ require.ErrorContains(t, err, "appears in an ambiguous context within a URL")
+}
diff --git a/modules/templates/static.go b/modules/templates/static.go
new file mode 100644
index 0000000..b5a7e56
--- /dev/null
+++ b/modules/templates/static.go
@@ -0,0 +1,22 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package templates
+
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/assetfs"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// GlobalModTime provide a global mod time for embedded asset files
+func GlobalModTime(filename string) time.Time {
+ return timeutil.GetExecutableModTime()
+}
+
+func BuiltinAssets() *assetfs.Layer {
+ return assetfs.Bindata("builtin(bindata)", Assets)
+}
diff --git a/modules/templates/templates_bindata.go b/modules/templates/templates_bindata.go
new file mode 100644
index 0000000..6f1d3cf
--- /dev/null
+++ b/modules/templates/templates_bindata.go
@@ -0,0 +1,8 @@
+// Copyright 2016 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build bindata
+
+package templates
+
+//go:generate go run ../../build/generate-bindata.go ../../templates templates bindata.go true
diff --git a/modules/templates/util_avatar.go b/modules/templates/util_avatar.go
new file mode 100644
index 0000000..afc1091
--- /dev/null
+++ b/modules/templates/util_avatar.go
@@ -0,0 +1,81 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "html/template"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/avatars"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ gitea_html "code.gitea.io/gitea/modules/html"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+type AvatarUtils struct {
+ ctx context.Context
+}
+
+func NewAvatarUtils(ctx context.Context) *AvatarUtils {
+ return &AvatarUtils{ctx: ctx}
+}
+
+// AvatarHTML creates the HTML for an avatar
+func AvatarHTML(src string, size int, class, name string) template.HTML {
+ sizeStr := fmt.Sprintf(`%d`, size)
+
+ if name == "" {
+ name = "avatar"
+ }
+
+ return template.HTML(`<img loading="lazy" class="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`)
+}
+
+// Avatar renders user avatars. args: user, size (int), class (string)
+func (au *AvatarUtils) Avatar(item any, others ...any) template.HTML {
+ size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...)
+
+ switch t := item.(type) {
+ case *user_model.User:
+ src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
+ if src != "" {
+ return AvatarHTML(src, size, class, t.DisplayName())
+ }
+ case *repo_model.Collaborator:
+ src := t.AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
+ if src != "" {
+ return AvatarHTML(src, size, class, t.DisplayName())
+ }
+ case *organization.Organization:
+ src := t.AsUser().AvatarLinkWithSize(au.ctx, size*setting.Avatar.RenderedSizeFactor)
+ if src != "" {
+ return AvatarHTML(src, size, class, t.AsUser().DisplayName())
+ }
+ }
+
+ return AvatarHTML(avatars.DefaultAvatarLink(), size, class, "")
+}
+
+// AvatarByAction renders user avatars from action. args: action, size (int), class (string)
+func (au *AvatarUtils) AvatarByAction(action *activities_model.Action, others ...any) template.HTML {
+ action.LoadActUser(au.ctx)
+ return au.Avatar(action.ActUser, others...)
+}
+
+// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string)
+func (au *AvatarUtils) AvatarByEmail(email, name string, others ...any) template.HTML {
+ size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...)
+ src := avatars.GenerateEmailAvatarFastLink(au.ctx, email, size*setting.Avatar.RenderedSizeFactor)
+
+ if src != "" {
+ return AvatarHTML(src, size, class, name)
+ }
+
+ return ""
+}
diff --git a/modules/templates/util_dict.go b/modules/templates/util_dict.go
new file mode 100644
index 0000000..8d6376b
--- /dev/null
+++ b/modules/templates/util_dict.go
@@ -0,0 +1,121 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "html"
+ "html/template"
+ "reflect"
+
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func dictMerge(base map[string]any, arg any) bool {
+ if arg == nil {
+ return true
+ }
+ rv := reflect.ValueOf(arg)
+ if rv.Kind() == reflect.Map {
+ for _, k := range rv.MapKeys() {
+ base[k.String()] = rv.MapIndex(k).Interface()
+ }
+ return true
+ }
+ return false
+}
+
+// dict is a helper function for creating a map[string]any from a list of key-value pairs.
+// If the key is dot ".", the value is merged into the base map, just like Golang template's dot syntax: dot means current
+// The dot syntax is highly discouraged because it might cause unclear key conflicts. It's always good to use explicit keys.
+func dict(args ...any) (map[string]any, error) {
+ if len(args)%2 != 0 {
+ return nil, fmt.Errorf("invalid dict constructor syntax: must have key-value pairs")
+ }
+ m := make(map[string]any, len(args)/2)
+ for i := 0; i < len(args); i += 2 {
+ key, ok := args[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("invalid dict constructor syntax: unable to merge args[%d]", i)
+ }
+ if key == "." {
+ if ok = dictMerge(m, args[i+1]); !ok {
+ return nil, fmt.Errorf("invalid dict constructor syntax: dot arg[%d] must be followed by a dict", i)
+ }
+ } else {
+ m[key] = args[i+1]
+ }
+ }
+ return m, nil
+}
+
+func dumpVarMarshalable(v any, dumped container.Set[uintptr]) (ret any, ok bool) {
+ if v == nil {
+ return nil, true
+ }
+ e := reflect.ValueOf(v)
+ for e.Kind() == reflect.Pointer {
+ e = e.Elem()
+ }
+ if e.CanAddr() {
+ addr := e.UnsafeAddr()
+ if !dumped.Add(addr) {
+ return "[dumped]", false
+ }
+ defer dumped.Remove(addr)
+ }
+ switch e.Kind() {
+ case reflect.Bool, reflect.String,
+ reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
+ reflect.Float32, reflect.Float64:
+ return e.Interface(), true
+ case reflect.Struct:
+ m := map[string]any{}
+ for i := 0; i < e.NumField(); i++ {
+ k := e.Type().Field(i).Name
+ if !e.Type().Field(i).IsExported() {
+ continue
+ }
+ v := e.Field(i).Interface()
+ m[k], _ = dumpVarMarshalable(v, dumped)
+ }
+ return m, true
+ case reflect.Map:
+ m := map[string]any{}
+ for _, k := range e.MapKeys() {
+ m[k.String()], _ = dumpVarMarshalable(e.MapIndex(k).Interface(), dumped)
+ }
+ return m, true
+ case reflect.Array, reflect.Slice:
+ var m []any
+ for i := 0; i < e.Len(); i++ {
+ v, _ := dumpVarMarshalable(e.Index(i).Interface(), dumped)
+ m = append(m, v)
+ }
+ return m, true
+ default:
+ return "[" + reflect.TypeOf(v).String() + "]", false
+ }
+}
+
+// dumpVar helps to dump a variable in a template, to help debugging and development.
+func dumpVar(v any) template.HTML {
+ if setting.IsProd {
+ return "<pre>dumpVar: only available in dev mode</pre>"
+ }
+ m, ok := dumpVarMarshalable(v, make(container.Set[uintptr]))
+ var dumpStr string
+ jsonBytes, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ dumpStr = fmt.Sprintf("dumpVar: unable to marshal %T: %v", v, err)
+ } else if ok {
+ dumpStr = fmt.Sprintf("dumpVar: %T\n%s", v, string(jsonBytes))
+ } else {
+ dumpStr = fmt.Sprintf("dumpVar: unmarshalable %T\n%s", v, string(jsonBytes))
+ }
+ return template.HTML("<pre>" + html.EscapeString(dumpStr) + "</pre>")
+}
diff --git a/modules/templates/util_json.go b/modules/templates/util_json.go
new file mode 100644
index 0000000..71a4e23
--- /dev/null
+++ b/modules/templates/util_json.go
@@ -0,0 +1,35 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "bytes"
+
+ "code.gitea.io/gitea/modules/json"
+)
+
+type JsonUtils struct{} //nolint:revive
+
+var jsonUtils = JsonUtils{}
+
+func NewJsonUtils() *JsonUtils { //nolint:revive
+ return &jsonUtils
+}
+
+func (su *JsonUtils) EncodeToString(v any) string {
+ out, err := json.Marshal(v)
+ if err != nil {
+ return ""
+ }
+ return string(out)
+}
+
+func (su *JsonUtils) PrettyIndent(s string) string {
+ var out bytes.Buffer
+ err := json.Indent(&out, []byte(s), "", " ")
+ if err != nil {
+ return ""
+ }
+ return out.String()
+}
diff --git a/modules/templates/util_misc.go b/modules/templates/util_misc.go
new file mode 100644
index 0000000..7743854
--- /dev/null
+++ b/modules/templates/util_misc.go
@@ -0,0 +1,193 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "html/template"
+ "mime"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ giturl "code.gitea.io/gitea/modules/git/url"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/svg"
+
+ "github.com/editorconfig/editorconfig-core-go/v2"
+)
+
+func SortArrow(normSort, revSort, urlSort string, isDefault bool) template.HTML {
+ // if needed
+ if len(normSort) == 0 || len(urlSort) == 0 {
+ return ""
+ }
+
+ if len(urlSort) == 0 && isDefault {
+ // if sort is sorted as default add arrow tho this table header
+ if isDefault {
+ return svg.RenderHTML("octicon-triangle-down", 16)
+ }
+ } else {
+ // if sort arg is in url test if it correlates with column header sort arguments
+ // the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev)
+ if urlSort == normSort {
+ // the table is sorted with this header normal
+ return svg.RenderHTML("octicon-triangle-up", 16)
+ } else if urlSort == revSort {
+ // the table is sorted with this header reverse
+ return svg.RenderHTML("octicon-triangle-down", 16)
+ }
+ }
+ // the table is NOT sorted with this header
+ return ""
+}
+
+// IsMultilineCommitMessage checks to see if a commit message contains multiple lines.
+func IsMultilineCommitMessage(msg string) bool {
+ return strings.Count(strings.TrimSpace(msg), "\n") >= 1
+}
+
+// Actioner describes an action
+type Actioner interface {
+ GetOpType() activities_model.ActionType
+ GetActUserName(ctx context.Context) string
+ GetRepoUserName(ctx context.Context) string
+ GetRepoName(ctx context.Context) string
+ GetRepoPath(ctx context.Context) string
+ GetRepoLink(ctx context.Context) string
+ GetBranch() string
+ GetContent() string
+ GetCreate() time.Time
+ GetIssueInfos() []string
+}
+
+// ActionIcon accepts an action operation type and returns an icon class name.
+func ActionIcon(opType activities_model.ActionType) string {
+ switch opType {
+ case activities_model.ActionCreateRepo, activities_model.ActionTransferRepo, activities_model.ActionRenameRepo:
+ return "repo"
+ case activities_model.ActionCommitRepo:
+ return "git-commit"
+ case activities_model.ActionDeleteBranch:
+ return "git-branch"
+ case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
+ return "git-merge"
+ case activities_model.ActionCreatePullRequest:
+ return "git-pull-request"
+ case activities_model.ActionClosePullRequest:
+ return "git-pull-request-closed"
+ case activities_model.ActionCreateIssue:
+ return "issue-opened"
+ case activities_model.ActionCloseIssue:
+ return "issue-closed"
+ case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
+ return "issue-reopened"
+ case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
+ return "comment-discussion"
+ case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete:
+ return "mirror"
+ case activities_model.ActionApprovePullRequest:
+ return "check"
+ case activities_model.ActionRejectPullRequest:
+ return "file-diff"
+ case activities_model.ActionPublishRelease, activities_model.ActionPushTag, activities_model.ActionDeleteTag:
+ return "tag"
+ case activities_model.ActionPullReviewDismissed:
+ return "x"
+ default:
+ return "question"
+ }
+}
+
+// ActionContent2Commits converts action content to push commits
+func ActionContent2Commits(act Actioner) *repository.PushCommits {
+ push := repository.NewPushCommits()
+
+ if act == nil || act.GetContent() == "" {
+ return push
+ }
+
+ if err := json.Unmarshal([]byte(act.GetContent()), push); err != nil {
+ log.Error("json.Unmarshal:\n%s\nERROR: %v", act.GetContent(), err)
+ }
+
+ if push.Len == 0 {
+ push.Len = len(push.Commits)
+ }
+
+ return push
+}
+
+// MigrationIcon returns a SVG name matching the service an issue/comment was migrated from
+func MigrationIcon(hostname string) string {
+ switch hostname {
+ case "github.com":
+ return "octicon-mark-github"
+ default:
+ return "gitea-git"
+ }
+}
+
+type remoteAddress struct {
+ Address string
+ Username string
+ Password string
+}
+
+func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteName string) remoteAddress {
+ ret := remoteAddress{}
+ remoteURL, err := git.GetRemoteAddress(ctx, m.RepoPath(), remoteName)
+ if err != nil {
+ log.Error("GetRemoteURL %v", err)
+ return ret
+ }
+
+ u, err := giturl.Parse(remoteURL)
+ if err != nil {
+ log.Error("giturl.Parse %v", err)
+ return ret
+ }
+
+ if u.Scheme != "ssh" && u.Scheme != "file" {
+ if u.User != nil {
+ ret.Username = u.User.Username()
+ ret.Password, _ = u.User.Password()
+ }
+ }
+
+ // The URL stored in the git repo could contain authentication,
+ // erase it, or it will be shown in the UI.
+ u.User = nil
+ ret.Address = u.String()
+ // Why not use m.OriginalURL to set ret.Address?
+ // It should be OK to use it, since m.OriginalURL should be the same as the authentication-erased URL from the Git repository.
+ // However, the old code has already stored authentication in m.OriginalURL when updating mirror settings.
+ // That means we need to use "giturl.Parse" for m.OriginalURL again to ensure authentication is erased.
+ // Instead of doing this, why not directly use the authentication-erased URL from the Git repository?
+ // It should be the same as long as there are no bugs.
+
+ return ret
+}
+
+func FilenameIsImage(filename string) bool {
+ mimeType := mime.TypeByExtension(filepath.Ext(filename))
+ return strings.HasPrefix(mimeType, "image/")
+}
+
+func TabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
+ if ec != nil {
+ def, err := ec.GetDefinitionForFilename(filename)
+ if err == nil && def.TabWidth >= 1 && def.TabWidth <= 16 {
+ return "tab-size-" + strconv.Itoa(def.TabWidth)
+ }
+ }
+ return "tab-size-4"
+}
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
new file mode 100644
index 0000000..c53bdd8
--- /dev/null
+++ b/modules/templates/util_render.go
@@ -0,0 +1,264 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "encoding/hex"
+ "fmt"
+ "html/template"
+ "math"
+ "net/url"
+ "regexp"
+ "strings"
+ "unicode"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/emoji"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// RenderCommitMessage renders commit message with XSS-safe and special links.
+func RenderCommitMessage(ctx context.Context, msg string, metas map[string]string) template.HTML {
+ cleanMsg := template.HTMLEscapeString(msg)
+ // we can safely assume that it will not return any error, since there
+ // shouldn't be any special HTML.
+ fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: metas,
+ }, cleanMsg)
+ if err != nil {
+ log.Error("RenderCommitMessage: %v", err)
+ return ""
+ }
+ msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n")
+ if len(msgLines) == 0 {
+ return template.HTML("")
+ }
+ return RenderCodeBlock(template.HTML(msgLines[0]))
+}
+
+// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
+// the provided default url, handling for special links without email to links.
+func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlDefault string, metas map[string]string) template.HTML {
+ msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace)
+ lineEnd := strings.IndexByte(msgLine, '\n')
+ if lineEnd > 0 {
+ msgLine = msgLine[:lineEnd]
+ }
+ msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
+ if len(msgLine) == 0 {
+ return template.HTML("")
+ }
+
+ // we can safely assume that it will not return any error, since there
+ // shouldn't be any special HTML.
+ renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{
+ Ctx: ctx,
+ DefaultLink: urlDefault,
+ Metas: metas,
+ }, template.HTMLEscapeString(msgLine))
+ if err != nil {
+ log.Error("RenderCommitMessageSubject: %v", err)
+ return template.HTML("")
+ }
+ return RenderCodeBlock(template.HTML(renderedMessage))
+}
+
+// RenderCommitBody extracts the body of a commit message without its title.
+func RenderCommitBody(ctx context.Context, msg string, metas map[string]string) template.HTML {
+ msgLine := strings.TrimSpace(msg)
+ lineEnd := strings.IndexByte(msgLine, '\n')
+ if lineEnd > 0 {
+ msgLine = msgLine[lineEnd+1:]
+ } else {
+ return ""
+ }
+ msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace)
+ if len(msgLine) == 0 {
+ return ""
+ }
+
+ renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: metas,
+ }, template.HTMLEscapeString(msgLine))
+ if err != nil {
+ log.Error("RenderCommitMessage: %v", err)
+ return ""
+ }
+ return template.HTML(renderedMessage)
+}
+
+// Match text that is between back ticks.
+var codeMatcher = regexp.MustCompile("`([^`]+)`")
+
+// RenderCodeBlock renders "`…`" as highlighted "<code>" block, intended for issue and PR titles
+func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
+ htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), `<code class="inline-code-block">$1</code>`) // replace with HTML <code> tags
+ return template.HTML(htmlWithCodeTags)
+}
+
+const (
+ activeLabelOpacity = uint8(255)
+ archivedLabelOpacity = uint8(127)
+)
+
+func GetLabelOpacityByte(isArchived bool) uint8 {
+ if isArchived {
+ return archivedLabelOpacity
+ }
+ return activeLabelOpacity
+}
+
+// RenderIssueTitle renders issue/pull title with defined post processors
+func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) template.HTML {
+ renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: metas,
+ }, template.HTMLEscapeString(text))
+ if err != nil {
+ log.Error("RenderIssueTitle: %v", err)
+ return template.HTML("")
+ }
+ return template.HTML(renderedText)
+}
+
+// RenderRefIssueTitle renders referenced issue/pull title with defined post processors
+func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
+ renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text))
+ if err != nil {
+ log.Error("RenderRefIssueTitle: %v", err)
+ return ""
+ }
+
+ return template.HTML(renderedText)
+}
+
+// RenderLabel renders a label
+// locale is needed due to an import cycle with our context providing the `Tr` function
+func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
+ var (
+ archivedCSSClass string
+ textColor = util.ContrastColor(label.Color)
+ labelScope = label.ExclusiveScope()
+ )
+
+ description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
+
+ if label.IsArchived() {
+ archivedCSSClass = "archived-label"
+ description = locale.TrString("repo.issues.archived_label_description", description)
+ }
+
+ if labelScope == "" {
+ // Regular label
+
+ labelColor := label.Color + hex.EncodeToString([]byte{GetLabelOpacityByte(label.IsArchived())})
+ s := fmt.Sprintf("<div class='ui label %s' style='color: %s !important; background-color: %s !important;' data-tooltip-content title='%s'>%s</div>",
+ archivedCSSClass, textColor, labelColor, description, RenderEmoji(ctx, label.Name))
+ return template.HTML(s)
+ }
+
+ // Scoped label
+ scopeText := RenderEmoji(ctx, labelScope)
+ itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:])
+
+ // Make scope and item background colors slightly darker and lighter respectively.
+ // More contrast needed with higher luminance, empirically tweaked.
+ luminance := util.GetRelativeLuminance(label.Color)
+ contrast := 0.01 + luminance*0.03
+ // Ensure we add the same amount of contrast also near 0 and 1.
+ darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
+ lighten := contrast + math.Max(contrast-luminance, 0.0)
+ // Compute factor to keep RGB values proportional.
+ darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
+ lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
+
+ opacity := GetLabelOpacityByte(label.IsArchived())
+ r, g, b := util.HexToRBGColor(label.Color)
+ scopeBytes := []byte{
+ uint8(math.Min(math.Round(r*darkenFactor), 255)),
+ uint8(math.Min(math.Round(g*darkenFactor), 255)),
+ uint8(math.Min(math.Round(b*darkenFactor), 255)),
+ opacity,
+ }
+ itemBytes := []byte{
+ uint8(math.Min(math.Round(r*lightenFactor), 255)),
+ uint8(math.Min(math.Round(g*lightenFactor), 255)),
+ uint8(math.Min(math.Round(b*lightenFactor), 255)),
+ opacity,
+ }
+
+ scopeColor := "#" + hex.EncodeToString(scopeBytes)
+ itemColor := "#" + hex.EncodeToString(itemBytes)
+
+ s := fmt.Sprintf("<span class='ui label %s scope-parent' data-tooltip-content title='%s'>"+
+ "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+
+ "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important'>%s</div>"+
+ "</span>",
+ archivedCSSClass, description,
+ textColor, scopeColor, scopeText,
+ textColor, itemColor, itemText)
+ return template.HTML(s)
+}
+
+// RenderEmoji renders html text with emoji post processors
+func RenderEmoji(ctx context.Context, text string) template.HTML {
+ renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx},
+ template.HTMLEscapeString(text))
+ if err != nil {
+ log.Error("RenderEmoji: %v", err)
+ return template.HTML("")
+ }
+ return template.HTML(renderedText)
+}
+
+// ReactionToEmoji renders emoji for use in reactions
+func ReactionToEmoji(reaction string) template.HTML {
+ val := emoji.FromCode(reaction)
+ if val != nil {
+ return template.HTML(val.Emoji)
+ }
+ val = emoji.FromAlias(reaction)
+ if val != nil {
+ return template.HTML(val.Emoji)
+ }
+ return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction)))
+}
+
+func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //nolint:revive
+ output, err := markdown.RenderString(&markup.RenderContext{
+ Ctx: ctx,
+ Metas: map[string]string{"mode": "document"},
+ }, input)
+ if err != nil {
+ log.Error("RenderString: %v", err)
+ }
+ return output
+}
+
+func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, isPull bool) template.HTML {
+ htmlCode := `<span class="labels-list">`
+ for _, label := range labels {
+ // Protect against nil value in labels - shouldn't happen but would cause a panic if so
+ if label == nil {
+ continue
+ }
+
+ issuesOrPull := "issues"
+ if isPull {
+ issuesOrPull = "pulls"
+ }
+ htmlCode += fmt.Sprintf("<a href='%s/%s?labels=%d' rel='nofollow'>%s</a> ",
+ repoLink, issuesOrPull, label.ID, RenderLabel(ctx, locale, label))
+ }
+ htmlCode += "</span>"
+ return template.HTML(htmlCode)
+}
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
new file mode 100644
index 0000000..da74298
--- /dev/null
+++ b/modules/templates/util_render_test.go
@@ -0,0 +1,223 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "context"
+ "html/template"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const testInput = ` space @mention-user
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+:+1:
+mail@domain.com
+@mention-user test
+#123
+ space
+` + "`code :+1: #123 code`\n"
+
+var testMetas = map[string]string{
+ "user": "user13",
+ "repo": "repo11",
+ "repoPath": "../../tests/gitea-repositories-meta/user13/repo11.git/",
+ "mode": "comment",
+}
+
+func TestApostrophesInMentions(t *testing.T) {
+ rendered := RenderMarkdownToHtml(context.Background(), "@mention-user's comment")
+ assert.EqualValues(t, template.HTML("<p><a href=\"/mention-user\" rel=\"nofollow\">@mention-user</a>&#39;s comment</p>\n"), rendered)
+}
+
+func TestNonExistantUserMention(t *testing.T) {
+ rendered := RenderMarkdownToHtml(context.Background(), "@ThisUserDoesNotExist @mention-user")
+ assert.EqualValues(t, template.HTML("<p>@ThisUserDoesNotExist <a href=\"/mention-user\" rel=\"nofollow\">@mention-user</a></p>\n"), rendered)
+}
+
+func TestRenderCommitBody(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ msg string
+ metas map[string]string
+ }
+ tests := []struct {
+ name string
+ args args
+ want template.HTML
+ }{
+ {
+ name: "multiple lines",
+ args: args{
+ ctx: context.Background(),
+ msg: "first line\nsecond line",
+ },
+ want: "second line",
+ },
+ {
+ name: "multiple lines with leading newlines",
+ args: args{
+ ctx: context.Background(),
+ msg: "\n\n\n\nfirst line\nsecond line",
+ },
+ want: "second line",
+ },
+ {
+ name: "multiple lines with trailing newlines",
+ args: args{
+ ctx: context.Background(),
+ msg: "first line\nsecond line\n\n\n",
+ },
+ want: "second line",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, RenderCommitBody(tt.args.ctx, tt.args.msg, tt.args.metas), "RenderCommitBody(%v, %v, %v)", tt.args.ctx, tt.args.msg, tt.args.metas)
+ })
+ }
+
+ expected := `/just/a/path.bin
+<a href="https://example.com/file.bin" class="link">https://example.com/file.bin</a>
+[local link](file.bin)
+[remote link](<a href="https://example.com" class="link">https://example.com</a>)
+[[local link|file.bin]]
+[[remote link|<a href="https://example.com" class="link">https://example.com</a>]]
+![local image](image.jpg)
+![remote image](<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>)
+[[local image|image.jpg]]
+[[remote link|<a href="https://example.com/image.jpg" class="link">https://example.com/image.jpg</a>]]
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" class="compare"><code class="nohighlight">88fc37a3c0...12fc37a3c0 (hash)</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" class="commit"><code class="nohighlight">88fc37a3c0</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">👍</span>
+<a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a>
+<a href="/mention-user" class="mention">@mention-user</a> test
+<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
+ space
+` + "`code <span class=\"emoji\" aria-label=\"thumbs up\">👍</span> <a href=\"/user13/repo11/issues/123\" class=\"ref-issue\">#123</a> code`"
+ assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas))
+}
+
+func TestRenderCommitMessage(t *testing.T) {
+ expected := `space <a href="/mention-user" class="mention">@mention-user</a> `
+
+ assert.EqualValues(t, expected, RenderCommitMessage(context.Background(), testInput, testMetas))
+}
+
+func TestRenderCommitMessageLinkSubject(t *testing.T) {
+ expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" class="mention">@mention-user</a>`
+
+ assert.EqualValues(t, expected, RenderCommitMessageLinkSubject(context.Background(), testInput, "https://example.com/link", testMetas))
+}
+
+func TestRenderIssueTitle(t *testing.T) {
+ expected := ` space @mention-user
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">👍</span>
+mail@domain.com
+@mention-user test
+<a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
+ space
+<code class="inline-code-block">code :+1: #123 code</code>
+`
+ assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas))
+}
+
+func TestRenderRefIssueTitle(t *testing.T) {
+ expected := ` space @mention-user
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">👍</span>
+mail@domain.com
+@mention-user test
+#123
+ space
+<code class="inline-code-block">code :+1: #123 code</code>
+`
+ assert.EqualValues(t, expected, RenderRefIssueTitle(context.Background(), testInput))
+}
+
+func TestRenderMarkdownToHtml(t *testing.T) {
+ expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
+/just/a/path.bin
+<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
+<a href="/file.bin" rel="nofollow">local link</a>
+<a href="https://example.com" rel="nofollow">remote link</a>
+<a href="/src/file.bin" rel="nofollow">local link</a>
+<a href="https://example.com" rel="nofollow">remote link</a>
+<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a>
+<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a>
+<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a>
+<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a>
+<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a>
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">👍</span>
+<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
+<a href="/mention-user" rel="nofollow">@mention-user</a> test
+#123
+space
+<code>code :+1: #123 code</code></p>
+`
+ assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput))
+}
+
+func TestRenderLabels(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ tr := &translation.MockLocale{}
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
+
+ assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", false),
+ "user2/repo1/issues?labels=1")
+ assert.Contains(t, RenderLabels(db.DefaultContext, tr, []*issues_model.Label{label}, "user2/repo1", true),
+ "user2/repo1/pulls?labels=1")
+}
diff --git a/modules/templates/util_slice.go b/modules/templates/util_slice.go
new file mode 100644
index 0000000..a3318cc
--- /dev/null
+++ b/modules/templates/util_slice.go
@@ -0,0 +1,35 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "reflect"
+)
+
+type SliceUtils struct{}
+
+func NewSliceUtils() *SliceUtils {
+ return &SliceUtils{}
+}
+
+func (su *SliceUtils) Contains(s, v any) bool {
+ if s == nil {
+ return false
+ }
+ sv := reflect.ValueOf(s)
+ if sv.Kind() != reflect.Slice && sv.Kind() != reflect.Array {
+ panic(fmt.Sprintf("invalid type, expected slice or array, but got: %T", s))
+ }
+ for i := 0; i < sv.Len(); i++ {
+ it := sv.Index(i)
+ if !it.CanInterface() {
+ continue
+ }
+ if it.Interface() == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/modules/templates/util_string.go b/modules/templates/util_string.go
new file mode 100644
index 0000000..f23b747
--- /dev/null
+++ b/modules/templates/util_string.go
@@ -0,0 +1,68 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+
+ "code.gitea.io/gitea/modules/base"
+)
+
+type StringUtils struct{}
+
+var stringUtils = StringUtils{}
+
+func NewStringUtils() *StringUtils {
+ return &stringUtils
+}
+
+func (su *StringUtils) HasPrefix(s any, prefix string) bool {
+ switch v := s.(type) {
+ case string:
+ return strings.HasPrefix(v, prefix)
+ case template.HTML:
+ return strings.HasPrefix(string(v), prefix)
+ }
+ return false
+}
+
+func (su *StringUtils) ToString(v any) string {
+ switch v := v.(type) {
+ case string:
+ return v
+ case template.HTML:
+ return string(v)
+ case fmt.Stringer:
+ return v.String()
+ default:
+ return fmt.Sprint(v)
+ }
+}
+
+func (su *StringUtils) Contains(s, substr string) bool {
+ return strings.Contains(s, substr)
+}
+
+func (su *StringUtils) Split(s, sep string) []string {
+ return strings.Split(s, sep)
+}
+
+func (su *StringUtils) Join(a []string, sep string) string {
+ return strings.Join(a, sep)
+}
+
+func (su *StringUtils) Cut(s, sep string) []any {
+ before, after, found := strings.Cut(s, sep)
+ return []any{before, after, found}
+}
+
+func (su *StringUtils) EllipsisString(s string, max int) string {
+ return base.EllipsisString(s, max)
+}
+
+func (su *StringUtils) ToUpper(s string) string {
+ return strings.ToUpper(s)
+}
diff --git a/modules/templates/util_string_test.go b/modules/templates/util_string_test.go
new file mode 100644
index 0000000..5844cf2
--- /dev/null
+++ b/modules/templates/util_string_test.go
@@ -0,0 +1,20 @@
+// Copyright Earl Warren <contact@earl-warren.org>
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "html/template"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_StringUtils_HasPrefix(t *testing.T) {
+ su := &StringUtils{}
+ assert.True(t, su.HasPrefix("ABC", "A"))
+ assert.False(t, su.HasPrefix("ABC", "B"))
+ assert.True(t, su.HasPrefix(template.HTML("ABC"), "A"))
+ assert.False(t, su.HasPrefix(template.HTML("ABC"), "B"))
+ assert.False(t, su.HasPrefix(123, "B"))
+}
diff --git a/modules/templates/util_test.go b/modules/templates/util_test.go
new file mode 100644
index 0000000..79aaba4
--- /dev/null
+++ b/modules/templates/util_test.go
@@ -0,0 +1,79 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+ "html/template"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDict(t *testing.T) {
+ type M map[string]any
+ cases := []struct {
+ args []any
+ want map[string]any
+ }{
+ {[]any{"a", 1, "b", 2}, M{"a": 1, "b": 2}},
+ {[]any{".", M{"base": 1}, "b", 2}, M{"base": 1, "b": 2}},
+ {[]any{"a", 1, ".", M{"extra": 2}}, M{"a": 1, "extra": 2}},
+ {[]any{"a", 1, ".", map[string]int{"int": 2}}, M{"a": 1, "int": 2}},
+ {[]any{".", nil, "b", 2}, M{"b": 2}},
+ }
+
+ for _, c := range cases {
+ got, err := dict(c.args...)
+ require.NoError(t, err)
+ assert.EqualValues(t, c.want, got)
+ }
+
+ bads := []struct {
+ args []any
+ }{
+ {[]any{"a", 1, "b"}},
+ {[]any{1}},
+ {[]any{struct{}{}}},
+ }
+ for _, c := range bads {
+ _, err := dict(c.args...)
+ require.Error(t, err)
+ }
+}
+
+func TestUtils(t *testing.T) {
+ execTmpl := func(code string, data any) string {
+ tmpl := template.New("test")
+ tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
+ template.Must(tmpl.Parse(code))
+ w := &strings.Builder{}
+ require.NoError(t, tmpl.Execute(w, data))
+ return w.String()
+ }
+
+ actual := execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "a"})
+ assert.Equal(t, "true", actual)
+
+ actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "x"})
+ assert.Equal(t, "false", actual)
+
+ actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []int64{1, 2}, "Value": int64(2)})
+ assert.Equal(t, "true", actual)
+
+ actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "b"})
+ assert.Equal(t, "true", actual)
+
+ actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
+ assert.Equal(t, "false", actual)
+
+ tmpl := template.New("test")
+ tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
+ template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
+ // error is like this: `template: test:1:12: executing "test" at <SliceUtils.Contains>: error calling Contains: ...`
+ err := tmpl.Execute(io.Discard, map[string]any{"Slice": struct{}{}})
+ require.ErrorContains(t, err, "invalid type, expected slice or array")
+}
diff --git a/modules/templates/vars/vars.go b/modules/templates/vars/vars.go
new file mode 100644
index 0000000..cc9d0e9
--- /dev/null
+++ b/modules/templates/vars/vars.go
@@ -0,0 +1,92 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package vars
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+// ErrWrongSyntax represents a wrong syntax with a template
+type ErrWrongSyntax struct {
+ Template string
+}
+
+func (err ErrWrongSyntax) Error() string {
+ return fmt.Sprintf("wrong syntax found in %s", err.Template)
+}
+
+// ErrVarMissing represents an error that no matched variable
+type ErrVarMissing struct {
+ Template string
+ Var string
+}
+
+func (err ErrVarMissing) Error() string {
+ return fmt.Sprintf("the variable %s is missing for %s", err.Var, err.Template)
+}
+
+// Expand replaces all variables like {var} by `vars` map, it always returns the expanded string regardless of errors
+// if error occurs, the error part doesn't change and is returned as it is.
+func Expand(template string, vars map[string]string) (string, error) {
+ // in the future, if necessary, we can introduce some escape-char,
+ // for example: it will use `#' as a reversed char, templates will use `{#{}` to do escape and output char '{'.
+ var buf strings.Builder
+ var err error
+
+ posBegin := 0
+ strLen := len(template)
+ for posBegin < strLen {
+ // find the next `{`
+ pos := strings.IndexByte(template[posBegin:], '{')
+ if pos == -1 {
+ buf.WriteString(template[posBegin:])
+ break
+ }
+
+ // copy texts between vars
+ buf.WriteString(template[posBegin : posBegin+pos])
+
+ // find the var between `{` and `}`/end
+ posBegin += pos
+ posEnd := posBegin + 1
+ for posEnd < strLen {
+ if template[posEnd] == '}' {
+ posEnd++
+ break
+ } // in the future, if we need to support escape chars, we can do: if (isEscapeChar) { posEnd+=2 }
+ posEnd++
+ }
+
+ // the var part, it can be "{", "{}", "{..." or or "{...}"
+ part := template[posBegin:posEnd]
+ posBegin = posEnd
+ if part == "{}" || part[len(part)-1] != '}' {
+ // treat "{}" or "{..." as error
+ err = ErrWrongSyntax{Template: template}
+ buf.WriteString(part)
+ } else {
+ // now we get a valid key "{...}"
+ key := part[1 : len(part)-1]
+ keyFirst, _ := utf8.DecodeRuneInString(key)
+ if unicode.IsSpace(keyFirst) || unicode.IsPunct(keyFirst) || unicode.IsControl(keyFirst) {
+ // the if key doesn't start with a letter, then we do not treat it as a var now
+ buf.WriteString(part)
+ } else {
+ // look up in the map
+ if val, ok := vars[key]; ok {
+ buf.WriteString(val)
+ } else {
+ // write the non-existing var as it is
+ buf.WriteString(part)
+ err = ErrVarMissing{Template: template, Var: key}
+ }
+ }
+ }
+ }
+
+ return buf.String(), err
+}
diff --git a/modules/templates/vars/vars_test.go b/modules/templates/vars/vars_test.go
new file mode 100644
index 0000000..c543422
--- /dev/null
+++ b/modules/templates/vars/vars_test.go
@@ -0,0 +1,72 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package vars
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExpandVars(t *testing.T) {
+ kases := []struct {
+ tmpl string
+ data map[string]string
+ out string
+ error bool
+ }{
+ {
+ tmpl: "{a}",
+ data: map[string]string{
+ "a": "1",
+ },
+ out: "1",
+ },
+ {
+ tmpl: "expand {a}, {b} and {c}, with non-var { } {#}",
+ data: map[string]string{
+ "a": "1",
+ "b": "2",
+ "c": "3",
+ },
+ out: "expand 1, 2 and 3, with non-var { } {#}",
+ },
+ {
+ tmpl: "中文内容 {一}, {二} 和 {三} 中文结尾",
+ data: map[string]string{
+ "一": "11",
+ "二": "22",
+ "三": "33",
+ },
+ out: "中文内容 11, 22 和 33 中文结尾",
+ },
+ {
+ tmpl: "expand {{a}, {b} and {c}",
+ data: map[string]string{
+ "a": "foo",
+ "b": "bar",
+ },
+ out: "expand {{a}, bar and {c}",
+ error: true,
+ },
+ {
+ tmpl: "expand } {} and {",
+ out: "expand } {} and {",
+ error: true,
+ },
+ }
+
+ for _, kase := range kases {
+ t.Run(kase.tmpl, func(t *testing.T) {
+ res, err := Expand(kase.tmpl, kase.data)
+ assert.EqualValues(t, kase.out, res)
+ if kase.error {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}